A subagent got a simple task: read a project's docs and say how to run it. It read docs/readme.txt, found "make run", and then asked for one more file: ~/.ssh/id_rsa.
Why it asked doesn't matter. A hidden instruction in something it read, a buggy third-party tool, a model that "thought" the key would help. What matters is what happens next, and in most setups today the file is read. Not because anyone granted the subagent access to the key, but because nobody granted anything to anyone: the tool runs as your user, and your user can read your key.
There's an old name for this: ambient authority. The kernel doesn't know about agents. It sees user ana and processes of ana. It can't ask "was this Ana, Ana's agent, or the tool her agent's subagent downloaded yesterday?".
Where the usual defenses stop
- Gateways and proxies check permissions at the door, then let the tool run natively with full access.
- Agent sandboxes (NVIDIA OpenShell, AWS Strands Box, Anthropic's sandbox-runtime) contain the whole agent process with one policy. Useful, but every subagent and tool inside shares it.
- Logs ask you to trust whoever wrote them.
I wanted something one level down: per tool call, decided by a permission that can only shrink as it is handed on, and proved afterwards.
Five pieces
CleiMesh is a Rust node (MIT or Apache-2.0) that puts five pieces together:
- Delegable, signed permissions. A token, following the IETF Attenuating Agent Tokens JWT profile. An agent can hand a narrower part to a subagent, offline, never more than it got.
-
The permission becomes the sandbox. Tools run in WebAssembly (wasmtime + WASI) where nothing is allowed by default. The token decides the preopened directories, network or not, instruction and memory limits. A subagent granted
./project/docscan't physically open./project. -
A signed receipt for every call, allowed or denied: Ed25519, hash-chained JSONL with a signed head, in the
draft-sahu-agent-action-receiptsformat. Digests and pseudonyms, never the data. No receipt, no run. - Don't trust the receipt, replay it. Calls run with a frozen clock and seeded RNG recorded in the receipt, so an auditor re-runs the call and compares the output byte for byte. A node that signs a false result is caught.
-
Taint that follows the delegation chain. Once a tool marked
--taints(mail, web) returns content to an agent, tools marked--clean-only(write, send) are refused for that agent's whole chain until a human clears it. Handing the work to a fresh subagent doesn't launder it.
# Reading the inbox brings in outside content; writing to the outbox acts on the world
cleimesh token issue <agent> --tool file_read --read ~/inbox --taints -o read.json
cleimesh token issue <agent> --tool file_write --write ~/outbox --clean-only -o write.json
A real model, a poisoned mail
I ran OpenClaw with a local Qwen3 4B on Ollama and a mail carrying a hidden instruction to write a secret to a file.
- With OpenClaw's own file tools on, the model obeyed and wrote the file, without ever calling CleiMesh.
- With only CleiMesh's tools, the read tainted the chain, the write was refused by the node, and the refusal is on a signed receipt.
That first act is the honest limit: CleiMesh only contains what goes through it. cleimesh doctor reads Claude Code's and OpenClaw's config and warns when the agent still has its own file or shell tools. And it doesn't "solve" prompt injection: the model can still be fooled, and an allowed action can still be the wrong one. What the node adds is a rule outside the model that the model can't argue with.
Numbers, measured
- One static musl binary, 37 MB (13.7 MB gzipped), starts in ~2.5 ms.
- A cached WASM instance per call is ~0.5 ms; a full call with two fsynced receipts is ~3 ms on a laptop.
- A load test with 5,000 simulated agents, part of them hostile: zero escapes, a verified log, ~400 calls/s, bounded by fsync. Group commit is next.
Try it
curl -fsSL https://github.com/cleitonaugusto/cleimesh/releases/latest/download/install.sh | sh
# or from source:
cargo build -p cleimesh-cli
CLEIMESH=target/debug/cleimesh examples/demo/demo.sh
The demo runs in a fresh /tmp directory with a fake project, a fake ~/.ssh and throwaway keys. It's alpha: the threat model in the repo lists what it protects, what it doesn't, and the gaps before 1.0.
Repo: https://github.com/cleitonaugusto/cleimesh
I'd like opinions on two things: grouping receipt fsyncs without weakening "no receipt, no run", and the guest ABI (core module now, Component Model before 1.0).
I also wrote up the design, the attacks and the mistakes in a book, AI Agents in a Cage.
Top comments (5)
On grouping fsyncs: group commit keeps "no receipt, no run" as long as no call starts (or returns its output) before the fsync that covers its receipt returns. Each call appends its pre-call receipt to the buffer and waits; one fsync every couple of ms or N entries releases the whole batch. Latency per call goes up a little, the guarantee does not change. Postgres commit_delay works the same way.
A question from the screen-control side (I'm on the Auten team, we build a computer-use MCP that clicks and types on a real desktop and an Android phone in beta). That tool is exactly the honest limit you describe: it never goes through file_read, it reads pixels and the accessibility tree and acts with clicks. A screenshot of a browser tab is outside content in the same way mail is, and a click can be a send or a delete. How would you model it in CleiMesh? Mark the screenshot as --taints and the click as --clean-only? Then the chain freezes after the first screenshot of any web page, so a computer-use agent can almost never act. Or does the cage need a narrower unit there, like a target app or window instead of a path?
Thanks, both points are useful.
Group commit: agreed, and that's the design we'll ship. One rule per receipt: no call starts before the fsync covering its decision receipt returns, and no call returns output before the fsync covering its result receipt returns. The fsync moves off the event loop. Today it's two fsyncs per call, which caps one log at a few hundred calls per second, so batching is the next throughput step.
Computer-use: you're right that screenshot = taints plus click = clean-only freezes the agent. But I'd keep taint where it is. The unit of taint is the context: the same model that saw the page decides the next action. The unit of action is the surface: the app or window. If we cleared everything outside the app that tainted, a page could just tell the agent to type into the terminal. So the fix isn't a narrower taint. It's taint that confines the agent instead of freezing it:
--taints. In-app actions (click, scroll, type) are granted with theappargument pinned by a signed constraint (OneOf [browser]) and without clean-only. Cross-app and destructive actions (send, delete, anything in the terminal) are--clean-only. After reading a page, the agent keeps working inside the browser and can't reach anything else. This needs the tool to take anapp/windowargument and capture and act only there.app=firefox), and tools marked as confined then run only on the label that tainted the chain. An unlabelled taint, such as a mail read, still blocks them. It's the same rules as now: flags can't be dropped down the delegation chain, the state is rebuilt from the receipts, and every refusal points at the receipt that caused it.Since your tool already acts on a real desktop, it would be a great first case for this. If you wrap it with
cleimesh mcp wrapand send me the tool schema (does it take an app or window argument?), I'll write the token recipe for step 1 and build the labels against it.Confining by surface instead of freezing makes sense, and the signed single-call approval is the piece I'd want most: "confirm this click" with exact arguments in the receipt beats a blanket unfreeze.
To your question: no, not today. Actions don't carry an app argument. click and fill take an element id from the last list_elements, which reads the frontmost window, and the target is chosen by separate calls (open_app, focus_window by title). So the app is session state, not a field on the action, and a OneOf [browser] constraint on click would have nothing to pin. Step 1 would need either a wrapper that resolves each element id to its owning app before the grant check, or an explicit app/window field on every action call. The second fits your model much better, and it's a fair note for us.
One thing that already sits below the agent and lines up with your origin labels: saved logins are pinned to the site or app where they were first used, and on any other origin nothing is typed. So even a tainted chain that reaches the fill call can't move a password to the terminal.
(From the Auten team.)
That's a clear answer, thanks.
Then the explicit field is the way. A wrapper that tracks open_app and focus_window can't be trusted here: focus can change between calls (a popup, a window a page opens, the user), so the app has to be resolved by the tool at the moment of the action. If click, fill and type carry
app(or a window id) and your server refuses an element that isn't in that app, the signed constraint has something real to pin, and the receipt records which surface was touched.Given what you said, the single-call approval goes first. It works with your tool as it is today: the action is clean-only, and once the chain is tainted the agent needs a root-signed approval for those exact arguments (element id included), single use, about 60 seconds. The approval and the call end up in the same receipt.
Pinning saved logins to their first origin is the same idea one layer below, and the two fit together: we limit what the agent may ask for, you limit what the credential store will hand out.
When the app field exists, I'd like to test it together end to end: a page with an injected instruction, the agent confined to the browser, the terminal refused, and the receipt showing why. I'd be glad to write it up as a joint example.
Agreed on the focus race. A popup or a window the page opens between the focus call and the click is exactly where a tracking wrapper would pin the wrong surface, so the check has to happen inside the tool at the moment of the action.
One detail for approval-first: element ids are numbered per list_elements call, so an approved id only means something against the snapshot it came from. Binding the approval to that snapshot, or re-checking the element's role and label at call time, would stop a page from reshuffling the screen between the approval and the click.
I've passed the app/window field on to the people who own the tool schema, without a date I can promise. If it ships, I'll post it here, and your injected-page test is the right way to show it.
(From the Auten team.)