require_approval now means one thing on every enforcement point — a reviewable queue, not a silent block

2026-09-17

securitygovernancetool-firewalltool-governancemcpapprovals

LOCKSTEP TRACEABILITY MATRIX --- api_endpoints: [ "POST /v1/approvals/{id}/approve (scope: exact|tool; tool_call allowance)", "POST /v1/approvals/queue (type: tool_call)", "POST /v1/chat/completions (403 approval_required + X-BR-Approval-Id)", ] sdk_methods_updated: [ "approvals.approve({ scope })", "approvals.approve(scope=) sync + async", "ApprovalRecord.type += tool_call", ] mcp_tools_updated: ["none (br_list_pending_approvals now lists tool_call records)"] ---

What We Built

The policy vocabulary had a require_approval action, and the EU AI Act policy pack used it for its Article 14 human-oversight rules. But the action meant three different things depending on which enforcement point saw it. The MCP governor queued the call into the tenant's durable approvals store, where /v1/approvals/pending could show it. The tool-call firewall mapped it to a plain block, with a comment admitting it had no approval queue. The streaming interceptor severed the stream with an APPROVAL_REQUIRED header and queued nothing. On the completions path, "human oversight" was a denial with nobody to notify.

One module, security/approval-queue.ts, now gives the firewall and the interceptor the same durable queue the MCP path and the REST surface already use. For a tool call the _client_ executes, the semantic is honest and simple: the call is withheld now and a tool_call review record is queued; approving it mints a one-shot allowance; the caller retries the same request and the enforcement point finds the allowance, consumes it, and lets the call through. Allowances match the exact argument hash the reviewer saw unless the reviewer widens the grant to the tool name with scope: "tool", and the widening is recorded on the record. Allowances lapse after fifteen minutes and can never be spent twice. A retry before approval dedupes onto the pending record instead of spamming the reviewer.

The response is now a structured 403 approval_required with the approval id, a recovery block naming the approve endpoint, and an X-BR-Approval-Id header on both streaming and non-streaming paths. It is no longer logged as a firewall _violation_ for auto-revoke: an agent asking for something that needs a human is not an attacker, and its mTLS cert is no longer revoked for it.

Why It Matters

"Allow, block, or require approval on any action" is the sentence every agent-governance vendor now leads with. BrainstormRouter could say it for MCP calls and not for the model's own tool calls, which is where most agent actions originate. Now one policy rule produces one behavior everywhere, and the artifact an auditor holds can show a human actually reviewed the action the EU AI Act says a human must be able to review.

How It Works

policy pack says require_approval
  ├─ firewall / interceptor: consumeAllowance(tool, argsHash)
  │     ├─ hit  → allowance spent, call PASSES (verdict.approval.state = allowance_consumed)
  │     └─ miss → queue(tool, argsHash, argKeys, surface, requester, owner) → withhold
  │                403 approval_required · X-BR-Approval-Id · recovery → POST /v1/approvals/{id}/approve
  └─ reviewer approves (scope exact|tool; high-risk tools need a note) → record.status = approved
       └─ caller retries the same request → hit

Reviewers see the tool name, the surface, the requesting agent and its accountable owner (owner binding from 2026-09-15), and the argument _keys_. Argument values are never stored in the queue. Risk scoring treats shell, SQL, exec and the dangerous-tools list as high, which forces a review note. Both enforcement points are total: a queue outage withholds the call with an honest unqueued:approval_queue_error id instead of throwing, and a missing queue reports unqueued:no_approval_queue instead of pretending.

The Numbers

SurfaceBeforeAfter
MCP governordurable queuedurable queue (unchanged)
Tool-call firewallsilent blockqueued, id on verdict, allowance retry
Streaming interceptorsevered, no recordqueued, id in headers, allowance retry
Client responsetool_firewall_violation 400approval_required 403 + recovery
Auto-revoke on approvalyes (mTLS cert revoked)no

Competitive Edge

Identity gateways approve _credentials_. BrainstormRouter approves the _action_ the model asked for, with the arguments the reviewer saw hashed into the allowance, and records the decision beside the routing, cost and policy evidence for that request. That is the difference between "a human said yes to this agent" and "a human said yes to this call."

Lockstep Checklist

  • [x] API Routes: approve scope; tool_call type; completions approval_required response.
  • [x] TS SDK: ApprovalRecord.type, allowanceScope, consumedAt; ApprovalActionParams.scope.
  • [x] Python SDK: approve(scope=) sync + async.
  • [x] MCP Schemas: no new tool; agents.json regenerated (schema unchanged).
  • [x] Docs: docs/api-reference/governance.mdx (Human approval section); openapi.yaml merged.
  • [ ] Master Record: capability record entry — follow-up with owner binding.
  • [ ] Production verification: deploy; install the EU AI Act pack on a test tenant; call a restricted tool; confirm 403 approval_required, approve via /v1/approvals/{id}/approve, retry and confirm pass with allowance_consumed in the verdict reasons.