The Worm Rode In on the Results
OpenAI caught prompt injections copying themselves through tool results in its own lab. Scanning for the next one is a coin toss, so this week's companion gates what an agent does after it reads one.
Issue 35 - Oct. 6, 2026
By o;border-top:1px solid #d4d4d8;font-size:0;line-height:0">
Let's Call It Quarantine Engineering
Two weeks ago I built a lock for MCP tool manifests, and last week I moved that lock onto the wire, where it checks every tools/list answer as it passes. Both READMEs carry the same admission, and I wrote it knowing this issue was coming: "Tool results are not checked." A lockfile can pin a tool description because a description is supposed to hold still; the text a server sends back from tools/call is supposed to change on every call, so there's nothing to pin.
On Sept. 25, OpenAI's alignment team published a report that puts that hole on the record. Its researchers found a new kind of prompt injection, one that "can self-propagate akin to a computer worm": the payload arrives in a tool result, and the model copies it out through the next thing it writes. All of it happened inside OpenAI's own training runs; the report says "No impact was observed outside of the simulated tool calls in training and evaluation" (which is the good news, and also the only reason we're reading it as research instead of an incident report).
What we'll call QUARANTINE (in a port or a lab, it's the rule that anything exposed stays put until someone signs it out, whether it looks sick or not) is the tool I'd reach for here. It doesn't try to spot the infection in each result; it tracks exposure. Once an agent has read text from a source you don't control, the tools that write, send, or post stop working for the rest of the session until a person clears it.
The agent keeps reading; the outbound tools wait for a human.
COMPANION SCRIPT Companion script for this issue: |
FOR FURTHER READING
|
What OpenAI Found
The discovery date on the report is June 27; the disclosure came three months later. The attacker was a GPT-Red-style red-team model trained with reinforcement-learning self-play. In the email and filesystem cases, the vulnerable model was an internal research checkpoint based on GPT-5.4-mini; a separate Slack test used GPT-5.5 as the target. None of the environments touched anything real.
The payloads took three routes, and every one of them is a tool result in and a write out. In the email case, the injection arrived as a message laying out a fake filing rule that told the agent to quote the entire email verbatim in anything it sent. In the filesystem case, a fake system warning got the model to delete important reports and then copy the whole attack into a file for the next reader. In the Slack case, GPT-5.5 followed a chain of messages through a sequence of reads, sent a named colleague some of an internal currency OpenAI uses for recognition, and reposted the injected message.
The manifests in those environments never changed. mcp-pin would've passed every server, mcp-guard would've passed every answer, and the worm would've walked through both.
OpenAI's fix is on the model side: self-reproduction is now an attacker goal in GPT-Red training, so future models will have seen this kind of injection before they ship. That's the right fix for a model vendor. It's also a fix you can't apply to the agent you're running this afternoon.
I'll Scan the Results Instead. Right?
That's the reflex, and I'd be lying if I said it wasn't mine; the mcp-guard README sends readers to two content filters in the same repo. A scanner catches the payload that announces itself. Adversa AI's write-up, published this week, is about one that doesn't: a web page carries its instructions encrypted alongside the key, GitHub Copilot CLI decrypts them inside its own session, and the plaintext a filter would've flagged never sits anywhere a filter can look. The decrypted stage tells the agent to fetch a follow-up URL "to grab more context," with the harvested file contents riding along in the request; the exfiltration is a read. With the CLI in autopilot mode and a permissive model on the session, the demo moves every secret in a .env.prod file to an attacker's log in 28 seconds.
GitHub validated the finding and declined to call it a vulnerability. Its triage team said the user "explicitly asked Copilot CLI to fetch attacker-controlled content while giving copilot full permissions to act autonomously," and the report was ruled ineligible for a bounty. That's a fair description of the setup (it's also a fair description of how a lot of people run agents in CI).
Nobody upstream claims scanning holds, either. OX Security's late-September report, "15,465 MCP Servers, 0 Governance," summarizes Anthropic's response to one of its findings this way: model-level detection of malicious content "is a best-effort heuristic, not a security boundary." The MCP spec says the same thing in RFC grammar. Servers MUST "Sanitize tool outputs"; clients SHOULD "Validate tool results before passing to LLM." The firm obligation lands on the party you don't trust; the soft one lands on you.
My position is that tool results are untrusted input and get handled like any other untrusted input: kept apart from instructions, read in a fixed shape, and never allowed to trigger a write, a send, or a new outbound call on their own. You can't prove a result is clean, but you always know which tool it came from, and that's enough to decide what the agent gets to do next.
Where the Gate Stands
mcp-gate sits where mcp-guard sits, between the client and a stdio server, and the two chain: gate outside, guard inside, server at the end. The policy file has three lists. untrusted names the tools whose results count as exposure ("*" covers a whole server, which is right for a web fetcher); outbound names the tools that write, send, or post; allow_hosts names the only hosts a tool argument may mention.
Every tools/call gets two checks before the server sees it. The gate walks every string in the arguments, nested or not, pulls the host out of anything shaped like a URL, and refuses the call if a host isn't on allow_hosts. That check runs on a clean session too, because the secrets in Adversa's demo left on a fetch, and a fetch to an unlisted host is a send whatever the tool is called. Then, if the session is tainted, any tool on the outbound list is refused. On the way back, a result from an untrusted tool appends a line to the session's taint file naming the server and tool that caused it.
The taint file is where the quarantine lives. It's keyed on the client's process ID (or MCP_GATE_SESSION, if you set it), so every gate one client launched reads the same file; a page fetched through your web server blocks a post through your Slack server, which is the multi-hop route from the OpenAI report. Refusals come back as JSON-RPC error -32002, the server keeps running, and the model gets an error it can explain to you. Clearing is deliberate: mcp-gate clear, or a new session.
In Meta's terms, an agent should hold no more than two of three properties in a session: it processes untrustworthy inputs, it can reach sensitive systems or private data, and it can change state or communicate externally. Simon Willison's lethal trifecta is the same three legs. The gate lets an agent have all three tools configured and refuses to let the first and third be live at the same time.
The test fixture is the worm in miniature. fake-server.sh returns a release-notes page carrying a fake SYSTEM WARNING that asks for .env.prod to be posted to https://paste.example/ingest and for the notice to be repeated in every message. The run fetches it through one gate, then tries to post it through a second gate on the same session. All 11 checks pass on macOS with bash 3.2.57, including the one that reads the fake server's own log to prove the refused post never arrived.
What the Gate Can't See
The gate sees MCP traffic and nothing else. An agent's own web fetch and shell tools aren't MCP servers, so a stdio proxy never sees them; check which of your agent's tools ship built in before assuming the gate covers them, and put those behind the agent's own permission settings, or turn them off.
The outbound list is yours to get right. A shell tool, a generic HTTP tool, or a file write that a CI job later publishes is outbound whatever its name says, and the one you miss is the hole. The host check has a gap of its own: it reads scheme://host, so a bare paste.example, an email address, or a host split across two arguments gets past it (the taint check still catches the call if the tool is on the outbound list).
The gate stops tool calls; it doesn't stop the model from repeating the payload in its answer to you, which becomes the email route the moment a person pastes that answer somewhere. Like mcp-guard, it's stdio only and runs one jq per message.
The last gap is the person who clears it. Someone who clears the taint 10 times a day without reading the reason will clear the 11th too.
QUICK TIP Gate Outside, Guard Inside Sort each server's tools into readers and writers, then chain the gate in front of the guard in your client config: The complete script, bash 3.2 or later plus |
Quick Wins
🟢 Easy (~10 min): Clone bashmatica-scripts and run mcp-gate/test/run.sh. Then read the fake SYSTEM WARNING in test/fake-server.sh; it asks for two things, the secret and its own repetition, and the second one is what makes it a worm.
🟡 Medium (~1 hour): List your MCP servers in two columns, tools that read outside content and tools that write or send, and turn that list into a policy file. Any server that shows up in both columns is the first one to gate.
🔴 Advanced (half day): Teach the argument check to catch bare hostnames and email addresses, add a fixture case for each, and send the pull request.
Next Week
This closes the MCP arc: a lock for the manifest, a guard on the wire, and a gate on what the results can set in motion. Next week, unless the news votes otherwise, I'm turning to the person who clears the gate, and how to make a human sign-off cost enough that it still means something on the 11th click.
A worm needs two things from an agent: something to read and somewhere to write.
Keep those two apart for the rest of the session.
Mark every tool that reads the outside world, mark every tool that writes to it, and let the gate refuse the second kind once the first kind has spoken; the model will keep reading whatever it's handed, and that's fine, because a payload held in quarantine has nowhere to find its next host.
Bobby R. Goldsmith
Ambassador Extraordinary and Plenipotentiary of Bashmatica! by NodeBridge Automation Solutions
P.S. mcp-gate chains in front of mcp-guard from last week's issue, which reads the lock from the week before; the gate runs fine alone, but the three together cover the manifest, the wire, and the results. If someone forwarded this to you, subscribe at bashmatica.com (it's free, and it's never once asked you to post anything to paste.example).
NODEBRIDGE AUTOMATION SOLUTIONS Every guardrail in this newsletter, already wired into your Claude Code setup. NodeBridge configures Claude Code against your actual repos: MCP servers, subagents, hooks, permissions and a project memory that keeps the right context in and the stale context out. You own every config when it ships. Setups start at $750 and go live in about three days. |