# What a tool-call approval actually approves

Author: Chris Chabot
Canonical URL: https://chabot.dev/blog/what-a-tool-call-approval-actually-approves/
Published: 2026-11-03

Binding a human yes to one tool call, with exact inputs, policy revision, thread and turn, spending it once across replicas, and asking where the last check sits.

When an agent stops and asks whether it may run a command, the person answering believes they are deciding about that command. The simplest implementation records something much looser, a flag on a pending request, and that flag then has to survive a trip back through a gateway that may be running on several replicas, a policy that someone can tighten while the prompt sits unanswered, and an agent that can retry with slightly different arguments. A yes stored as a boolean has no way of noticing any of this, so it applies to whatever happens to be pending when it arrives.

In August 2026 I set about making that yes narrower on the agent platform I work on. An approval should mean one tool call, with these exact inputs, in this thread and turn, under this version of the policy, once.

Before there is a decision to bind, something has to work out whether to ask at all. Policy in the gateway's request path arrived in six layers: server, managed, tenant, project, thread and turn. What makes a stack like that usable is that no layer can undo a deny, so a managed deny beat a project allow and a turn-level ask together, and the test for that arrangement also checks that the denial is attributed to the managed layer. Beyond that, the highest-precedence match won, an ask beat an allow at equal precedence, and where nothing matched, reads were allowed and anything with an effect asked. The classifier leaned the same way, assuming that every MCP tool mutates something external and that a tool name it doesn't recognise has full access.

The stack also reduced to one value, a hash over its sorted layers and their rules, and that revision is what turned "the policy this was approved under" into something that could be compared later. A stored profile that failed to parse became a deny-all layer with a revision derived from the parse error, so a corrupted configuration failed closed.

When the answer was ask, the agent computed a digest of the canonicalised tool input and sent it on the approval card together with the tool name, a preview of the arguments and the MCP server involved. The person read the preview and the system kept the digest. When the decision came back, the gateway resolved the policy again instead of assuming the world had stood still while the prompt was open. An allow that the current policy would deny was downgraded to a single denial, and an allow that arrived with a patch to the input was rejected with a 400, because approving an edited call is a different decision from the one the card asked for.

An allow that survived became a receipt, an HMAC-signed token naming the tool, the input digest, the policy revision, the thread, the turn, the environment, the decision, who made it and when it expired. It lasted five minutes by default and fifteen at most. Verifying it was deliberately dull: an exact segment count, a timing-safe comparison of the signature, validation of the claims and an expiry check. Consuming it was where the binding did its work, because the claims were compared with the execution actually pending, and a receipt for one command presented against another failed with the pleasantly literal complaint that it "does not match the pending execution".

A signature can't make anything single-use, though. It proves who issued the receipt, and any replica holding the key will go on accepting it until it expires, so the memory of having spent it has to live somewhere the replicas share. Here that was one insert into a shared key-value store, a compare-and-swap with an expected version of zero, which succeeds only if the key doesn't exist yet. The first consumer won, and everyone after it hit a version conflict and was told the approval had "already been consumed".

The decision I'd defend hardest is what that key was made of. It was a hash over the approval ID, tool name, input digest, policy revision, thread, turn and environment, with the receipt's own ID left out on purpose. Had it been included, two processes that each minted a receipt for the same approval would have got two different keys and both would have spent successfully; one of the tests mints exactly that second receipt in another process and expects it to be refused. What gets spent is the authority, not the piece of paper describing it. The spent record also outlived the receipt by the maximum receipt lifetime, so that a sibling minted late couldn't outlast the record that would refuse it, and because the expiry was stored on the row, the ledger pruned itself once nothing could verify against it anyway.

That gives you one spend of one approval across replicas and restarts. It isn't exactly-once execution of the tool: consuming an approval does not establish whether an external effect completed. Two smaller decisions reinforced it. The function that sent an approval downstream threw if handed an allow without a receipt, and after a recovery, a pending approval got its binding back only if it had a tool name and a well-formed digest, so older cards from before the binding could still be answered, but only with a no.

Tool discovery put pressure on the same idea from another direction. Keeping large catalogs out of the initial prompt and letting agents find tools on demand saves context, but a tool the agent has found looks a lot like a tool the agent may use. The dispatcher, the single execution path, stated the rule in its header comment: a mounted or discovered definition is never an authorization grant. Before each call it re-resolved the catalog revision, the definition's fingerprint, availability, policy, approval and live capability, and an effectful tool whose authority couldn't be established was denied. Approval bound to the target tool instead of the generic wrapper that reached it, since otherwise one yes to the wrapper would cover everything behind it.

Not every form of authority in a system like this should look like a receipt. Agents on the platform could also repair their own working environment, managing settings, MCP connections, extensions and threads through the public broker, with no administrative access of their own. That ran on a signed grant minted for each turn, with a fixed thirty-minute lifetime. The user's identity lived in the grant's verified claims, so the agent never passed one; the broker accepted only an allowlist of methods, scoped to the user and project, and its handlers applied the same ownership checks they would apply to that user's browser session; and secrets came back as names, never values. MCP credentials were different again, a stored reference binding tenant, scope, optional user and server configuration with no expiry of its own, alongside a pending OAuth handoff that expired after ten minutes and was consumed through the same kind of compare-and-swap.

Those four instruments answer four different questions. May this exact call run? What may this agent ask the platform to do during this turn? Whose credential is this? Is this the OAuth handoff we started? A single token asked to answer all of them would have to live as long as the longest of the four and reach as far as the broadest, which is the situation the receipt exists to avoid.

None of this deals with the oldest weakness of approval prompts. The MCP specification recommends [keeping a human in the loop](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) who can deny a tool call, which leaves open what happens when that human is looking at the fortieth prompt of the afternoon. The approval module in my Ferrite libraries names this directly in its opening comment, as the failure mode "where synchronous approval prompts train users into blanket auto-approval". The design of those libraries comes from [Cloudflare OS](https://github.com/cloudflare/cloudflare-os). My Rust port and digest binding made those approval rules concrete: a benchmark exercised them on deterministic fixture backends, with no live GitHub or Google accounts behind them.

A proposed write there didn't interrupt anyone. It went into a queue with a SHA-256 digest of its canonical JSON arguments and a simulated outcome, approval was refused if the digest didn't match, and a connector that implemented revert could undo the write afterwards. In the GitHub connector the simulated pull request carried the number 0, since it didn't exist yet, and reverting the real one closed it. Even a wildcard grant didn't let that connector act on a repository until it had been introduced by URL.

A binding can still stop short of the effect. Imagine an apply step that constructs the vendor call from a simulated outcome rather than from the arguments whose digest was approved. The digest can match perfectly while the call that eventually runs differs from the one the person reviewed. Testing against a fixture does not remove that distinction; the test has to compare the approved arguments with the request assembled for the backend.

The same question applies at a gateway boundary. If the gateway mints, consumes and forwards a receipt, but the executor accepts only the resulting yes or no, the executor is trusting the gateway's decision rather than checking the signed binding against its own pending call. The question I'd put to any approval system, this one included, is where the last check sits. An approval is exactly as narrow as that check, and everything after it is running on trust.
