feat(server): BuyerAgent.sandbox_only — defense-in-depth for test-only agents#1322
Merged
Conversation
…y agents Phase 1.5 of #1269 (proposed during review on PR #1320). Adds a `sandbox_only?: boolean` field to `BuyerAgent` plus a framework-level gate that rejects requests from sandbox-only agents targeting non- sandbox accounts. WHEN TO USE IT. CI runners, internal QA agents, partner pre-prod environments. If a test agent's credential leaks, blast radius is bounded to sandbox accounts. Production agents leave the field unset (or `false`). GATE BEHAVIOR. - `agent.sandbox_only === true && ctx.account.sandbox !== true` → PERMISSION_DENIED with `error.details.scope: 'agent'`, `error.details.reason: 'sandbox-only'`, `recovery: 'terminal'`. - Account-less tools (provide_performance_feedback, list_creative_formats) pass — the sandbox/production axis doesn't apply when no account is in scope. - Runs AFTER Stage 4's status enforcement, so a suspended sandbox-only agent fails with status (not sandbox). COMPOSES with `Account.sandbox` from #1256 — same axis at the agent level. Adopters who set `Account.sandbox` on their resolver get the gate for free; those who don't see no behavior change (default treats unset as production-allowed for the agent's other capabilities). 7 new tests at `test/server-buyer-agent-sandbox-only.test.js` cover sandbox→sandbox success, sandbox→prod rejection with structured envelope, undefined/false `sandbox_only` permissive cases, account-less tool pass-through, status-before-sandbox ordering, and null-registry skip. Full suite: 7459 pass, 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…e 5 wiring check Per the design conversation on PR #1320: sandbox_only is the right default for any agent registered for testing purposes, including Addie (the storyboard runner). The harness token is literally `sk_harness_do_not_use_in_prod` — a leak should not yield production reach. Two coordinated changes: 1. Addie's seed entry in ONBOARDING_LEDGER gets `sandbox_only: true`. Adopters cloning this example get the safe default; production buyer agents leave the field unset. 2. accounts.resolve returns `sandbox: true` on every resolved account. The upstream is the AdCP mock-server — every account it serves IS a sandbox account by definition. Production adapters set sandbox: true only when they actually resolved a sandbox-flagged account from their backing store. Together these compose against the framework's sandbox_only gate: sandbox_only && account.sandbox !== true → PERMISSION_DENIED. The storyboard pass (Gate 2) confirms the gate behaves correctly end-to-end. New Gate 5 in the test: load-bearing pair check — asserts both sandbox_only:true on Addie and sandbox:true on the resolver remain present. A future refactor dropping either side of the pair fails Gate 5 explicitly AND fails Gate 2 implicitly (storyboard would 403 on every request), so the regression surfaces twice. Depends on the parent commit (BuyerAgent.sandbox_only, PR #1322); this branch is rebased on top of that one. When #1322 lands, this commit applies cleanly to main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… pair, strict-default comment, +2 tests Per coordinated review on PRs #1322 + #1320: 1. JSDoc on BuyerAgent.sandbox_only now explicitly names the load-bearing pair contract: for sandbox-only agents to admit any traffic, the resolver MUST return sandbox: true on the matching accounts. Mismatches 403 every request — failure is loud, not silent. 2. Inline comment on the gate's type cast in create-adcp-server.ts explaining the strict-by-default behavior: legacy/non-Account shapes lacking sandbox read as `undefined !== true → reject`. 3. Two new tests: - production agent + production account → handler explicitly invoked (vs. just non-error overall). - sandbox-only agent + account with explicit sandbox: false (vs unset) → rejected. Locks the `!== true` semantics in the gate. 4. Stripped absolute test count from changeset (rots on every test add). No behavior changes. Test count: 7 → 9. Full suite: green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…e 5 wiring check Per the design conversation on PR #1320: sandbox_only is the right default for any agent registered for testing purposes, including Addie (the storyboard runner). The harness token is literally `sk_harness_do_not_use_in_prod` — a leak should not yield production reach. Two coordinated changes: 1. Addie's seed entry in ONBOARDING_LEDGER gets `sandbox_only: true`. Adopters cloning this example get the safe default; production buyer agents leave the field unset. 2. accounts.resolve returns `sandbox: true` on every resolved account. The upstream is the AdCP mock-server — every account it serves IS a sandbox account by definition. Production adapters set sandbox: true only when they actually resolved a sandbox-flagged account from their backing store. Together these compose against the framework's sandbox_only gate: sandbox_only && account.sandbox !== true → PERMISSION_DENIED. The storyboard pass (Gate 2) confirms the gate behaves correctly end-to-end. New Gate 5 in the test: load-bearing pair check — asserts both sandbox_only:true on Addie and sandbox:true on the resolver remain present. A future refactor dropping either side of the pair fails Gate 5 explicitly AND fails Gate 2 implicitly (storyboard would 403 on every request), so the regression surfaces twice. Depends on the parent commit (BuyerAgent.sandbox_only, PR #1322); this branch is rebased on top of that one. When #1322 lands, this commit applies cleanly to main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…ME on sandbox: true Per coordinated review on PRs #1322 + #1320: - Dropped Gate 5 (read example source via fs.readFileSync, regex-match for `sandbox_only: true` and `sandbox: true`). Code-reviewer and security-reviewer both flagged it as brittle: the regex matches comments alone, so deleting the actual seed-entry / resolver lines would still pass the gate. Behavioral coverage is already provided by Gate 2 (storyboard runs end-to-end through the gate composition) and `test/server-buyer-agent-sandbox-only.test.js` (the framework gate's behavior in isolation). Replaced with a brief comment pointing readers at those. - Added inline `FIXME(adopter)` comment next to the resolver's `sandbox: true` line. Adopters cloning this who skip the comment-block above would otherwise mark all production accounts as sandbox in their real adapter, defeating the gate. The FIXME is right at the load-bearing line so it surfaces during copy-paste, not just on careful reading. Test count for the example: 5 → 4. Behavioral coverage unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Contributor
Author
Review fixes pushed (cd09abe)Coordinated review on PRs #1322 + #1320 — no blockers, four fixes for #1322:
Full suite green. Ready to land alongside #1320. |
7 tasks
Contributor
Author
|
Acknowledged — fixes noted (JSDoc contract wording, strict-by-default cast comment, two additional test cases, changeset cleanup). No action needed from triage; this is ready for human review to land alongside #1320. Generated by Claude Code |
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…e 5 wiring check Per the design conversation on PR #1320: sandbox_only is the right default for any agent registered for testing purposes, including Addie (the storyboard runner). The harness token is literally `sk_harness_do_not_use_in_prod` — a leak should not yield production reach. Two coordinated changes: 1. Addie's seed entry in ONBOARDING_LEDGER gets `sandbox_only: true`. Adopters cloning this example get the safe default; production buyer agents leave the field unset. 2. accounts.resolve returns `sandbox: true` on every resolved account. The upstream is the AdCP mock-server — every account it serves IS a sandbox account by definition. Production adapters set sandbox: true only when they actually resolved a sandbox-flagged account from their backing store. Together these compose against the framework's sandbox_only gate: sandbox_only && account.sandbox !== true → PERMISSION_DENIED. The storyboard pass (Gate 2) confirms the gate behaves correctly end-to-end. New Gate 5 in the test: load-bearing pair check — asserts both sandbox_only:true on Addie and sandbox:true on the resolver remain present. A future refactor dropping either side of the pair fails Gate 5 explicitly AND fails Gate 2 implicitly (storyboard would 403 on every request), so the regression surfaces twice. Depends on the parent commit (BuyerAgent.sandbox_only, PR #1322); this branch is rebased on top of that one. When #1322 lands, this commit applies cleanly to main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…ME on sandbox: true Per coordinated review on PRs #1322 + #1320: - Dropped Gate 5 (read example source via fs.readFileSync, regex-match for `sandbox_only: true` and `sandbox: true`). Code-reviewer and security-reviewer both flagged it as brittle: the regex matches comments alone, so deleting the actual seed-entry / resolver lines would still pass the gate. Behavioral coverage is already provided by Gate 2 (storyboard runs end-to-end through the gate composition) and `test/server-buyer-agent-sandbox-only.test.js` (the framework gate's behavior in isolation). Replaced with a brief comment pointing readers at those. - Added inline `FIXME(adopter)` comment next to the resolver's `sandbox: true` line. Adopters cloning this who skip the comment-block above would otherwise mark all production accounts as sandbox in their real adapter, defeating the gate. The FIXME is right at the load-bearing line so it surfaces during copy-paste, not just on careful reading. Test count for the example: 5 → 4. Behavioral coverage unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley
added a commit
that referenced
this pull request
May 2, 2026
…al_marketplace (#1320) * docs(example): wire BuyerAgentRegistry into hello_seller_adapter_signal_marketplace Every seller needs a buyer-agent registry. Updates the worked reference adapter to demonstrate the Phase 1 surface (#1269) end-to-end so adopters cloning the example get the full identity story by default rather than a partial scaffold. Adds: - ONBOARDING_LEDGER (in-memory Map; production replaces with a Postgres query) keyed by credential.key_id — the sha256 hash prefix verifyApiKey stamps, NOT the raw token. - BuyerAgentRegistry.cached(BuyerAgentRegistry.bearerOnly({...})) wired to the platform's agentRegistry field. Framework runs resolve() once per request; status enforcement (suspended/blocked → 403) fires before any handler — adopters don't reimplement the gate. - Demonstration site in accounts.resolve where adopters route tenant resolution against ctx.agent (operator-vs-agent gating, allowed_brands cross-checks). Currently a `void buyerAgent;` placeholder so adopters see the seam without imposing a specific check. Test additions: - New Gate 4 asserts unknown api-key tokens are rejected at the auth layer before the registry runs. Locks the auth-before-registry order and prevents future refactors from silently dropping the wiring. The existing three gates (strict typecheck, storyboard pass, façade traffic) still pass — the registry wiring is exercised implicitly by every storyboard step that authenticates. Full suite: 7448 pass, 0 fail. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(example): name Addie explicitly and document credential issuance flow Per review on PR #1320: - Renamed the seed onboarding-ledger entry from 'Compliance harness' to 'Addie (storyboard runner)' — Addie is the canonical AdCP buyer-agent persona used by storyboard infrastructure and signing docs. Makes the storyboard ↔ registry wiring legible: storyboards drive this adapter with the harness token; the token hashes to Addie's key_id and resolves to the seed record. Previously the connection was implicit. - Added a 'how credentials get issued' comment block documenting the adopter-side admin flow (generate token → hash → insert ledger row → hand raw token to buyer out-of-band). Adopters cloning the example no longer wonder where credentials come from in production. The sandbox-only capability (separate question raised in the same review) will land in a follow-up PR — adds `BuyerAgent.sandbox_only` field + framework gate, distinct from the example doc improvements here. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(example): mark Addie sandbox_only + accounts as sandbox; add Gate 5 wiring check Per the design conversation on PR #1320: sandbox_only is the right default for any agent registered for testing purposes, including Addie (the storyboard runner). The harness token is literally `sk_harness_do_not_use_in_prod` — a leak should not yield production reach. Two coordinated changes: 1. Addie's seed entry in ONBOARDING_LEDGER gets `sandbox_only: true`. Adopters cloning this example get the safe default; production buyer agents leave the field unset. 2. accounts.resolve returns `sandbox: true` on every resolved account. The upstream is the AdCP mock-server — every account it serves IS a sandbox account by definition. Production adapters set sandbox: true only when they actually resolved a sandbox-flagged account from their backing store. Together these compose against the framework's sandbox_only gate: sandbox_only && account.sandbox !== true → PERMISSION_DENIED. The storyboard pass (Gate 2) confirms the gate behaves correctly end-to-end. New Gate 5 in the test: load-bearing pair check — asserts both sandbox_only:true on Addie and sandbox:true on the resolver remain present. A future refactor dropping either side of the pair fails Gate 5 explicitly AND fails Gate 2 implicitly (storyboard would 403 on every request), so the regression surfaces twice. Depends on the parent commit (BuyerAgent.sandbox_only, PR #1322); this branch is rebased on top of that one. When #1322 lands, this commit applies cleanly to main. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(example): drop brittle Gate 5 string-match test, add adopter FIXME on sandbox: true Per coordinated review on PRs #1322 + #1320: - Dropped Gate 5 (read example source via fs.readFileSync, regex-match for `sandbox_only: true` and `sandbox: true`). Code-reviewer and security-reviewer both flagged it as brittle: the regex matches comments alone, so deleting the actual seed-entry / resolver lines would still pass the gate. Behavioral coverage is already provided by Gate 2 (storyboard runs end-to-end through the gate composition) and `test/server-buyer-agent-sandbox-only.test.js` (the framework gate's behavior in isolation). Replaced with a brief comment pointing readers at those. - Added inline `FIXME(adopter)` comment next to the resolver's `sandbox: true` line. Adopters cloning this who skip the comment-block above would otherwise mark all production accounts as sandbox in their real adapter, defeating the gate. The FIXME is right at the load-bearing line so it surfaces during copy-paste, not just on careful reading. Test count for the example: 5 → 4. Behavioral coverage unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 1.5 of #1269 — adds
BuyerAgent.sandbox_only?: booleanand a framework-level gate. Proposed during review on PR #1320 (Hello Seller adapter integration): every seller has test agents (CI runners, internal QA agents, partner pre-prod environments) that should ONLY operate against sandbox accounts. Without this field, sellers either accept that test agents have production reach or hand-roll the gate per platform.What's added
BuyerAgent.sandbox_only?: boolean— whentrue, the framework rejects any request whose resolvedAccount.sandbox !== truewith:```json
{
"code": "PERMISSION_DENIED",
"message": "Buyer agent is sandbox-only; this request targets a non-sandbox account.",
"recovery": "terminal",
"details": { "scope": "agent", "reason": "sandbox-only" }
}
```
Default
false/ undefined — production-allowed.Gate placement
Runs after
accounts.resolveso the framework can compareagent.sandbox_onlyagainstaccount.sandbox. Runs after Stage 4's status enforcement, so a suspended sandbox-only agent fails with status (not sandbox) — predictable error ordering.Account-less tools pass the gate.
provide_performance_feedback,list_creative_formats, etc. don't operate on a specific account; the sandbox/production axis doesn't apply.Composes with
Account.sandbox(#1256)Same axis at the agent level. Adopters who set
Account.sandbox: trueon their resolver get the gate for free; those who don't see no behavior change.What's NOT in (deferred)
sync_accountsrequest body (where each account inaccounts[]has its own sandbox flag) — adopter-side gating inaccounts.upsertfor now; Phase 2 candidate.AGENT_SANDBOX_ONLY_VIOLATIONstandardized error code — usingPERMISSION_DENIED + scope:'agent' + reason:'sandbox-only'as the structured signal until then. File upstream alongside adcp#3871 if the pattern broadens.Test plan
npm run typecheckclean.test/server-buyer-agent-sandbox-only.test.js— 7/7 pass:scope: 'agent',reason: 'sandbox-only',recovery: 'terminal'); handler not invoked.npm run format:checkclean.Cross-links
Account.sandboxfield this composes with)🤖 Generated with Claude Code