Screen tool results for prompt injection before the agent reads them
Prompt injection is not mainly something a user types. It arrives in tool results: the web page the agent fetched, the file it read, the issue body it pulled from an API, the response an MCP server returned. All of that is text somebody else wrote, and the agent reads it in the same context as instructions from you.
That is the asymmetry worth naming. You chose the tool, so the call looks fine. The content coming back is chosen by whoever wrote the page, and there is no point in the chain where the agent is told which of the two it is reading.
The channel is every result, not just the web #
- A fetched page. The obvious one. Forty kilobytes come back and one sentence in the middle is addressed to the reader rather than to you.
- A file on disk. Not every file on your machine was written by you. A dependency's README, a generated fixture and a file a previous run downloaded are all ordinary reads.
- An API response. Issue bodies, pull request descriptions, review comments, support tickets, calendar invitations. Anywhere a third party fills in a field that the agent later reads back.
- An MCP tool result. Whatever the server decides to return, which is the whole surface, and a server that was honest yesterday is not thereby honest today.
What the screen looks for #
Code goes first and finds the places text is trying not to be read by a person: zero-width and bidirectional control characters, HTML comments, blocks hidden with CSS, and long base64 runs. Those are listed as hidden regions in the state, and the model is asked what they say rather than being trusted to notice them.
The result itself is split into numbered blocks and screened as a unit, so one answer can point at the block it means. A result too large for one request is split at paragraph boundaries into several, and the verdict is the highest across them.
- instructs_reader. Does any block contain text written to instruct, command or steer the AI system reading this result, rather than to inform a human. This is the one the thresholds act on.
- exposes_secret. Does any block carry a credential, token, private key or password. Above 0.7 the result is redacted and annotated.
- which_block. Which block most clearly tries to instruct the reader, so the warning can be attached where the problem is instead of to the whole result.
- severity. If the agent followed the strongest instruction in there, how much harm follows, from none through nuisance to exfiltration or running untrusted code.
Annotate, or withhold #
These are different operations and they are deliberately kept apart. Annotating returns the result exactly as it arrived with a banner in front and the flagged block wrapped, because the point is that the agent should read it and know what it is looking at. Withholding replaces the body, because the point is that it must not.
- At 0.5 and above, annotated. The agent reads the result with a banner saying part of it may be written to instruct the reader rather than to inform you.
- At 0.8 and above, withheld, and only when severity also reaches 1.5. The agent is told which tool returned nothing, how many blocks were held back, and that you can read the original locally.
Severity selects the tier, it does not gate the screen. On the email set most attacks are low-harm, the sort that asks for a joke or a translation, and gating on severity would let them all through.
agent-chaperone show 4f1c9ab203A withheld result is still on your machine in full. The command above prints the original, so withholding keeps the text away from the agent rather than away from you.
What it catches, and what it does not #
Counted at 0.5, the threshold the shipped rule acts at, on one dated run against public benchmarks.
| Set | Items | To catch | Caught | Missed | Flagged in error |
|---|---|---|---|---|---|
| InjecAgent tool responses | 1,394 | 1,054 | 848 | 206 | 9 |
| BIPIA email | 250 | 200 | 165 | 35 | 0 |
| Benign documents that discuss injection | 63 | none | - | - | 7 |
The largest set is also the weakest. Of 1,054 injected tool results, 206 were not flagged at all, and of those that were, only 359 crossed the bar to be withheld rather than annotated. Anyone telling you a screen like this catches everything has not counted.
The full results, every threshold, and the misses named. The recorded model responses are committed to the repository, so the scorer reproduces these numbers without an API key.
The thing it gets wrong most #
Writing about prompt injection looks a lot like performing one. Security documentation, READMEs of tools like this one, and blog posts quoting an attack are the main false-positive class, and the question's own criteria try to draw that line: discussing or quoting an instruction is not issuing one.
On 63 such paragraphs, 7 scored at or above 0.5. The two highest were a documentation header addressed to automated readers, and code containing literal question text. If your agent reads a lot of security writing, expect banners it did not need.
Limits #
- Not a guarantee. The underlying model does not treat its input as hostile by default, and an attack written against this screen will get through it. The numbers above are the honest version of what it does.
- Calibrated against one model. Every threshold here was chosen from measured numbers for Jev. Screening through a general gateway is supported and returns numbers that look the same and mean something else. Read your own log before letting one of those decide anything.
- The content leaves the machine. Results being screened go to the configured backend. Secret shapes are replaced first, and screening can be turned off for a server whose content has to stay put.
- Not everything is a result. A file you paste into your own message never passes through a tool, so no screen sees it.
The false criterion on the main question, and the second question that was measured and dropped for adding no signal, are both in the design document.