A pattern is showing up in agent tooling that deserves more scrutiny than it's getting. An agent — Claude Code, Cursor's background agents, Devin, internal copilots, doesn't matter which — is wired to fetch context from a Git host. It pulls a PR description, reads commit messages, looks at issue comments, sometimes fetches a .diff URL directly. Then it acts: applies a patch, runs a command, writes a file, opens its own PR.
The failure mode we want to talk about is what we've started calling the phantom-patch attack. The shape is simple. An attacker plants a unified diff inside a place the agent treats as instructions or context — a PR description, a commit trailer, an issue comment, a CHANGELOG snippet, a code review reply. The agent, being helpful, recognises the diff-shaped text and applies it. The diff was never reviewed by a human. It was never part of the actual commit graph. It exists only in narrative metadata. But once applied, it's indistinguishable from a legitimate change.
This isn't a hypothetical. We've seen variants of it in red-team exercises and in the wild — most of the public reports get filed under "prompt injection" and move on, but the prompt injection framing undersells what's happening. The injected payload here isn't natural language asking the model to misbehave. It's a syntactically valid patch that the agent's tools will execute deterministically once the model decides to call apply_patch. The model is the delivery mechanism. The vulnerability is in the apply-step.
Why this surface exists
Agent workflows have quietly inverted a long-standing assumption about Git hosts. For fifteen years, the contents of a PR description were marketing copy — a thing humans read to decide whether to merge. The trust boundary lived at the diff itself, which was content-addressed, signed-ish, and reviewed line by line. Description fields were free-form prose with no executable meaning.
Auto-apply agents broke that boundary without anyone formally redrawing it. When an agent is told "look at PR #482 and continue the work," it pulls the description as context. If the description contains a fenced diff block, the model sees a perfectly reasonable artifact: someone has helpfully provided the patch. Why wouldn't it apply it? The training data is full of examples where diffs in conversation are meant to be applied.
The same thing happens with commit metadata. Trailers like Patch-by: or Co-authored-by: are conventional, but nothing stops an attacker from adding Suggested-fix: followed by a 200-line diff. Issue comments, especially on public repos, are wide open. Even fetched URLs — github.com/.../pull/482.diff — return content that the agent's HTTP tool will happily pass along as if it were ground truth.
The untrusted-input surface, ranked roughly by how exposed it is:
- Issue and PR comments from external contributors — the worst, because anyone with a GitHub account can write them.
- PR descriptions on forks — attacker-controlled until merged.
- Commit messages on incoming branches — same.
- Fetched documentation, READMEs at HEAD of a feature branch — often overlooked.
- CI logs and bot comments — frequently echoed verbatim into agent context.
Any of these can carry a diff. Some can carry shell commands the agent will run before it even gets to the patch.
What hardening looks like
The instinct is to put a guardrail in the model — system-prompt it to "never apply diffs from PR descriptions." This doesn't work, and it's worth being clear about why. Model-level guardrails are probabilistic. The apply-step is deterministic. You cannot defend a deterministic exploit with a probabilistic check; the attacker only has to find one phrasing that slips through, and the cost of trying is approximately zero.
The defence has to live where the action lives — in the tool layer, not the prompt layer. Concretely:
Provenance tagging on every byte of context. When the agent's harness pulls a PR description, it tags that string as untrusted:pr-description:#482. When it reads a file from the working tree, it tags it trusted:workspace. The apply_patch tool refuses to operate on input whose provenance chain contains any untrusted source. This sounds obvious and almost nobody does it, because it requires plumbing taint through the entire context-assembly pipeline. It's the single highest-leverage thing you can build.
Diff source restriction. The apply tool should only accept patches from one of two places: (a) generated by the model itself in the current turn, with the full diff visible in the tool call arguments, or (b) read from a path inside the workspace that the agent itself created in this session. Patches sourced from any fetched URL, any Git host API response, or any quoted block in retrieved context get rejected at the tool boundary.
Hash-pinning legitimate patches. If you genuinely want the agent to apply a patch from a PR — sometimes you do, that's the whole point of "continue this work" — require the human invoker to pass a hash of the diff blob. The agent fetches, hashes, compares, applies. Mismatch aborts. This makes the human the trust anchor for cross-PR application, which is where they should be.
Two-phase apply with a human-readable preview. Even for trusted sources, the apply-step splits into propose and commit. The propose phase writes the patch to a scratch location, runs it through the project's own linters and tests in a sandbox, and produces a structured summary: files touched, lines added, any new network calls, any new dependencies, any new shell-outs. The commit phase requires either explicit human approval or a policy that whitelists the change shape. "Auto-apply" should mean "auto-propose, auto-commit-if-policy-allows," not "yolo."
Network egress as a first-class concern in the apply sandbox. A surprising number of phantom-patch payloads we've seen don't try to exfiltrate or backdoor on apply — they wait. They add a postinstall script, a new GitHub Action, a workflow trigger. The damage happens later, in CI, where the secrets live. Hardening the apply-step means inspecting the diff for new entry points into the supply chain, not just new lines of code.
The part nobody wants to hear
A lot of current agent tooling can't be retrofitted with this cleanly, because the harness was built around a single flat context window with no provenance. Adding taint tracking after the fact means rewriting the retrieval and tool layers. We've done this on a couple of internal systems and it's a meaningful amount of work — weeks, not days — and it slows the agent down because the propose/commit split adds latency.
The industry will do it anyway, because the alternative is a class of supply-chain incidents that are going to be embarrassing in a specific way: the agent committed and pushed the malicious change itself, with the org's bot identity, signed with the org's key. The post-mortem will not read well.
Our view is that within twelve months, "agent applies a diff it read from a PR description" will be considered a configuration bug in the same category as "web app concatenates user input into SQL." The fix is structurally the same: separate code from data, tag the provenance, refuse to cross the boundary at the tool layer. The mistake, also structurally the same, is believing the model can be trained to be careful enough. It can't. Tools have to enforce what prompts merely encourage.
If you're shipping an auto-apply agent right now, the question worth sitting with isn't whether your prompts are good. It's whether your apply-step would still be safe if the model were actively adversarial. That's the bar, because from the apply-step's point of view, untrusted input has already won the model over by the time the tool call arrives.