Skip to content

feat(server): BuyerAgent.sandbox_only — defense-in-depth for test-only agents#1322

Merged
bokelley merged 2 commits into
mainfrom
bokelley/buyer-agent-sandbox-only
May 2, 2026
Merged

feat(server): BuyerAgent.sandbox_only — defense-in-depth for test-only agents#1322
bokelley merged 2 commits into
mainfrom
bokelley/buyer-agent-sandbox-only

Conversation

@bokelley

@bokelley bokelley commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 1.5 of #1269 — adds BuyerAgent.sandbox_only?: boolean and 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 — when true, the framework rejects any request whose resolved Account.sandbox !== true with:

```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.resolve so the framework can compare agent.sandbox_only against account.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: true on their resolver get the gate for free; those who don't see no behavior change.

What's NOT in (deferred)

  • Per-row sandbox enforcement on sync_accounts request body (where each account in accounts[] has its own sandbox flag) — adopter-side gating in accounts.upsert for now; Phase 2 candidate.
  • Spec issue for AGENT_SANDBOX_ONLY_VIOLATION standardized error code — using PERMISSION_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 typecheck clean.
  • test/server-buyer-agent-sandbox-only.test.js — 7/7 pass:
    • Sandbox-only agent → sandbox account: succeeds.
    • Sandbox-only agent → non-sandbox account: PERMISSION_DENIED with structured envelope (scope: 'agent', reason: 'sandbox-only', recovery: 'terminal'); handler not invoked.
    • Production agent (sandbox_only undefined): both account types succeed.
    • Production agent (sandbox_only: false): explicit false matches default.
    • Account-less tool: gate skipped.
    • Status enforcement fires BEFORE sandbox-only check (suspended sandbox-only agent → status error, not sandbox error).
    • Null registry result: gate skipped (no agent to check).
  • npm run format:check clean.
  • Full suite: 7459 pass, 0 fail.

Cross-links

🤖 Generated with Claude Code

…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>
@bokelley

bokelley commented May 2, 2026

Copy link
Copy Markdown
Contributor Author

Review fixes pushed (cd09abe)

Coordinated review on PRs #1322 + #1320no blockers, four fixes for #1322:

  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.
  2. Strict-by-default inline comment in create-adcp-server.ts explaining the type cast: legacy/non-Account shapes lacking sandbox read as undefined !== true → reject.
  3. +2 tests (7 → 9): production-agent + production-account explicitly invokes handler; explicit sandbox: false (vs unset) on sandbox-only agent rejects.
  4. Stripped absolute test count from changeset (rots).

Full suite green. Ready to land alongside #1320.

@bokelley

bokelley commented May 2, 2026

Copy link
Copy Markdown
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 bokelley merged commit 7ebc3fe into main May 2, 2026
10 checks passed
@bokelley bokelley deleted the bokelley/buyer-agent-sandbox-only branch May 2, 2026 19:47
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant