TL;DR. I hid one injection in the untrusted carriers a coding agent reads (PR comments, issue bodies, SKILL.md, dependency READMEs, CI logs), rendered the same intent in nine obfuscations, and sprayed it across five carriers (45 cases). A scanner that inspects the input text — even with normalization — was evaded on 15/45 (33%): base64, synonyms, and translation erase the lexical signature. A deterministic guard that inspects the tool-call wire blocked 45/45 (100%). The reason is plain — all 45 text variants collapse to one action. Input defense fights an unbounded text space; wire defense fights a bounded action space.
The unsettled question
In the prompt-injection overview the conclusion was: the guarantee comes from a deterministic layer outside the model, and what's left is where to place that boundary. Coding agents are where that question is sharpest — nearly everything an agent reads (PR comments, issues, READMEs, CI logs, an installed SKILL.md) is text an attacker can write, while the agent holds dangerous tools (shell, file write, network). Comment and Control (2026-04) drove Claude Code, the Gemini CLI, and the GitHub Copilot agent to RCE and secret exfiltration using nothing but instructions hidden in PR comments and issue bodies — all three vendors treated it as a structural limitation.
The field's first reflex is "scan that text before the agent reads it." Does that actually close, or is there a ceiling? Headlines won't tell you. Reproduction will.
How I dug in
No external model was called. I measured only the part that's deterministically reproducible — the comparison of where the defense sits. The threat-model premise comes from the primary sources: a capable coding agent decodes any carrier or obfuscation into the same action (exactly what Comment and Control showed, hidden text → RCE). So each obfuscation is modeled as "text varies, the action is invariant."
- Attack surface — 5 carriers (
pr_comment,issue_body,skill_md,dep_readme,ci_log) × 9 semantics-preserving obfuscations (plain, base64, homoglyph, zero-width, HTML comment, string-split, synonym, spotlight-delimiter escape, translation) = 45 cases. - Defense A — input text scanner (LLM-free): NFKC normalization + zero-width stripping, then a keyword/regex blocklist (
ignore previous,curl,| sh,https?://,base64…). Representative of "scan what the agent reads." - Defense B — tool-call wire guard (LLM-free): deterministic field-diff of the incoming tool_call against the task's sealed intent (here, "fix the failing unit test" → allowed tools
read_file(repo/),write_file(repo/src/),run_tests). The guard never sees the carrier text — only the resulting action.
The core reproduction is short.
# Defense A: looks at the TEXT — evaded once obfuscation erases the lexical signature
def input_scanner_blocks(text):
norm = unicodedata.normalize("NFKC", text).replace("", "")
return any(rx.search(norm) for rx in BLOCK_PATTERNS)
# Defense B: looks at the ACTION — 45 texts collapse to 1 action, obfuscation is invisible
def wire_guard_blocks(action): # action = {"tool":"run_shell","arg":"curl …| sh"}
return action not in SEALED_ALLOWLIST # not in the sealed intent -> block
What I found
Running the same attack through both defenses gives an asymmetry.
| Defense placement | Space it must cover | Blocked | Evaded |
|---|---|---|---|
| Input text scanner | 9 text variants (unbounded in principle) | 30/45 (67%) | 15/45 (33%) |
| Tool-call wire guard | 1 action (the dangerous-action set) | 45/45 (100%) | 0/45 |
Input defense fights an unbounded text space
Even the normalized scanner caught homoglyph, zero-width, string-split, HTML comment, and delimiter escape — the URL string leaks through intact. But base64, synonym, and translation erase the lexical signature itself and passed (3 of 9 obfuscations, carrier-independent). Don't read 33% as a ceiling — add a base64-decode rule and the attacker moves to ROT13; block synonyms and they shift to another detour. The input defender runs an arms race over an open text space. It is, in principle, endless.
The action is one, no matter the obfuscation
The wire guard blocked 45/45 not because it is clever but because obfuscation lives in the text channel while harm happens in the action channel. Base64 or translation alike, to cause harm the injection must become one tool_call — run_shell("curl …| sh") — and by then the obfuscation is already decoded away. The guard deterministically rejects any call not in the sealed intent (read_file, write_file(src/), run_tests). Forty-five text variants collapse to one action, so the wire defender fights a closed action space. This is why the overview's wire-guard measurement (13/13 injections blocked) closes identically over coding-agent carriers.
The asymmetry matches vendors' own designs. Even the autonomous vulnerability-discovery harness Anthropic open-sourced on 6/6 cages its agent not with model alignment but with gVisor sandboxing, network isolation, and an egress allowlist — the same pattern of taking the model out of the trust path and putting determinism at the boundary.
Skill registries are a new carrier
Among the carriers, skill_md is the fastest-growing surface. ClawHub Security Signals (2026-06) scanned 67,453 agent skills in the ClawHub registry with three scanners (VirusTotal, static analysis, SkillSpector); only 0.69% were flagged by all three. The disagreement is structured, not random — SkillSpector dominates suspicious verdicts (75.3%), VirusTotal dominates malicious ones (72.8%). No single scanner closes it, and now that the skill is a unit of design, that unit is an installable carrier. Scanning skills on the input side lands precisely in the 33% arms race above.
What this study cannot say
The threat-model premise (the agent decodes obfuscation into the same action) is taken from primary sources, not measured live — no external model was called. So the per-model success rate of "does the agent actually execute the base64" is not my measurement but rests on the paper numbers the overview cites (guard LLM bypassed up to 100%). The 33% and 100% reproduce the asymmetry of defense placement, not an absolute benchmark. And the wire guard's limit is the same as the overview's — it cannot judge the content of an allowed action (plant a backdoor via write_file(repo/src/x) and the injection guard passes it). This looks at one threat only: injection.
Next investigation
How to automatically derive the sealed intent inside a real coding-agent loop — if you can't tightly scope allowed tools and paths from the task statement, the wire guard is either too wide (misses) or too narrow (false-blocks normal work). And for the skill_md carrier, how far an install-time deterministic check (a skill's declared actions ⊆ allowed actions) closes the ClawHub 0.69% disagreement. Candidates for the next dig.
Primary sources
- Prompt-injection overview — the ceiling on probabilistic guards, the guarantee outside the model — the parent overview; wire-guard measurement 13/13
- Comment and Control — coding agents (Claude Code, Gemini CLI, Copilot) hijacked via PR comments (2026-04)
- ClawHub Security Signals — 67,453 agent skills, scanners agree only 0.69% (2026-06)
- Anthropic — Defending Code Reference Harness (autonomous vuln discovery, caged by egress allowlist + sandbox, 2026-06)
- Greshake et al. — Not What You've Signed Up For (indirect injection, 2023)
- Microsoft — Spotlighting (input-delimiter marking defense, 2024)
- Reproduction harness — 5 carriers × 9 obfuscations = 45 cases, input scanner vs tool-call wire guard (LLM-free, written and run for this post)