Skip to content

Insecure-by-default gates: test-controller sandbox marker, ungated comply controller, and ref-routed tenant resolution #2428

Description

@bokelley

From codex-security candidate verification. Items 1 and 2 were reproduced against the built dist and are straightforward code fixes. Item 3 is a deliberate documented default that needs a maintainer decision, not a patch.

The common thread: three places where a warning stands in for a gate. In all three the repo already agrees with itself on the right shape — createAdcpServer refuses InMemoryStateStore outside {NODE_ENV=test, NODE_ENV=development} unless ADCP_DECISIONING_ALLOW_INMEMORY_STATE=1, and assertSandboxAccount fails closed on undefined. A state-mutating test surface deserves at least what an in-memory store gets.

1. Test-controller bridge: the documented trust boundary does not hold (candidate-cd887c5188044f75) — High

The scan's framing ("when no account resolver is configured") understates this. Reproduced against a server that does wire resolveAccount:

construction warns matching trust-boundary: 0
creatives: [ 'real-1', 'FIXTURE-LEAK' ]
response sandbox flag: true

Request was list_creatives with no account field and context: { sandbox: true }.

Cause: create-adcp-server.ts:5007 runs the resolver only when params.account != null, and the else if at :5047 requires resolveAccountFromAuth. Wire only resolveAccount — the primary documented path — and ctx.account stays undefined for any request omitting the account ref, so the merge gate's ctx.account === undefined || arm admits on a buyer-supplied marker alone. The #1784 warning at :3745 fires only when both resolvers are absent, so the misconfiguration that actually matters is silent. Same hole when resolveAccountFromAuth returns null (:5062, unknown principal).

Impact: seeded-fixture disclosure into production read responses, plus responses falsely stamped sandbox: true — a wire-honesty violation. Not by itself cross-tenant real-data disclosure, but makeAutoSeedBridge (from-platform.ts:6535-6537) falls back to the buyer-claimed account_id when ctx.account is absent, so the fixture namespace is caller-selectable.

Second, independent defect found while confirming: the gate compares ctx.account.sandbox === true literally and never calls getAccountMode. An account resolved as { account_id: 'sb', mode: 'sandbox' } is refused (reproduced: mode:sandbox account -> creatives: []). So the bridge gate and the comply-controller gate implement the same trust boundary two different ways, and adopters who migrate to mode per src/lib/server/account-mode.ts silently lose their fixtures.

Fix: require ctx.account !== undefined for the merge; use isSandboxOrMockAccount(ctx.account) rather than raw .sandbox === true; fire the construction warning unless both resolvers are wired.

2. Comply controller: env flags suppress the warning rather than gating anything (candidate-ecd76e4fd9107db2) — Medium

src/lib/testing/comply-controller.ts:338-355, :685-778. With no sandboxGate, reproduced:

force response: {"status":"completed","ok":true}
adapter invoked with: [{ media_buy_id: 'mb_victim', status: 'completed', ... }]

Note what ADCP_SANDBOX / ADCP_COMPLY_CONTROLLER_UNGATED at :757-763 actually do: they silence the warning. They gate nothing. That is the inverse of the pattern used correctly elsewhere in this repo.

Partial refutation worth recording: createAdcpServerFromPlatform bypasses controller.register and registers the tool itself behind a real resolved-account gate (from-platform.ts:2394-2620) — Phase 2 of #1435 is present, contradicting the stale "lands in a follow-up" comment at src/lib/server/account-mode.ts:11-13 (worth deleting). So this is scoped to adopters calling createComplyController(...).register(server) directly via @adcp/sdk/testing.

Two residual questions: the mcp == null branch at :2503-2508 deliberately falls back to the ungated controller.register(server), and it's unclear whether A2A dispatch routes through the MCP-registered handler (and therefore the gate) or has its own path. Both worth a targeted check.

Fix: make sandboxGate required, or fail closed in register() unless NODE_ENV ∈ {test, development} and an explicit ack env is set.

3. createTenantStore ref-routed default — needs your call, not a patch (candidate-bd22564f2d8ecfb6)

REFUTED as an undiscovered bug. src/lib/server/decisioning/tenant-store.ts:217-235 behaves exactly as the scan described, but test/lib/create-tenant-store.test.js:109-117 is a deliberate characterization test — its name is "default ('ref-routed') resolves a cross-tenant ref without checking the caller" — paired with four 'auth-scoped' tests proving the fail-closed alternative works. CHANGELOG.md:259 records that refAccess was added specifically to close this, and that the docs were corrected because they "previously implied resolve was always an isolation gate; it is not." The module header, the refAccess JSDoc, and the createTenantStore JSDoc all say so.

What is real is a docs gap with real teeth. skills/build-holdco-agent/SKILL.md — the file an agent-builder actually reads, whose front-matter promises "per-tenant data isolation" and whose § "Account resolution + tenant-isolation gate" presents createTenantStore as the answer — never mentions refAccess. Its "What the helper guarantees" list covers upsertRow / syncGovernanceRow / non-writability and omits the resolve carve-out entirely.

That matters more than "reads" implies: accounts.resolve is the account path for create_media_buy and update_media_buy too, so under the default a Meridian credential naming Pinnacle's operator resolves Pinnacle's Account (including its ctx_metadata) and every downstream handler runs scoped to Pinnacle — cross-tenant spend, not just cross-tenant reads. The upsert/syncGovernance gate doesn't cover this; those only guard sync_accounts / sync_governance.

Recommendation:

  • Now: add the resolve carve-out to skills/build-holdco-agent/SKILL.md, in the "What the helper guarantees" list.
  • Next major: flip the default to 'auth-scoped', keeping 'ref-routed' available behind an explicit opt-in. This is the one item here that breaks real agency-hub adopters, which is why it's your decision rather than a patch.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions