2026-06-10-inc1-scoped-agent-grants

ADR / Ship Log: Provably-Scoped Agent Action — Capability-Scoped Liaison Grants (Increment 1)

  • Status: ACCEPTED (locked, go-with-changes) — hardened by a 5-lens adversarial design panel (G1) before any code.
  • Date: 2026-06-10
  • Milestone: Pivot Increment 1 — first proof that BrainstormRouter is an authorization-and-evidence plane, not just a router.

Context

BR already has the strongest authorization primitive in the codebase — the single-use, HMAC-signed, replay-proof liaison plan contract — but it scopes only _cost_ (ceiling_usd). Every other authority dimension in BR (br_scope, behavior-cert resource_scopes, agent-JWT scopes) is modeled-but-not-enforced. Increment 1 generalizes the plan contract to scope which models and which tools an agent may use, enforces it on the executed request, and records every allow and deny in the tamper-evident completion_audit hash chain — proven adversarially.

Decision

Ride capability scope on the existing liaison plan (not a new object) for the fastest _provable_ spine. Default semantics are fail-closed. The G1 panel returned go-with-changes; the must-fix items below are the locked design.

Locked design (must-fix, all required before/within G2)

  1. Signature same-bytes. Eliminate the hand-built 8-field envelope literal at guardian.ts:439-451. Verify over the exact persisted record including allowed_models/allowed_tools. Export one shared ENVELOPE_FIELDS list from liaison-contract-signer.ts that both signPlanEnvelope and the guardian verify-call iterate, so adding a field without updating both sites is impossible. Add a sign→persist→verify round-trip test with non-empty scopes.
  2. Canonical array normalization. normalizeScopeList(x) = [...new Set(x.map(s=>s.trim()))].sort(), applied identically at sign and verify and on every store/Redis round-trip in plan.ts. Bump an explicit v:2 version field into the signed payload. Never omit-when-undefined (omission enables scope-downgrade-by-omission forgery — plans live only in the in-memory LRU with no integrity beyond the HMAC).
  3. Fail-closed absence semantics. Do NOT inherit checkResourceScope's empty==unrestricted branch. At mint in plan.ts: undefined allowed_models[modelLabel]; undefined allowed_tools[] (no tools). New matchCapabilityScope(allowed, requested) in src/security/scope-match.ts where [] ⇒ false (deny-all) and unrestricted requires an affirmative ['*'] sentinel. Guardian treats undefined as DENY. Lock with matchCapabilityScope([],'gpt-4')===false.
  4. Canonical model identity. Enforce allowed_models against the SAME resolved identity the executor uses: run body.model through parseModelRouterRef (+ resolveBareModelId), resolve via registry.findEndpoints, compare each resolved ${provider}/${modelId} against allowed_models as canonical exact-set membership (NOT glob; a variant suffix must not satisfy a non-variant grant). Store allowed_models as canonical catalog labels at mint. Reject body.model resolving to >1 endpoint when a plan is in play.
  5. Enforce on the EXECUTED model, not the requested one. Thread allowedModels into RouteCompletionParams from planRecord; reject any resolved ${provider}/${modelId} not in the grant at resolveEndpoint AND on every fallback/auto re-entry (model-router.ts:932, :1194) and pool expansion (model-router-select.ts). When a plan is in play, force allowFallback=false; reject body.models[] against a single-model grant. Abort with a hard scope error instead of swapping to brainstormrouter/auto; the completions handler calls recordAuthorizationDenial when the router throws it.
  6. Tool extraction across all channels. Extract names = union of body.tools[].function?.name ?? .name, body.functions[].name (legacy), forced body.tool_choice.function.name, and built-in tool .type (computer_, bash_, web_search). Fail-closed on any tool entry lacking an extractable name. Match via a dedicated name-glob in scope-match.ts (drop the path-only [^/]* semantics).
  7. Order + single-use. Scope checks BEFORE store.update marks committed=true; a plan_scope_exceeded 403 must leave committed=false (re-committable until TTL — no self-DoS, no probing oracle). Keep the whole check synchronous inside the existing no-await span (419→557): static top-level import of scope-match, precompute regex/canonical resolution synchronously — any introduced await reopens the single-use replay race.
  8. Fail-closed on body-parse when a plan is present. At guardian.ts:250-257, when X-BR-Plan-Id is present and body JSON parse fails, return 400 plan_invalid and do NOT next() (current fall-through is fail-open and skips the scope block entirely).
  9. Deny-path chain integrity. Split computeHash into a pure compute(prevHash, payload) + commitHead(tenantId, prevHash, eventHash) called ONLY after the durable insert succeeds. recordAuthorizationDenial must await the insert and commitHead on success; on failure do NOT advance the head and emit a high-severity security_event (audit.deny_record_failed). Advance through the Redis Lua CAS as source of truth (loop-recompute on conflict), not fire-and-forget.
  10. Verify ordering by monotonic key. Chain-verify reconstruction uses orderBy(asc(completionAudit.id)); drop toReversed() over desc(createdAt) (completion-audit-store.ts:189). Keep createdAt only for the since filter. Test: interleave allow+deny rows within one millisecond, assert verifyChain stays valid.
  11. Deny payload shape. recordAuthorizationDenial builds its hash input EXCLUSIVELY via buildAuditChainPayload (same 10-field funnel), costUsd/inputTokens/outputTokens/durationMs=null, isStreaming from body.stream, outcome:'denied', model = the SAME canonical resolved string the allowlist compared. Widen the store union (completion-audit-store.ts:31) to include 'denied'. Synthesize a deterministic non-forgeable requestId (deny_${randomUUID}) used in BOTH the insert and the hash input. Store deny reason / violated scope in NON-hashed columns only. Roundtrip test for outcome:'denied' with all-null cost/tokens.
  12. Deny coverage. Centralize all plan-block denials behind one denyAndAudit(c, {code, status, body, model}) helper that records THEN returns. Add a grep/lint test asserting no bare c.json 4xx return inside the plan-enforcement block bypasses it (~8 denial returns; any un-instrumented one is a silent evidence hole).

Open questions — resolved for Increment 1

  • Pre-deploy v1 plan compatibility: accept mass-invalidation of in-flight v1 plans on deploy (TTL ≤ 300s, in-memory only, low blast radius). Documented intentional choice; no legacy canonicalizer.
  • Single-model grant + fallback: hard-disable fallback when a plan is in play. Multi-model grants are a later increment.
  • Tool names in prompt_fingerprint: defer the double-binding; document that allowed_tools HMAC is currently the sole binding, so any extraction bug is a direct bypass (covered by tests).
  • Per-tenant monotonic sequence column: scope the schema change now (so suppressed denials surface as a numbering gap); gap-verification logic lands next increment.

Adversarial test obligations (G2 must produce, all green)

Signature tamper of scope fields fails verify; round-trip sign→persist→verify with non-empty scopes; matchCapabilityScope([], x)===false; out-of-grant tool → 403 across all tool channels; out-of-grant model incl. alias/variant/fallback-swap → 403; replay of a committed scoped plan → plan_already_committed; a denied 403 leaves committed=false; every denial appears in completion_audit with outcome:'denied' and /v1/audit/verify reports an unbroken chain (incl. sub-millisecond allow/deny interleave); body-parse failure under a plan → 400, not fail-open.

Lockstep obligations (G3)

The liaison plan request gains allowed_models/allowed_tools. Propagate to: TS SDK, Py SDK (sync+async), MCP tool schema + agents.json, docs, website, and regenerate llms-full.txt/OpenAPI.

Known limitation & follow-up: audit-chain crash-safety

Residual risk (pre-existing, surfaced by Codex pass 3, P1). The tamper-evident completion-audit chain stores its per-tenant head in Redis (Lua CAS, source of truth) while the audited rows live in Postgres. This Redis-head / Postgres-rows split means head-advance and durable-row-insert are not a single atomic unit on either path. The deny-path (recordAuthorizationDenial in src/api/routes/completions/helpers.ts) advances the head via commitHead before the durable store.log insert (CAS-then-insert ordering, which is deliberate — it prevents the double-insert/fork an earlier review flagged). If the insert failed after the head had advanced, the tenant head would point at an event hash with no backing row, permanently breaking verifyChain for that tenant. The allow-path (recordCompletionAudit) is best-effort/fire-and-forget and has the same underlying split.

Bounded mitigation applied (this increment, deny-path only). On a durable-insert failure after a successful commitHead, the deny-path now attempts a best-effort, guarded head ROLLBACK: it CASes the head from the just-committed eventHash back to the prevHash it advanced from, only if the head still equals that eventHash. The primitive is rollbackChainHeadCas(tenantId, fromEventHash, toPrevHash) in src/security/audit-chain.ts, which reuses the same Redis Lua CAS machinery as commitHead (no second mechanism). The CAS-then-insert ordering and the critical audit.deny_record_failed security event on failure are both retained.

  • What it guarantees: in the common single-writer transient-insert-failure

case the head returns to prevHash, leaving no dangling gap — the next denial or completion chains cleanly and verifyChain stays valid.

  • What it does NOT guarantee (residual caveat): in the rare concurrent case

where another writer advanced the head past the just-committed eventHash before the rollback runs, the guarded CAS declines (it does not force the head, which would fork the chain) and leaves the head advanced. In that case the deny-path degrades to exactly the same best-effort level as the pre-existing allow path: a detectable gap behind an advanced head, surfaced by the critical audit.deny_record_failed event. This bounded fix is not full crash-safety: a process crash between commitHead and the insert, or a Redis-unreachable rollback, still leaves the gap (still detectable, never a silent fork).

Locked by tests: src/api/routes/completions/helpers-deny-cas.test.ts (commit-success → insert-failure asserts the critical event AND the guarded rollback to prevHash, single-writer recovery; plus no-rollback on the happy path and no double-insert on CAS conflict), src/security/audit-chain-deny.test.ts (in-memory single-process rollback restores the head and a subsequent deny verifies), and src/security/audit-chain-rollback.test.ts (Redis-Lua-CAS guard: rolls back when the head still equals the committed hash, declines without forcing when a concurrent writer advanced past it).

Related — concurrent-denial ordering (Codex follow-up, document-only). Codex also flagged that under concurrency two denials can land out of id order because the Redis chain head advances (commitHead) before the durable Postgres row insert (recordAuthorizationDenial, helpers.ts:~403). This is the same Redis-head / Postgres-rows split root cause as the crash-safety risk above — not a separate bug — and is therefore also covered by the planned DB-transactional chain-head increment (Increment 1.5), not by Inc1's bounded mitigation. Inc1 does not attempt to order concurrent denials; chain-verify already reconstructs by orderBy(asc(completionAudit.id)) (locked design item 10), so the durable rows remain verifiable once written.

Planned next increment (own design gate). Full crash-safety: a DB-transactional chain head covering BOTH allow + deny, removing the Redis-head / Postgres-rows split so the head-advance and the durable row commit or roll back atomically. That redesign is intentionally out of scope here and is scheduled as its own increment behind a dedicated design gate. It subsumes both the crash-safety gap and the concurrent-denial ordering note above.

Known limitation & follow-up: durable chain recording requires Postgres

Durable, tamper-evident chain recording of denials requires Postgres; without it (airgapped / Postgres-less deployments), denials are still enforced (the request is denied, fail-closed) and loggedrecordAuthorizationDenial (src/api/routes/completions/helpers.ts) now emits a critical audit.deny_record_failed security event, falling through to a CRITICAL structured-log floor when no event store is reachable — but they are not chain-recorded. Previously this no-Postgres path returned null with no event at all, so on those deployments the "every denial is recorded in the tamper-evident chain" guarantee degraded silently to enforcement-only. The denial is now never silently invisible: the evidence degradation is observable. Locked by src/api/routes/completions/helpers-deny-no-postgres.test.ts (no-Postgres denial still returns null/denies, writes no chain row, and emits the critical event / CRITICAL log floor). Full durable chain recording on Postgres-less deployments is out of scope for Inc1.

Hardening: chain verification now detects row suppression, not just mutation

verifyChain (src/security/audit-chain.ts) previously validated only each row's eventHash against its own stored prevHash + payload — detecting content mutation but not row suppression/fork: a privileged mid-chain DELETE left the surviving rows internally self-consistent and verify still returned valid:true. It now also asserts adjacent-row link continuityentries[i].prevHash === entries[i-1].eventHash — between every pair of consecutive present rows, reporting valid:false with brokenAt set to the discontinuity. The window boundary is handled by asserting continuity only between two rows both present in the verified set (from i=1 onward); the first row of a fetched window is never failed on continuity, because the chain-recent window legitimately starts mid-chain with a prevHash pointing at a row outside the window. Locked by src/security/audit-chain.test.ts (suppressed mid-chain row → valid:false at the gap; intact chain → valid:true; recent-window starting mid-chain does not false-fail on its first row) and the existing sub-millisecond allow/deny interleave in src/security/audit-chain-deny.test.ts.