Menu
← FIELD NOTESSECURITY 2026.07.08 · 14 min

Approving an agent's action is not authorizing it.

An agent workflow that pauses for human approval and then resumes looks safe — a person clicked approve. But the approval decision and the authority the resumed step runs under are two different objects, and most systems conflate them by carrying the approval as an in-band signal the resumed call trusts. That is a confused-deputy bug: a forged resume, a replayed request, or an approval granted at one gate authorizes an action it was never meant to. The fix is to stop transporting approval and start deriving authority from the trusted approval record at resume time, scoped to the exact suspended step — capabilities enforced at the tool boundary, fail-closed by default.

A team ships an agent that can move money. Not recklessly — it drafts the action, suspends, and waits for a person before it touches anything irreversible. A reviewer sees the request in a queue, reads it, clicks approve. The workflow resumes exactly where it left off and completes the transfer. The demo is clean, the control is real, and the audit trail is exactly what a compliance reviewer wants: every side effect has a human’s name next to it.

Then one of two things happens in production. Either a run resumes and executes against a tool the reviewer never saw — the approval was for the payment step, but the resumed leg also reached the outbound-email connector, and the grant the system minted covered both. Or a resume arrives that no reviewer approved at all: a replayed request, a retry that re-entered a step whose approval was already spent, a forged body posted straight to the resume endpoint — and the action goes through, because the system asked “is this approved?” and something in the request said yes.

Both failures come from the same conflation. The reviewer’s decision — this specific step, on this specific run, should proceed — is one thing. The authority the resumed code executes under is another. A safe system keeps them as separate objects and computes the second from the first through a trusted path. An unsafe one treats the approval as a token it can carry inside the resume, and a carried token is a forgeable one.

This post is about why the approval and the authority are different objects, why the signal that says “approved” cannot be trusted if it rides in-band with the request, how to derive the executing authority from the trusted approval record instead of transporting it, why that authority has to be scoped to the exact suspension it was granted for, and where this pattern stops helping.

The approval and the authority are two different objects

The intuitive picture is that approval is a gate: the run reaches the gate, a human opens it, the run walks through and does what it was going to do. In that picture there is only one object — the request — and approval is a boolean stapled to it. This is the picture almost every first implementation encodes, and the stapled boolean is the bug.

Pull the two apart. The approval decision is a fact about a moment: reviewer R, at time T, looked at suspended step S of run X and said proceed. It lives — if the system is built right — in a store the reviewer’s action wrote to, under access control, with an audit record. The executing authority is a capability: when the resumed step calls a tool that touches the world, something has to answer the question “is this call permitted right now?” These are not the same object and they do not even have the same shape. One is a durable record of a human judgment; the other is a permission checked at a side effect, possibly milliseconds later, possibly on a different machine, after a suspend that outlived the process.

The confusion is understandable, because in a synchronous function call they collapse into one. if (approved) doIt() is fine when approved is a local variable set three lines up by code you trust and doIt runs immediately in the same stack. Agent workflows break every one of those conditions. The approval is set by a different actor than the one that resumes. The resume crosses a suspend boundary — a durable one, if the run is backed by something like Durable Objects, which is what makes replay and resume a first-class operation. And the tool call is a separate trust domain from the orchestration. Once approval and authority are separated in time, in actor, and in trust domain, stapling a boolean to the request stops being a shortcut and starts being a hole.

The resume is an attack surface

The reason you cannot let the authority ride inside the request is that, in an agent system, the request’s contents are not trustworthy. This is not a hypothetical. Indirect prompt injection made it a standing property of the field: Not what you’ve signed up for: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection (arXiv 2302.12173) showed adversaries can “remotely (without a direct interface) exploit LLM-integrated applications by strategically injecting prompts into data likely to be retrieved.” The model’s inputs are attacker-reachable, which means the model’s outputs are attacker-influenced, which means anything downstream that trusts model-shaped or context-shaped data is trusting the attacker. An “approved: true” that arrives in the same envelope as model output, tool results, or a client-posted resume body is exactly that kind of downstream trust — this is the prompt-injection vulnerability class pointed at the approval path instead of the answer.

And the tool boundary is where it bites, because the tool boundary is where the side effects are. AgentDojo: A Dynamic Environment to Evaluate Prompt Injection Attacks and Defenses for LLM Agents (arXiv 2406.13352) built an evaluation environment of “97 realistic tasks (e.g., managing an email client, navigating an e-banking website, or making travel bookings), 629 security test cases” precisely to measure attacks and defenses at that boundary, and found that “existing prompt injection attacks break some security properties but not all.” Read that as a floor, not a comfort: the boundary is contested, some properties already fall, and the defenses are partial. A design that puts the approval check anywhere the model or the client can reach has moved the gate to the least defensible ground there is.

There is a classic name for the resulting bug. A confused deputy is a privileged component tricked into misusing its authority on behalf of a less-privileged caller. An agent resume endpoint that mints authority from a signal in the request is a confused deputy generator: the orchestrator holds the power to execute the side effect, and it hands that power out to whoever can shape a convincing-looking resume. The forged body and the injected “approved” are the same attack wearing different clothes.

Derive the capability; don’t transport it

The fix is a well-formed one, and it has a research backbone. The move is to stop treating approval as a token that travels with the request and start treating the executing authority as a capability derived from a trusted source at the moment of use.

Defeating Prompt Injections by Design (arXiv 2503.18813) — the CaMeL work from Google DeepMind — states the principle in a form that transfers directly. CaMeL “explicitly extracts the control and data flows from the (trusted) query; therefore, the untrusted data retrieved by the LLM can never impact the program flow,” and it “uses a notion of a capability to prevent the exfiltration of private data over unauthorized data flows by enforcing security policies when tools are called.” Two ideas do the work there, and both apply to approval. First, authority comes from the trusted part of the system — the query CaMeL trusts, not the retrieved data it doesn’t — never from the untrusted stream. Second, the capability is enforced at the tool call, at the boundary where the side effect actually happens, not asserted somewhere upstream and assumed to hold.

Port that to a suspend/resume approval loop and the design writes itself. The trusted source is the approval store — the records a reviewer’s authenticated decision wrote, which no model output and no client request can forge. At the moment a run starts or resumes, a provider inside the trust boundary reads that store and recomputes the set of connectors the run is currently permitted to use, from records marked approved. That derived set is the capability. It is injected into the execution context by the trusted resume path, never accepted from the resume’s HTTP body. The tool wrapper, at the point of the side effect, checks the call against the derived capability and refuses anything not covered. Approval never becomes a transported token, so there is no token to forge; the only thing that crosses the wire is the reviewer’s decision into the store, through an authenticated, access-controlled write — the exact path a real human-in-the-loop checkpoint already has to defend anyway.

This is also why the capability has to be enforced at the tool boundary specifically, where the schema and the permission are the model-facing and world-facing contract. Checking “is the run approved?” once, at the top of the resume, and then trusting every subsequent call is the boolean-staple bug again at a coarser grain. The derived capability is checked per call, against the specific connector the call names, because that is the only place the question “is this side effect permitted?” has a real answer.

Scope it to the suspension, or the deputy stays confused

Deriving the capability from the store fixes forgery. It does not, on its own, fix the first failure from the opening — the approval for the payment step that also unlocked the email connector — and that one is a scoping bug, not an authenticity bug.

If the derived capability is run-scoped (“this run may use connector C”), then an approval granted at one gate authorizes C at every gate of the run, including gates the reviewer never saw. That is over-broad authority, and over-broad authority is how a confused deputy stays confused even when every signal is authentic. The discipline is least privilege, enforced by isolation between the parts of the system that should not share authority — the same instinct behind IsolateGPT: An Execution Isolation Architecture for LLM-Based Agentic Systems (arXiv 2403.04960), which starts from the premise that “third-party apps may not be trustworthy” and isolates them so one component’s authority does not silently become another’s. Approval scoping is the temporal version of the same idea: the authority a reviewer grants at step S of run X must be spendable only at step S of run X, and spent once.

Concretely, that means the capability is keyed to the suspension it was granted for — the specific suspended step and its suspension timestamp — not just the run. An approval minted for one suspension does not satisfy a different suspension of the same step, and re-entering the step after a decision spends the earlier approval rather than silently reusing it. A step-less, run-level approval is the explicitly broad case, and it should be the exception a system makes you opt into, not the default it hands you. Get this wrong in the permissive direction and the audit trail lies: it shows a human approved something on this run, while the action that executed is not the one they read.

Fail closed, and simulate the side effect first

Two defaults finish the pattern, and both are about what happens in the absence of a clean, matching approval rather than the presence of one.

The first is fail-closed. When the derived capability does not cover a call — because the approval was for another step, because no record matches, because a resume arrived that nothing in the store authorizes — the tool boundary denies, it does not default open. This sounds obvious and is routinely inverted in practice, because the convenient default during development is “proceed unless explicitly blocked,” and that default ships. It has to be the other way: proceed only on an explicit, matching, derived grant. The cost of fail-closed is real — a run approved through some bare agent UX that never minted a grant into the store will be denied at the connector even though a human did click approve — and that cost is the correct trade. A denied legitimate action is a retry; an executed forged one is an incident.

The second is simulate-before-commit. A large part of what makes approval gates load-bearing is that agents take unsafe actions at rates that are not rounding errors. Identifying the Risks of LM Agents with an LM-Emulated Sandbox (arXiv 2309.15817) built ToolEmu, which “uses an LM to emulate tool execution” so agents can be tested against risky scenarios without real consequences, and found that “even the safest LM agent exhibits such failures 23.9% of the time according to our evaluator.” Nearly one in four, for the safest agent measured. That number is the argument both for having a human gate at all and for giving the reviewer a faithful preview: a dry-run mode that shows exactly what the call would do without doing it, so the approval is made against the real action and not a hopeful summary of it. A dry run is also the one request that should be exempt from the write gate — a simulation never reaches a side effect — which keeps the preview cheap without punching a hole in the thing it previews.

What this does not solve

The pattern removes a specific, common class of bug. It is not a safety framework, and overselling it would earn the skepticism of anyone who has shipped one of these systems.

It does nothing about whether the reviewer should have approved. A capability derived from a genuine approval record faithfully authorizes whatever a human genuinely approved — including a human who was rushed, phished, or fooled by a request that read as benign and was not. Derivation moves the trust to the human decision; it does not improve the decision. It also assumes the approval store and the code that reads it are inside the trust boundary and correct; if the store can be written by untrusted input, or the derivation provider can be fed client data, the whole argument collapses, because you have re-created the in-band channel you were trying to close. The scoping guarantee is only as clock-clean as the timestamps it keys on — bind the approval to the suspension through values that come from one authoritative clock, or same-step re-suspensions will either leak or falsely deny. And none of this touches the attacks AgentDojo measures directly: a strong enough injection can still drive the agent to request a malicious action through the front door, at which point your only remaining defense is the reviewer and the faithfulness of the preview they saw. Derivation secures the path from decision to authority. Securing the decision is a different post.

Building the pattern

Because the design is small and mostly a set of refusals, it fits as middleware rather than a runtime of its own. We put it into an open-source layer, Anchorage, that sits on top of Mastra: the connector wrapper is where the tool-boundary check lives, and the approval service is where derivation happens. The grant is minted by recomputing the approved-connector set from approval records on every start and resume — through a provider wired into the run’s context-building step, never from the resume body — and it is keyed to the resumed step and its suspension timestamp, so an approval spent at one gate does not unlock another. Self-approval is denied by default. The public resume route carries no grant at all, which is the point: there is nothing in the request for a forged resume to fill in, and a resume that nothing in the store authorizes fails closed at the connector. The suspend/kill/restart/resume path and the forged-resume denial are exercised as an automated proof against a real workerd runtime, because a fail-closed claim you have not tried to forge your way past is a hope, not a property.

The checklist

Before you trust an agent’s approval gate:

  • The approval decision and the executing authority are separate objects — the first a record a reviewer’s authenticated action wrote, the second a capability checked at the side effect.
  • The executing authority is derived from the trusted approval store at start/resume, never transported in the resume request, model output, or tool results.
  • The capability is enforced at the tool boundary, per call, against the specific connector named — not asserted once at the top of the resume.
  • The grant is scoped to the exact suspended step and suspension, spent once; run-level scope is an explicit opt-in, not the default.
  • The tool boundary fails closed: it proceeds only on an explicit matching grant, and denies on a missing, mismatched, or unauthorized resume.
  • The reviewer approves against a faithful dry-run preview of the real action, and dry runs are exempt from the write gate because they reach no side effect.
  • The self-approval case is denied by default, and the resume timestamp binding is clock-clean.

Reading list

The team in the opening did everything the demo asked of them: a real gate, a real reviewer, a real name on every side effect. What they shipped was a boolean stapled to a request, and the staple held right up until someone shaped a request the staple believed. The gate was never the weak part. The weak part was treating the reviewer’s decision and the code’s authority as one object when they are two — separated by a suspend, an actor, and a trust boundary. Keep them apart, derive the second from the first through a path nothing untrusted can write, scope it to the moment it was granted, and fail closed when it does not match. Then the human’s name in the audit trail means what it says.

NEW ENGAGEMENT · INTAKE

Tell us about it.

The more specific you are, the more useful our first reply.

SERVICE AREA
↩ ENCRYPTED IN TRANSIT
ASK THE FIELD NOTES BETA