Skip to content
Back to all writing

Your Agent Still Has the Tool You Revoked

An agent can retain a revoked tool. Why long-running workflows create a TOCTOU authorization bug — and why the decisive guard belongs immediately before provider I/O.

A stale blue tool token travels from a planning chamber toward a provider but is stopped by an amber authorization gate that recomputes current authority

An admin revokes a connector at 14:32. A run that started at 14:28 calls that connector at 14:35. The call succeeds.

Nothing failed technically. The revocation is recorded correctly. The permissions table is right. The agent simply picked up a tool handle at the start of its run, and that handle was authorised under conditions that stopped being true three minutes later.

This is the failure I keep coming back to on the enterprise agent platform I've been building over the last few months, where a chat surface fans out to connector-backed specialists that can read from and write to real business systems. It falls naturally out of a common implementation pattern: compose the tools when a run begins, then assume those handles remain valid for the lifetime of the run.

In security terms, this is a time-of-check to time-of-use problem, usually shortened to TOCTOU. The agent-specific twist is the size of the window. The check happens when the runtime assembles the tools. The use may happen minutes later, or days later when a durable workflow resumes.

Tool composition is a snapshot

The convenient pattern looks like this:

tools = build_tools_for(user, installation)   # resolve credentials + grants once
agent = Agent(model=model, tools=tools)
result = await agent.run(task)                # ...then run for a while

That first line does a lot of work. It resolves which connectors the user can reach, which operations are selected, which credential to use, and whether the installation is healthy. It bakes all of that into closures the agent can call.

And then it never asks again.

Every input to that decision can change independently while the run is still going. Membership can be removed. An installation's visibility can change. A grant can be narrowed. A credential can be rotated or revoked at the provider. The connector can start failing health checks. Operation policy can be tightened after an incident.

None of those events reaches into a running process and invalidates a closure. The tool handle outlives the permission that created it.

Some frameworks now support runtime tool filtering or middleware around model and tool calls. Those controls are useful: they reduce what the model sees and can refresh the available tool set between turns. But tool visibility and effect-time authorization are different controls. Hiding a tool before the next model call does not revoke a tool call that has already been selected, queued or handed to another worker.

For a three-second chat reply, that window is small. For a task that runs for twenty minutes, or a durable workflow that retries tomorrow after a provider failure, it is not theoretical. Retries are the nastiest version: the retry can execute a plan built under an authorization context that is now days stale.

Authorization has two times

The framing that unstuck this for me is that authorization has two distinct moments.

Planning time is when the runtime decides which tools the model may see and which actions it may attempt. This check matters. It narrows the model's attack surface, keeps irrelevant capabilities out of the prompt and prevents the system from planning work it already knows is forbidden.

Effect time is the instant a call is about to leave your enforcement boundary and reach somebody else's system. This is the decisive enforcement point, because it is where data is disclosed or an external state change begins.

The gap between those moments can span seconds, queues, process restarts and human approval pauses.

Which gives the rule I'd write on the wall if I wrote things on walls: a permission check is a fact about a moment, not a property of a handle. Checking at planning time tells you the operation was allowed. It tells you nothing about whether it is.

Three controls that don't close the gap

Worth naming these because I tried versions of all three.

Make runs shorter. This reduces the window and does not close it. It also fights the product — long-running tasks are the point of a durable agent platform.

Re-check at the top of the agent loop. Better, and still not the final boundary. Between that check and the HTTP call there may be argument construction, another model turn, a human approval pause or a queue hop. Dynamic tool filtering has the same limitation when it only runs before model calls: it refreshes what can be selected next, not what remains authorised now.

Tell the model not to use revoked tools. This one is worth being blunt about: a prompt is a request, not a control. I've written about that at length in The Model Wasn't the Hard Part — a model can be persuaded, confused or fed hostile retrieved content, and none of that should be able to widen what the system can do. The model should not be the component holding the revocation list.

Authority is an intersection, recomputed

The first real change was to stop treating authority as an attribute attached to an agent and start treating it as a result computed from independent sources whenever it is needed.

The operation set a run can reach begins as an intersection:

def visible_operations(actor, agent, installation, manifest):
    return set.intersection(
        operations_for_active_membership(actor, installation.tenant_id),
        operations_visible_on(installation),
        operations_granted_to(agent, installation),
        installation.selected_operations,
        credential_backed_operations(installation),
        manifest.first_party_operations,
    )

The design target is monotonic authority: adding another correctly computed constraint can only remove capability from the result. No source is supposed to widen the authority established by the others.

The intersection is not magic, and it does not make every bug fail safely. A stale membership set, an omitted operand, a cross-tenant lookup bug or a dependency that fails open can still make the result too broad. The property only holds when every required source is present, current, correctly scoped and treated as a denial when it cannot be evaluated.

The provider work in this platform is performed by a connector-bound specialist rather than the user-facing agent. At that delegation boundary, the parent authority is intersected again with the specialist's current installation grant, pinned release, operation policy and current credential-backed capability.

Two narrowings, no widenings. That produces the security invariant I care about most:

Delegation cannot increase authority.

A sub-agent is not a way to get more permissions than the thing that called it. If that invariant does not hold, every other control is decoration, because the escape hatch is just "ask a different agent to do it".

The guard immediately before I/O

The intersection tells the runtime what a run may attempt. It still does not solve the timing problem when it is computed only at planning time.

So the last component before every provider call is a guard that repeats the relevant checks:

async def invoke(operation, args, ctx):
    # Everything above this line was decided earlier and may be stale.
    decision = await authorize_now(
        actor=ctx.initiating_actor,
        installation=ctx.installation,
        specialist_release=ctx.release,
        operation=operation,
    )

    if not decision.available:
        raise AuthorizationUnavailable(operation)

    if not decision.allowed:
        raise AuthorityRevoked(operation, decision.reason)

    if decision.needs_approval and not ctx.approval_for(operation, args):
        raise ApprovalRequired(operation, args)

    return await provider_gateway.call(operation, args, ctx.credential_ref)

This is deliberately boring code. What matters is its position: between the plan and the effect, behind one provider gateway, with no alternate client that can skip it. It follows the same principle as OWASP's guidance to validate permissions on every request.

A revocation that lands after tool composition but before this guard now prevents the call. The system no longer relies solely on a decision made when the run began.

The cost is real. You pay for an authorization decision on every provider call instead of once per run. In practice that is often a handful of indexed reads next to a much slower network request, but it is still a deliberate trade. If a high-volume path caches the decision, the cache lifetime becomes the maximum revocation delay and should be treated as an explicit security parameter rather than an invisible optimization.

Not every operation deserves the same treatment

Once the guard exists, the next question is what it should do, and the answer is not uniform.

Reads still require current authority. A revoked connector should not remain readable, and a read can disclose data even though it does not mutate the provider. What comes back is also untrusted input: a document that says the user is an admin is a document, not permission evidence.

Reversible or low-impact writes get reauthorised at execution time and carry an idempotency identity so a replay does not duplicate the effect.

Impactful or destructive writes can require explicit approval, stronger authentication or a durable action record. The approval must be bound to the exact actor, operation and canonical arguments, with an expiry. "Yes, delete that record" is not "yes, this agent may delete records."

Ambiguous outcomes are their own category. A timeout after sending a write does not prove the write failed. Recovery needs durable intent and receipts so it can decide whether to replay, reconcile or ask a human — which is a whole article on its own, and it is one.

Lifecycle changes must feed the guard

An effect-time guard is only as fresh as the state it reads. That is why connector availability cannot be represented honestly by a boolean such as is_active = true.

Active according to what? A credential that resolved successfully last week? A health check nobody has repeated since the provider changed its API? A release created before the operation set was narrowed?

Activation has to earn the state:

  1. Resolve the credential reference and perform a real upstream health check.
  2. Tie the resulting evidence to the material configuration and credential version that produced it.
  3. Grant only the selected operations and bind the connector specialist to that verified installation state.
  4. Make the state runnable only if the entire transaction succeeds.

The lifecycle then has to invalidate that evidence deliberately. A material configuration change or app-scoped credential rotation suspends the capability until activation succeeds again. User-scoped delegated credentials remain isolated so one person's reauthentication does not suspend a shared installation for everyone. Revocation removes or retires the grants and bindings that the effect-time guard reads; it is not merely a dashboard flag.

Half-activated is the state that produces the strangest incidents, so it should not be a state the system can enter.

Freeze selection; revalidate permission

Model configuration is a useful counterexample, but the distinction is subtler than simply saying configuration should be frozen.

The chosen router, model and release should be resolved at invocation and handed to the specialist as a sealed snapshot. That prevents a delegated worker from quietly selecting a stronger provider, switching to a cheaper but incompatible model or drifting to a new release halfway through a recoverable run.

But the permission to use that pinned selection is still revocable. If provider policy changes while a run is paused, the runtime should verify that the pinned model remains allowed before the next model call. If it is no longer allowed, the safe behaviour is to stop or require an explicit migration — not silently choose another model and pretend the run is reproducible.

So the distinction is:

Freeze the selected value so it cannot drift. Revalidate the policy that permits that value so it can still be revoked.

That applies to more than models. Configuration answers "which exact thing did this run choose?" Authorization answers "may it still use that thing now?"

What this design does not guarantee

The guard is only as strong as its coverage. One bare provider client that bypasses provider_gateway undoes the property. "Every external call passes through this interface" needs to be enforced architecturally and tested, not maintained as a convention people remember during review.

The check and the provider call are not atomic. Authority can change after authorize_now() returns but before the request leaves the process, and a request already in flight may complete after revocation. Rechecking immediately before I/O shrinks a window that used to last minutes or days; it does not reduce it to zero. Harder revocation requires help from the provider boundary, such as short-lived credentials, provider-side token revocation or a gateway that evaluates policy as it forwards the request.

The authorization dependencies must also fail closed. If membership, policy or credential state cannot be read, turning that availability failure into an allow decision would make an outage a privilege escalation.

Finally, this platform is still under active development. This is a design I have implemented and reasoned about, not one I would present as independently security-reviewed or proven by years of production incidents.

The rule

If you take one thing from this: the moment your system decides an action is permitted and the moment it performs that action are different moments, and only the second one can authorise the effect.

Compose tools if it is convenient. Filter them before model calls. Cache the plan. But put the decision that actually protects the provider as close to the effect as you can, make every authority source narrow rather than widen, and ensure delegation is a path to less authority rather than a way around it.

Otherwise revocation is only a UI state — true in your database, and not true in the run that is still executing.