Skip to content

refactor(approval): make the gate unconditional for mutating tools - #33

Merged
IBJunior merged 1 commit into
mainfrom
refactor/always-on-approval-gate
Sep 13, 2026
Merged

IBJunior merged 1 commit into
mainfrom
refactor/always-on-approval-gate

Conversation

@IBJunior

Copy link
Copy Markdown
Member

The template shipped a gate on / gate off switch in the composer toolbar that disabled human approval for every mutating tool. Cameron's first design rule is that it never moves money without an explicit human decision — a control that turns that off contradicts the product it belongs to.

Removed from everything a request can reach

  • the toolbar toggle (MessageInput.tsx)
  • approveAllTools state in UISettingsContext
  • the approveAllTools query param — the route no longer reads it
  • its entry in the stream's OpenAPI schema (a documented bypass invites a client to send one)
  • the field on MessageOptions, the request-shaped type
  • the pass-through in agentService.ts

The middleware is now installed for anything reachable over HTTP, unconditionally.

One caller keeps the bypass

The eval harness (eval/run.mts) drives the agent factory in-process and has no human to answer an interrupt — without a bypass, every mutating eval case would hang. It passes bypassApprovalForEval: !testCase.approval directly to getAgent().

Renamed from approveAllTools so nothing mistakes it for a product setting, and documented on AgentConfigOptions as harness-only. It is unreachable over HTTP by construction: nothing in the route, the wire protocol, or the UI can set it.

Approval-gate eval cases still leave it unset — that's the behavior they grade.

Why there's a test for this

approvalGate.test.ts pins the boundary at each layer. The failure it guards against is silent: a bypass wired back into the route would still typecheck, still stream, and quietly write to a real ledger. The assertions are source-level, in the same spirit as capabilities.test.ts — what needs pinning is that a future edit doesn't reintroduce the path, which no runtime assertion observes.

I verified the tests actually fail by reintroducing the param in the route: the corresponding test failed, the other seven passed.

Verification

  • 221 tests pass (8 new), tsc --noEmit clean on both the root and eval trees, production build succeeds, prettier clean.
  • Adversarial live test: a request sending approveAllTools=true explicitly still paused at the gate (AI:PENDING), and the DB was unchanged — 12 transactions before and after, zero rows at the test amount.
  • The ordinary approve path still works: resuming with allowTool=allow ran the tool and committed the row (12 → 13).
  • Toolbar confirmed clean in the browser: no "gate on"/"gate off" text; model picker, attach, and char count intact.
  • Dev stack only (mydb_dev:5544, port 3101). Production untouched; nothing deleted.

Note on existing users

A stale approveAllTools: true may remain in some browsers' localStorage under agent_model_settings. Nothing reads it any more, so it is inert — no migration needed, and it cannot re-enable a bypass.

🤖 Generated with Claude Code

The template shipped a "gate on/off" switch that disabled human approval
for every mutating tool. Cameron's first design rule is that it never
moves money without an explicit human decision, so a control that turns
that off contradicts the product.

Removed from every surface a request can reach: the toolbar toggle, the
`approveAllTools` context state, the query param, its OpenAPI schema
entry, and the field on `MessageOptions`. The route no longer reads such
a param, and agentService no longer forwards one to the agent factory.

The bypass survives for exactly one caller. The eval harness drives the
factory in-process and has no human to answer an interrupt, so without it
every mutating case would hang. It is renamed `bypassApprovalForEval` so
nothing mistakes it for a product setting, and it is unreachable over
HTTP by construction.

`approvalGate.test.ts` pins that boundary at each layer. The failure it
guards against is silent — a bypass wired back into the route would still
typecheck, still stream, and quietly write to a real ledger — so the
assertions are source-level, like capabilities.test.ts.

Verified against the dev stack: a request sending `approveAllTools=true`
explicitly still paused at the gate and wrote nothing, and the ordinary
approve path still committed the row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@IBJunior
IBJunior merged commit 40d7b62 into main Sep 13, 2026
1 check passed
@IBJunior
IBJunior deleted the refactor/always-on-approval-gate branch September 13, 2026 18:57
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