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: mcp-gate. It wraps a stdio MCP server the way mcp-guard does and reads a small policy file that sorts each server's tools into untrusted readers and outbound writers. A result from an untrusted reader taints the session, and every gate your client launched shares that taint, so a page fetched through one server blocks a post through another. Any call whose arguments name a host outside your allowlist is refused, tainted or not. Refused calls come back as JSON-RPC errors and never reach the server. Hand-raiser keyword: MCPGATE. The complete script is inline in the Quick Tip below and in the bashmatica-scripts repo, with a fake server and an 11-check test run.

 

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:

{
  "allow_hosts": ["github.com", "api.github.com"],
  "servers": {
    "web":   { "untrusted": ["*"] },
    "slack": { "untrusted": ["read_channel"], "outbound": ["post_message"] }
  }
}
{
  "mcpServers": {
    "web": {
      "command": "/abs/path/mcp-gate.sh",
      "args": ["-p", "/Users/you/.config/mcp/gate.json", "-n", "web", "--",
               "/abs/path/mcp-guard.sh", "-f", "/Users/you/.config/mcp/mcp-pin.lock", "-n", "web", "--",
               "npx", "-y", "some-fetch-server"]
    }
  }
}

The complete script, bash 3.2 or later plus jq:

#!/usr/bin/env bash
# mcp-gate: sit between an MCP client and a stdio server and gate what the agent
# DOES after it reads something it shouldn't trust. A tools/call result from a
# tool the policy marks untrusted taints the session; once the session is tainted,
# every tool the policy marks outbound is refused until a human clears the taint.
# Independently, any tools/call whose arguments name a host outside allow_hosts is
# refused, tainted or not. Refusals are JSON-RPC errors; the call never reaches
# the server, and the server keeps running.
#
#   mcp-gate [-p policy.json] [-n name] -- <server command> [args]
#   mcp-gate clear          # remove this client's taint (or MCP_GATE_SESSION's)
#
#   -p, --policy  policy file (default: mcp-gate.json)
#   -n, --name    server entry in the policy (default: basename of the server command)
#
# Policy:
#   { "allow_hosts": ["github.com", "api.github.com"],
#     "servers": { "web":   { "untrusted": ["*"] },
#                  "slack": { "untrusted": ["read_channel"], "outbound": ["post_message"] } } }
#
# One taint file per client session, shared by every gate that client launched
# (keyed on the client's PID, or MCP_GATE_SESSION if set), so a result read through
# one server blocks a write through another. Chain it with mcp-guard:
#   mcp-gate -p gate.json -n fs -- mcp-guard -f mcp-pin.lock -n fs -- npx some-server
#
# Exit: 0 clean shutdown; 2 bad usage; 3 jq missing; 4 no policy entry.
set -uo pipefail

policy="mcp-gate.json"; name=""
taint="${MCP_GATE_SESSION:-${TMPDIR:-/tmp}/mcp-gate.$PPID}.taint"
if [ "${1:-}" = clear ]; then rm -f "$taint"; echo "mcp-gate: cleared $taint" >&2; exit 0; fi
while [ $# -gt 0 ]; do
  case "$1" in
    -p|--policy) policy="$2"; shift 2 ;;
    -n|--name)   name="$2"; shift 2 ;;
    --) shift; break ;;
    *) echo "mcp-gate: unknown argument: $1" >&2; exit 2 ;;
  esac
done
[ $# -gt 0 ] || { echo "usage: mcp-gate [-p policy] [-n name] -- <server command> [args]" >&2; exit 2; }
command -v jq >/dev/null || { echo "mcp-gate: jq is required" >&2; exit 3; }
[ -n "$name" ] || name="$(basename "$1")"
P="$(jq -c --arg n "$name" '{s: .servers[$n], hosts: (.allow_hosts // [])}
  | select(.s != null) | {u: (.s.untrusted // []), o: (.s.outbound // []), hosts}' "$policy" 2>/dev/null)"
[ -n "$P" ] || { echo "mcp-gate: no entry '$name' in $policy" >&2; exit 4; }

tmp="$(mktemp -d "${TMPDIR:-/tmp}/mcp-gate.XXXXXX")"
mkdir "$tmp/req"; mkfifo "$tmp/in" "$tmp/out"
srv_pid=""; cli_pid=""
# shellcheck disable=SC2329  # invoked by the EXIT trap
cleanup() {
  exec 2>/dev/null
  [ -n "$cli_pid" ] && kill "$cli_pid" 2>/dev/null
  [ -n "$srv_pid" ] && kill "$srv_pid" 2>/dev/null
  rm -rf "$tmp"
}
trap cleanup EXIT
emit() {  # both loops write to the client; a mkdir lock keeps lines whole
  until mkdir "$tmp/lock" 2>/dev/null; do sleep 0.01; done
  printf '%s\n' "$1"; rmdir "$tmp/lock"
}

"$@" <"$tmp/in" >"$tmp/out" &
srv_pid=$!
exec 3>"$tmp/in" 4<&0

# Client -> server: every tools/call is checked before the server sees it.
{ while IFS= read -r line; do
  why="$(jq -r --argjson P "$P" --arg tainted "$([ -s "$taint" ] && head -1 "$taint")" '
    select(type == "object" and .method == "tools/call") | .params as $c
    | ([$c.arguments | .. | strings | scan("[a-zA-Z][a-zA-Z0-9+.-]*://([^/:?#\\s\"]+)") | .[0]
        | ascii_downcase | select(. as $h | $P.hosts | index($h) | not)] | unique) as $bad
    | if ($P.hosts | length) > 0 and ($bad | length) > 0
        then "\($c.name): host not in allow_hosts: \($bad | join(", "))"
      elif $tainted != "" and ($P.o | index($c.name))
        then "\($c.name): outbound call after untrusted result (\($tainted))"
      else empty end' <<<"$line" 2>/dev/null)"
  if [ -n "$why" ]; then
    echo "mcp-gate: BLOCK $name: $why" >&2
    emit "$(jq -c --arg m "mcp-gate: $why; a human has to clear this session" \
      '{jsonrpc: "2.0", id, error: {code: -32002, message: $m}}' <<<"$line")"
    continue
  fi
  k="$(jq -r 'select(type == "object" and .method == "tools/call" and has("id"))
    | .id | tojson | @uri' <<<"$line" 2>/dev/null)"
  [ -n "$k" ] && jq -r '.params.name' <<<"$line" >"$tmp/req/$k"
  printf '%s\n' "$line" >&3
done; exec 3>&-; sleep 2; kill "$srv_pid" 2>/dev/null; } <&4 &
cli_pid=$!
exec 3>&- 4<&-

# Server -> client: a result from an untrusted tool taints the whole session.
while IFS= read -r line; do
  k="$(jq -r 'select(type == "object" and has("id")) | .id | tojson | @uri' <<<"$line" 2>/dev/null)"
  if [ -n "$k" ] && [ -f "$tmp/req/$k" ]; then
    tool="$(cat "$tmp/req/$k")"; rm -f "$tmp/req/$k"
    if jq -e --arg t "$tool" '.u | index("*") or index($t)' <<<"$P" >/dev/null; then
      [ -s "$taint" ] || echo "mcp-gate: TAINT $name: $tool returned content; outbound tools now refused" >&2
      printf '%s/%s %s\n' "$name" "$tool" "$(date -u +%FT%TZ)" >>"$taint"
    fi
  fi
  emit "$line"
done <"$tmp/out"

wait "$srv_pid" 2>/dev/null
srv_pid=""
exit 0
 

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.

See the Setup Packages