An agent is running, making tool calls, and something's wrong — bad prompt, gone off the rails. You need to stop just that one. Right now.
What do you do? Rotate the API key? It's shared, so you break every other agent. Kill the process? You lose its state and still don't know what it already did. Neither is "revoke this agent" — they're "blow something up and hope."
I went looking for the real version, found mostly duct tape, and built it. Four things got clear:
1. Identity and credentials shouldn't be the same thing. When your agent is its API key, that key is its identity, credential, and permissions all at once — so revoking means rotating and scoping means hoping the prompt holds. Split them: the agent has an identity, its authority is a separate signed grant, and the real secret is custodied elsewhere and injected at use. Now revoking is "invalidate one grant" and everything else keeps running.
2. Revocation has to hit the next action, not the next token refresh. If it "expires in 15 min," the agent keeps working for 15 minutes after you said stop. The only instant kill I found is putting the check in the action's path — for MCP, a proxy in front of the tool server:
chancery mcp wrap --agent deploy-bot --writ <id> -- npx some-mcp-server
Every call passes through it. Revoke → next call denied, mid-session, no restart. A prompt-injected agent still has to go through the proxy, so it can't skip the check — which a client-side "please check permissions" library can't promise.
3. Delegation should only ever narrow. Orchestrators that spawn workers usually hand each one full credentials — now you've got five copies of god-mode. Make delegation structurally attenuating: a worker's grant is a child of the parent's, and the format has no field for widening. A sub-agent can't out-scope its parent, and revoking the parent kills the subtree. You don't trust the logic; the structure can't represent the unsafe case.
4. The audit log has to be the enforcement path. A log the agent writes after acting is worthless the moment it's compromised — that's when it lies. But if every action already flows through a choke point for the permission check, that's where you record it: which agent, which version, under whose authority. The log becomes a byproduct of enforcement, not a favor the agent does you.
I built these into Chancery — self-hosted, single Go binary, Apache-2.0, pre-alpha (gaps written up honestly). The four ideas hold no matter what you build with. Solved any of them cleaner? I'd genuinely like to hear it: https://github.com/chanceryhq/chancery
Top comments (5)
Revocation needs a state-machine boundary, not just a stop signal. The practical test is whether every external side effect has a cancellable or compensating path, and whether in-flight tool calls carry an expiration token. Otherwise the UI says stopped while a queued action can still land later.
This is the right distinction and I'll be precise about where the line currently sits.
What revocation guarantees today: no new call is admitted after the revoke — the check is at call admission in the proxy, so the next thing the agent tries dies. What it does not do: recall a call that's already been forwarded to the tool server or compensate a side effect that already committed. So you're exactly right — "stopped" means "nothing further will be admitted," not "everything in flight is unwound."
And the honest part: unwinding committed effects isn't something the gate can do on its own — that's a transaction problem that lives at the resource layer, because only the tool knows how to reverse itself. The gate can refuse the next call; it can't un-send an email.
The in-flight expiration-token idea is the interesting middle ground I don't do yet — stamping each admitted call with a short-lived token the server is expected to check, so a call revoked mid-flight fails at the server instead of landing. That needs cooperation from the tool side, which is why it's not in today, but it's the right direction for shrinking that window. Curious if you've seen a clean version of that pattern in practice.
A clean version usually looks more like a capability lease than a cancellation token. The admitted call gets a short-lived token bound to the agent/task, tool, action/resource, nonce or idempotency key, and a revocation epoch. The tool checks it immediately before the side effect and again at meaningful checkpoints for long operations. Revocation increments the epoch, so an older lease fails. If the effect already committed, the execution ledger should emit an explicit compensating command instead of pretending it was cancelled. This works well for job runners and staged operations; one-shot third-party APIs are still the hard boundary. I would expose admitted, started, committed, and compensated as separate states in the trace.
this is more than I was fishing for, thanks. the epoch is the elegant bit — revoke bumps a counter and every older lease just dies, no per-call state.
one honest constraint though: the lease only works where the MCP server cooperates — it has to check the lease before committing the effect. so it's opt-in per tool server, not something the proxy can impose. admission-time denial stays the floor; leases shrink the in-flight window where the server plays along.
the part I'm stealing outright is the trace states — admitted / started / committed / compensated instead of one allow/deny. my log is honest about admission and silent about everything after, which is exactly the hole you found.
opened an issue for both, credited you.
github.com/chanceryhq/chancery/iss...
Right — cooperation is the honest boundary; the proxy can't reach past admission on a server that won't check the lease. Two things that help without needing every server to play along:
Classify tools by effect reversibility at registration, and have the proxy apply a stricter admission policy to the irreversible + non-cooperating ones — shorter queue depth, or a pre-commit confirmation — so the window you can't unwind is also the one you admit most conservatively.
And the trace states earn their keep even against an uncooperative server: you can't auto-compensate, but "admitted → started → (no committed/compensated)" is a detectable reconciliation gap. You may not prevent the effect, but you can flag the orphan for a human instead of going silent. Nice that you filed it — glad it was useful.