Repository navigation
feat(approvals): single-use grants bound to the parked tool call - #745
Merged
Merged
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
blove
temporarily deployed
to
vercel-preview
September 19, 2026 02:33 — with
GitHub Actions
Inactive
blove
added a commit
that referenced
this pull request
Sep 19, 2026
The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI clients outweighs Model B's better mechanical fit. B's reasoning is preserved in "alternatives considered" because it explains the shape of what replaces it — A is implemented over B's machinery, parking the call and retaining the parked state while never showing it to the client. The crux is the retained record. Naive A identifies the result by a client-supplied toolCallId (inbound.ts:36) on an array the client resupplies in full every run. Instead the turn parks, the server keeps a durable record of the outstanding call as bookkeeping the client never sees, and a returning tool message is matched against that record before anything reaches the model. Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301) discards the client's input entirely on a resume — so the resupplied history never reconstructs state. That is the whole security argument, and it rests on existing behavior. Two findings changed conclusions rather than decorating them. First, the forgery risk is latent, not live. A role:"tool" message never reaches the model at all: it is dropped by agui-handler.ts:503, which collapses the array to the newest USER message, and again by agent-adapter.ts:1136, which filters to role === "user". So toolCallId has no consumer anywhere downstream. Naive A would CREATE that hole rather than inherit it, and naive A also simply does not work today — the result is silently discarded. The requirement is therefore a constraint on construction, not a blocking dependency on separate global work, and the earlier "blocking" label is withdrawn. Second, the first revision was wrong to say createReactAgent has no interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph 1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls interrupt(), which is the shape permission-gate.ts:466 already proves and the only mechanism that works against the code as it stands. postModelHook and interruptBefore are recorded as viable alternatives with their costs. Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes the replayed insert idempotent: LangGraph re-executes an interrupted tool node from scratch, and the provider tool-call id is stable across that cycle while interrupt_id is not — which is why #745 had to sweep an orphan grant row. Against #745 as implemented, client tool calls should NOT mint grants: a grant only works if disclosed and echoed, and under A the park is deliberately invisible, so disclosing one would be B wearing a different name. Records the cost — a park that never calls mintGrantForPark is invisible to the approvals.grants: "required" latch. Carried forward unchanged: the untrusted-definition threat model, client_ namespacing, the clientTool permission key that closes the gateToolOp exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422 for routes that never opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B4.run parked a tool call for human approval and resumed it when an answer arrived, but said nothing about how many times. The parked interrupt was addressed by `interruptId` and `resumeKey`, and neither is a credential: `interruptId` is a millisecond timestamp plus ~31 bits of `Math.random`, disclosed in the persisted envelope, and `resumeKey` is LangGraph's deterministic XXH3 of the checkpoint namespace. `ThreadAccessPolicy` gates who may touch a thread, but disclosure control is not consumption control — inside a session that legitimately held the thread, nothing stopped an approval being replayed, or a stale approval from an earlier proposal being applied to the current one. The only defence was emergent from LangGraph advancing the checkpoint; nothing enforced or tested it. A grant is 32 CSPRNG bytes minted at the park site in `@b4run/core`, stored only as a SHA-256 hash in a B4-owned `interrupt_grants` table added by an additive versioned migration under the existing `runMigrations` advisory lock. It rides out on the channels that already carry the prompt and comes back as an opaque `grant` on the resume entry. Consumption is a conditional `UPDATE ... WHERE consumed_at IS NULL AND voided_at IS NULL`, so the row count decides the winner: atomic, durable across a restart, correct across replicas. A replay is refused rather than re-executed. The minter is injected through LangGraph's `config.configurable` — the same channel this repo already uses for live per-call identity — so nothing in core's call graph grows a storage handle. That injection is optional by construction, and the absence fails closed: under `approvals.grants: "required"`, a park with no minter aborts the turn instead of parking a prompt nobody can answer safely. The mode deliberately does NOT travel the same way; a mode that went missing alongside the minter could never detect the minter's absence. Off by default. Refs #736, #738. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The endpoint test seeds grant rows against a stub graph and a canned pending write, and the core park-site test drives a bare compiled graph, so nothing showed that the minter the adapter injects into config.configurable still reaches the permission gate when the tool runs inside the agent loop, which #820 rebuilt on langchain createAgent. A real agent() route now parks an approve-gated tool through aimock, the grant is disclosed on pending_interrupts, one resume runs the tool once, a replay is refused with stale_interrupt without running it again, and a resume without the grant is refused with grant_required. Both fail when the adapter stops injecting the minter. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…roup Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
blove
force-pushed
the
blove/approval-capability-grants
branch
from
September 28, 2026 19:02
876d23b to
9488ba1
Compare
Contributor
Author
|
Rebased onto main at Conflicts, 13 files:
Added:
Checked on the rebased code:
The decisions listed in the description, Q4 ( |
blove
temporarily deployed
to
vercel-preview
September 28, 2026 19:03 — with
GitHub Actions
Inactive
blove
added a commit
that referenced
this pull request
Sep 30, 2026
The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI clients outweighs Model B's better mechanical fit. B's reasoning is preserved in "alternatives considered" because it explains the shape of what replaces it — A is implemented over B's machinery, parking the call and retaining the parked state while never showing it to the client. The crux is the retained record. Naive A identifies the result by a client-supplied toolCallId (inbound.ts:36) on an array the client resupplies in full every run. Instead the turn parks, the server keeps a durable record of the outstanding call as bookkeeping the client never sees, and a returning tool message is matched against that record before anything reaches the model. Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301) discards the client's input entirely on a resume — so the resupplied history never reconstructs state. That is the whole security argument, and it rests on existing behavior. Two findings changed conclusions rather than decorating them. First, the forgery risk is latent, not live. A role:"tool" message never reaches the model at all: it is dropped by agui-handler.ts:503, which collapses the array to the newest USER message, and again by agent-adapter.ts:1136, which filters to role === "user". So toolCallId has no consumer anywhere downstream. Naive A would CREATE that hole rather than inherit it, and naive A also simply does not work today — the result is silently discarded. The requirement is therefore a constraint on construction, not a blocking dependency on separate global work, and the earlier "blocking" label is withdrawn. Second, the first revision was wrong to say createReactAgent has no interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph 1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls interrupt(), which is the shape permission-gate.ts:466 already proves and the only mechanism that works against the code as it stands. postModelHook and interruptBefore are recorded as viable alternatives with their costs. Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes the replayed insert idempotent: LangGraph re-executes an interrupted tool node from scratch, and the provider tool-call id is stable across that cycle while interrupt_id is not — which is why #745 had to sweep an orphan grant row. Against #745 as implemented, client tool calls should NOT mint grants: a grant only works if disclosed and echoed, and under A the park is deliberately invisible, so disclosing one would be B wearing a different name. Records the cost — a park that never calls mintGrantForPark is invisible to the approvals.grants: "required" latch. Carried forward unchanged: the untrusted-definition threat model, client_ namespacing, the clientTool permission key that closes the gateToolOp exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422 for routes that never opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blove
added a commit
that referenced
this pull request
Sep 30, 2026
…ecord (#744) * docs(specs): settle client-provided tools on park-and-resume B4.run accepts `RunAgentInput.tools` and never reads it. #740 made that audible by rejecting a non-empty `tools` unless a route opts in, which left `server.agui.clientTools` meaning "tolerate tools I will never call". This settles the model rather than implementing it. The issue's central question — how the tool result returns — has a code-grounded answer: Model A ("end the run at the tool call") has no native expression in this substrate. `createReactAgent` is compiled with no `interruptBefore`/`interruptAfter` (agent-adapter.ts:188) and `interrupt()` at permission-gate.ts:466 is the only thing in the repo that halts a graph mid-turn. A would have to park and then discard the very record requirement 6 needs, so it is B plus amnesia rather than B minus complexity. The design is therefore B, with an adapter that translates an A-shaped client's tool message into the equivalent resume. It also records three findings that make this a design note and not a PR: single-use protection (requirement 6) is #736, which exists only as proposal #738; there is no capability surface at all to set `clientProvided` on (requirement 5), so satisfying it means inventing a new public endpoint; and the resume payload widening touches three symbols exported as public API from @b4run/cli. A replayed client tool result injects attacker-chosen content, so it must not ship on the emergent, untested replay protection that exists now. The threat model is written down rather than implied: AG-UI's own ToolSchema has `parameters: z.ZodAny` and an unbounded `description`, so the protocol imposes no limits on caller-authored prompt content. Namespacing what the model sees closes both name confusion and a live privilege-inheritance bug — `gateToolOp` matches exact names under the exact-match `tool` permission key, so a client tool named `readFile` would inherit an operator's existing `tool:readFile` allow rule and run with no prompt. Also corrects the docs, which did not say that opting in to `clientTools` accepts the field without executing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(specs): rewrite client-tools design around Model A The maintainer chose Model A: compatibility with existing CopilotKit/AG-UI clients outweighs Model B's better mechanical fit. B's reasoning is preserved in "alternatives considered" because it explains the shape of what replaces it — A is implemented over B's machinery, parking the call and retaining the parked state while never showing it to the client. The crux is the retained record. Naive A identifies the result by a client-supplied toolCallId (inbound.ts:36) on an array the client resupplies in full every run. Instead the turn parks, the server keeps a durable record of the outstanding call as bookkeeping the client never sees, and a returning tool message is matched against that record before anything reaches the model. Delivery is an internal resume, and toAgentInput (execute-route-core.ts:301) discards the client's input entirely on a resume — so the resupplied history never reconstructs state. That is the whole security argument, and it rests on existing behavior. Two findings changed conclusions rather than decorating them. First, the forgery risk is latent, not live. A role:"tool" message never reaches the model at all: it is dropped by agui-handler.ts:503, which collapses the array to the newest USER message, and again by agent-adapter.ts:1136, which filters to role === "user". So toolCallId has no consumer anywhere downstream. Naive A would CREATE that hole rather than inherit it, and naive A also simply does not work today — the result is silently discarded. The requirement is therefore a constraint on construction, not a blocking dependency on separate global work, and the earlier "blocking" label is withdrawn. Second, the first revision was wrong to say createReactAgent has no interruptBefore/interruptAfter. It has both, plus postModelHook, in langgraph 1.4.9; B4 just never passes them. The turn ends via a stub tool whose run calls interrupt(), which is the shape permission-gate.ts:466 already proves and the only mechanism that works against the code as it stands. postModelHook and interruptBefore are recorded as viable alternatives with their costs. Keying the record on (thread_id, tool_call_id) rather than interrupt_id makes the replayed insert idempotent: LangGraph re-executes an interrupted tool node from scratch, and the provider tool-call id is stable across that cycle while interrupt_id is not — which is why #745 had to sweep an orphan grant row. Against #745 as implemented, client tool calls should NOT mint grants: a grant only works if disclosed and echoed, and under A the park is deliberately invisible, so disclosing one would be B wearing a different name. Records the cost — a park that never calls mintGrantForPark is invisible to the approvals.grants: "required" latch. Carried forward unchanged: the untrusted-definition threat model, client_ namespacing, the clientTool permission key that closes the gateToolOp exact-match inheritance trap, the caps pinned to MAX_ZOD_DEPTH, and #740's 422 for routes that never opted in. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(web): regenerate SEO lastmod for the client-tools docs corrections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(specs): record the maintainer's answers to the client-tools open questions Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 30, 2026
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements #736, following the accepted design in #738 and the decision comment on open question 1.
Off by default (
approvals.grants: "off"). Nothing changes for an existing app unless it opts in.What this adds
A grant: 32 CSPRNG bytes minted when B4.run parks a human-in-the-loop approval, bound to that one parked call, required back when the approval is answered, and consumable exactly once.
@b4run/sdkInterruptGrantStore/InterruptGrantRecord/InterruptGrantConsumption,ApprovalGrantMinter,ApprovalGrantMode,createApprovalGrant,hashApprovalGrant,timingSafeHexEqual,createMemoryInterruptGrantStore(WebCrypto only — the main entry is loaded by the edge targets)@b4run/coreconfigureApprovalGrants/approvalGrantMode/mintGrantForPark/MissingApprovalGrantMinterError;approvalsonB4Config; the park site mints@b4run/langchainapprovalGrantMinter→config.configurable[APPROVAL_GRANT_MINTER_KEY]@b4run/cliresolveInterruptGrantStore@b4run/ag-uigranton the interrupt and onB4ResumeRequest;B4AguiInterrupt@b4run/postgres-storagecreatePostgresInterruptGrantStore+INTERRUPT_GRANTS_MIGRATIONS@b4run/sqlite-storagecreateInterruptGrantStore(theb4 devdefault)Storage uses the existing versioned
runMigrationsunderpg_advisory_xact_lock, with its own migration component so it versions independently. No column default anywhere, every INSERT names every column, and the frozen-migration rule is written on the migration where the next person to change the shape will read it. Pinned bytest/interrupt-grants-ddl.test.ts.How the minter is injected, and how it fails closed
Two facts have to reach
emitPermissionInterrupt, and they arrive by deliberately different routes:config.configurable, following thethreadIdprecedent atcapabilities/types.ts, and the park site reads it withgetConfig(). Nothing in core's call graph grows a storage parameter.configurableinjection is optional by construction — legacy invokers omit it exactly as they omitthreadId— so a mode carried inconfigurablewould go missing from precisely the invokers whose missing minter it is meant to catch, and "no mode and no minter" would be indistinguishable from "grants are off". Instead it is a process-wide latch set from the resolved config byprepareRouteExecutionand at runtime boot, and it ratchets up only: a second app root in one process cannot weaken a stricter mode already in force.The fail-closed rule: mode
"required"+ no minter ⇒MissingApprovalGrantMinterError, thrown beforeinterrupt(), so the turn aborts and nothing is parked. Asserted loudly inpackages/core/test/capabilities/approval-grants.test.ts, which drives a real compiled graph and then checks the checkpoint holds no pending interrupt — a refusal that still parked would be the bypass wearing a stack trace. The test carries a "do not relax this" note.Resume ordering
thread-access gate → tryClaim → readPendingInterrupts → resolvePendingResume → verify grants → consume → deliver, on bothPOST /threads/:id/resumeand the AG-UI resuming branch (one shared helper, so the two cannot drift). The gate-before-tryClaimordering documented atruntime-fetch-core.tsis preserved unchanged and grant checks inherit it wholesale.403 grant_invalidis the same status, code and message for wrong grant / no row / malformed / other thread, so the endpoint is not an oracle.409 grant_consumedechoesconsumedAt+consumedDecisionand does not re-execute. Superseded checkpoints void outstanding grants when a turn settles, giving409 stale_interrupt.Decisions taken — please object to any of these
Open questions 2–7 in §8 are not settled. Where I had to choose, I took the design's stated recommendation and the narrowest version.
deny/cancelledconsumes the grant: YES (the design's recommendation). A denial is a decision, and a re-answerable denial is a replay surface of its own. Tested end to end.interrupt()in a subagent node reads the parent's ambient config and therefore the parent's minter. Stated explicitly oncreateApprovalGrantMinterso a future out-of-process subagent cannot silently mint against a thread with no resume endpoint.alwaysis grantable, with no extra gate. Narrowest version. The design notesalwayswrites a durable permission rather than making one decision and may warrant something stronger; I did not add it. Most likely of these to deserve a "no".resumeKeystill crosses the wire. Unchanged. Removing it is a breaking change toAttachInterruptand the design calls it "a strict improvement" without settling it.grantTtlMsomitted ⇒ no expiry; a human approval may sit overnight) and no deprecation schedule encoded. The default is"off", not the design's"optional": defaulting to"optional"would change behavior for every existing app in a minor.interruptId'sMath.randomis unchanged. Out of scope here; it is not a credential under this design."off"means "mint nothing", not the design's §6 "mint but never require." A mode called "off" that still opens a store and writes a row per park is not off. Cost, stated in the code:"off"→"required"strands prompts parked while it was off (409 grant_unavailable); step through"optional", which is the migration window.resolvePendingResumerather than before. Strictly less oracle surface, and a grant is never checked against a body whose shape has not been validated.@b4run/sdk, matching each package's ownThreadsStoreprecedent and keeping the lockfile untouched. Say the word and both become type-only imports.resolveInterruptGrantStoreis optional onRuntimeBootFallbacks, likeloadThreadAccess, because that interface is exported. Absence is not a silent ungating: the park site still fails closed and resume answers409 grant_unavailable.What did not survive contact with the code
interrupt()throw,config.configurable.checkpoint_idisundefined(the parked checkpoint is not written yet) andresume_keydoes not exist either — it is LangGraph's XXH3-128 of the checkpoint namespace, computed when the write is persisted, by a hasher LangGraph does not export. Verified empirically. So the row storescheckpoint_ns, the graph position the park site does have and the preimage ofresumeKey, and the other two halves are enforced against the live checkpoint at resume (the interrupt must still be pending) plusvoidOutstandingwhen the thread moves on. That is strictly fresher than a copy of the checkpoint id would have been.writes. Design §1 argued for attaching it at projection time to avoid exactly this; the park site cannot, having no storage handle and no reach into the three disclosure paths, and attaching downstream is the CLI-minting shape Design proposal (no implementation): approval grants bound to the parked tool call #738's decision rejected. The hash-only store therefore protects the consumption ledger, not the checkpoint — anyone with read access to the checkpoint store can answer a still-parked prompt. Stated in the docs under its own heading, not buried.Interruptcannot carry a top-levelgrantalone. It isz.inferover a closed"strip"-modeInterruptSchemawith nograntkey, so a client that re-validates silently drops it — quietly unanswerable under"required". The grant is therefore emitted both top-level (on a widenedB4AguiInterrupt) and inmetadata, which survives the round trip. Readgrant, fall back tometadata.grant.interruptIdis regenerated (it always has been), and a second row is written that is never disclosed and never presented. It is swept byvoidOutstandingwhen the turn settles. Accepted and documented at the park site rather than traded for a dependency on LangGraph's unexported scratchpad.@b4run/web's test suite does not catch an invalidCallouttype — only the Next.js prerender does, at build. Cost me a build cycle; worth a cheap guard someday.Test plan
From the worktree root, one clean sequential run:
pnpm build→Tasks: 26 successful, 26 totalpnpm typecheck→Tasks: 54 successful, 54 totalpnpm lint→Tasks: 29 successful, 29 total(warnings only, all pre-existing — the two inexecute-route-core.tsreproduce on the file atHEAD)pnpm test→Test Files 1 failed | 557 passed | 22 skipped (580),Tests 1 failed | 6718 passed | 253 skipped (6972)node scripts/check-docs.mjs→Docs completeness check passed.The one failure is
@b4run/workspace test/source-bundle.test.ts > "enforces 64 MiB total before encoding or decoding content"— the known memory-pressure flake #742 also hit, in a package this PR does not touch. Alone:pnpm --filter @b4run/workspace test→Test Files 9 passed (9),Tests 195 passed (195).Real-Postgres lane (Testcontainers
postgres:16):B4_TEST_PGSTORAGE=1 pnpm --filter @b4run/postgres-storage test→Test Files 11 passed (11),Tests 117 passed (117), zero skips — including 12 concurrent consumes of one grant yielding exactly one"consumed"and 11"already_consumed"carrying the winner's decision, and bothvoidOutstandingshapes.New coverage: 9 core park-site cases (minting, fail-closed, the ratchet), 3 adapter-injection cases (including that a route param cannot shadow the minter), 12 resume-endpoint cases (replay refused and not re-executed, oracle-indistinguishability asserted by comparing two whole response bodies, the
"optional"bypass rule, cancelled-consumes, expiry, voiding, disclosure and re-readability), 7 AG-UI projection cases, 12 SQLite and 15 Postgres store cases.Formatted only the touched files with
pnpm exec biome check --write --config-path packages/config-biome/biome.json <files>. Changeset included. Not released, not merged.🤖 Generated with Claude Code