Prompt injection against coding agents
A coding agent runs on text an attacker can write. Map the real injection surfaces on a server, then rank the defences that actually shrink the damage.
What prompt injection against a coding agent is
Prompt injection against a coding agent is simple to state: text the agent reads gets treated as an instruction the agent follows. The agent opens a file, a pull request comment, a web page or the result of a tool call. All of it arrives as the same kind of text as your own request. If an attacker controls any of that text, the attacker is writing into your session.
Every current agent product has this property. The model receives one sequence of tokens. Your request, the system prompt, the file contents and the tool results are joined together, and the model predicts what comes next. There is no privilege bit on a token. Nothing in the format says which part you authorised and which part came from a stranger's README file.
This page is the threat model: where attacker-controlled text reaches an agent running on a server, what an attacker gains at each point, and which defences are worth the effort. The containment advice in our other guides only makes sense once you know what you are containing the agent from.
Why the model cannot separate content from instructions
Training helps and does not settle it. Current models are trained to treat retrieved text with suspicion, and they refuse many crude attempts. A refusal is a probability, not a rule. An attacker can rephrase, retry, and hide the text in a format nobody planned for, and nothing limits how many wordings they try.
The OWASP GenAI project tracks this as LLM01:2025 Prompt Injection and splits it in two. Direct injection is a user's own prompt changing model behaviour. Indirect injection is external content, such as a website or a file, changing behaviour when the model processes it. Indirect injection is the one that matters on a server, because an agent reads far more text than you type.
The first systematic study is Greshake and colleagues, Not what you've signed up for (2023). Their conclusion is the sentence to keep: when an application feeds retrieved text into a model that can call tools, processing that text is close to arbitrary code execution.
The condition that turns a read into a breach
Reading hostile text is not damage on its own. The damage needs a path out of the machine.
Simon Willison named that combination the lethal trifecta in June 2025. An agent that holds private data, is exposed to untrusted content, and can send data outward can be talked into moving the first out through the third.
A coding agent on your VPS has all of that on day one. The private data is your source code, your .env file, your SSH keys and your shell history. The untrusted content is every repository, page and tool result it reads. The way out is git push, curl, npm publish, a pull request body, or a link printed in your terminal that you click.
You cannot remove the second condition, because reading untrusted text is the job you hired the agent for. So every practical defence works on the other two.
Where untrusted text reaches a coding agent on a server
The repository the agent is working in
Every file in the checkout is input. Source comments, README.md, changelogs, test fixtures, vendored code, and the agent instruction files themselves: CLAUDE.md, AGENTS.md and their equivalents. An agent asked to understand a codebase reads them, because that is what you asked for.
What an attacker gains here is reach into everyone who clones the repository and points an agent at it. Instruction files are the most direct route, because they exist to be read as instructions. A pull request that adds four useful lines to CLAUDE.md and one line that redirects the agent is a change a human reviewer skims past.
Issues, pull requests and code review comments
Anything a stranger can type into your tracker reaches the agent the moment you ask it to triage. In May 2025, Invariant Labs published a GitHub MCP finding with exactly this shape. A developer's agent had access to one public repository and to private ones. An attacker filed an issue in the public repository. When the developer asked the agent to look at open issues, the agent read private repository contents and wrote them into a pull request on the public side.
That report describes an architectural problem rather than a code defect in the MCP server. The agent held one wide access token, read from a public inbox, and had permission to write. Nothing was misconfigured in the usual sense, which is why the answer is scoping and not patching.
Web pages the agent fetches
Documentation, forum answers, a vendor page, a search result. Any of it can carry text written for the agent rather than for you. HTML converted to text gives an attacker extra room, because content a browser never displays still reaches the model.
An attacker gains control at the moment the agent is least watched. Nobody reads the full text of a page the agent fetched while looking something up.
MCP tool output
MCP (model context protocol) is the common way agents connect to external tools. Results come back as text and go straight into the context window. There are two surfaces here, not one. The data a tool returns is the obvious one. The tool's own name and description, which the model reads to decide when to call it, is the other, and a server you do not control can change either between calls.
An attacker who lands text in one tool's output reaches every other tool the agent has. That is how an injection in something low value ends up driving something high value.
CI logs, build output and dependency metadata
npm install prints text from packages you did not write. A test failure prints an assertion message from a library. A continuous integration (CI) job log is thousands of lines of third-party output. Ask an agent to fix a failing build and it reads all of it.
Here an attacker gains the build machine, which usually holds deploy credentials and registry tokens and gets less attention than a laptop.
What an attacker actually gets
Four outcomes are worth planning for.
Credential theft. Whatever the agent process can read is in scope: environment variables, ~/.aws/credentials, ~/.ssh, a gh token, a Docker config file. Sending them out does not need curl. A commit to a branch, a pull request description, a package published to a registry, or a DNS lookup for a name the attacker controls all move data off the box.
Code changes you approve. Writing code is what the agent does, so getting it to write a subtly wrong line is the cheapest outcome to reach. An added dependency, or a logging call that carries a token into a log you ship somewhere else.
Persistence. A file written once keeps working with no model involved: a hook in .git/hooks, a postinstall script in package.json, a line appended to a shell startup file, an extra line in CLAUDE.md. The next command runs it.
Movement inside your network. The agent runs where you put it. If that box reaches a database on loopback, an internal admin service, your cloud provider's metadata service, or another host on the private network, so does anything driving the agent.
Auto-approve modes remove the last check
In the default mode, Claude Code asks before it runs a command or edits a file. That prompt is the human check standing between every surface above and a real action. Modes that remove the prompt remove the check.
The documentation is direct about bypassPermissions: use it only in isolated environments like containers or VMs where Claude Code cannot cause damage. Auto mode is softer, and auto-approves tool calls with background safety checks that verify actions align with your request. Those checks catch a lot. They are still model judgement about model output, so treat them as a filter and not as a boundary.
An administrator can remove both. Set permissions.disableBypassPermissionsMode or permissions.disableAutoMode to "disable" in a settings file, and put that file in managed settings so a checked-out project cannot override it. Our guide to Claude Code auto mode and permission rules covers where each rule takes effect.
Defences, ranked by what they buy you
None of these is a fix. Each one either narrows what the agent holds or narrows what it can do with it.
- A machine you can destroy and rebuild, so a compromise costs you an hour instead of an incident.
- Credentials that are separate from your own, scoped to one repository, and short lived.
- No long-lived secrets in the environment the agent's commands inherit.
- Operating system enforcement on network egress and file access, which applies to every process the agent starts.
- Approval prompts kept on for writes and network calls.
- Hooks as a deterministic backstop for the specific actions you can name.
- Reading the diff before you merge it.
The order matters. Items 1 through 4 hold even when the model is fully under an attacker's control. Items 5 through 7 depend on a human paying attention, which is exactly what stops happening on a long agent run.
Put the agent on a machine you can throw away
A VPS that holds a checkout and one scoped token is a much smaller prize than a laptop with your keys on it. Run the agent as its own unprivileged user, not as your login account and not as root. Our guides on a disposable VM for coding agents and least-privilege users on a VPS cover the setup, and running Claude Code safely on a VPS covers the daily shape of it.
Take the secrets out of the environment
An environment variable is readable by every child process, which means every command the agent runs. Claude Code's sandbox can unset named variables before each sandboxed command. On Linux the sandbox needs two packages first:
sudo apt-get install bubblewrap socatThen in ~/.claude/settings.json:
{
"sandbox": {
"enabled": true,
"network": {
"allowedDomains": ["github.com", "*.npmjs.org"]
},
"credentials": {
"envVars": [
{ "name": "GITHUB_TOKEN", "mode": "deny" },
{ "name": "NPM_TOKEN", "mode": "deny" }
]
}
}
}A deny entry unsets that variable before each sandboxed command runs, and allowedDomains holds sandboxed commands to the hosts you list. The credentials block needs Claude Code v2.1.187 or later, checked August 2026. Run /sandbox in a session to see which layers are active and which dependencies are missing. Deciding which secrets need to exist on that box at all is the larger half of the job, and keeping secrets out of an AI agent's reach works through it.
Cut network egress at the operating system
A firewall rule does not care what the model decided. Run the agent as a dedicated agent user, then drop what that user sends:
table inet agentcage {
chain output {
type filter hook output priority filter; policy accept;
meta skuid "agent" ct state established,related accept
meta skuid "agent" oif lo accept
meta skuid "agent" counter drop
}
}That leaves the agent user with loopback only, so its traffic has to go through a proxy you run on the same box, and the proxy holds the hostname allowlist. With https_proxy pointing at that proxy, the client sends a CONNECT request and the proxy performs the name lookup, so the agent needs no outbound DNS (domain name system) of its own. Check your work with sudo nft list ruleset and watch the counter on the drop rule rise while the agent tries to reach something new.
Keep a second SSH session open while you apply firewall changes. Check what your container runtime does to these rules too: Docker writes its own chains, and published Docker ports bypass ufw describes the surprise that causes.
Hooks: the check the model cannot talk its way past
Permission rules and hooks are enforced by Claude Code, not by the model. The documentation states it plainly: instructions in your prompt or CLAUDE.md shape what Claude tries to do, and they do not change what Claude Code allows. That distinction is the whole value. A line in CLAUDE.md reading "never run curl" is a suggestion an injected paragraph can argue with. A hook is a process that returns an exit code.
Register a PreToolUse hook in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/no-egress.sh"
}
]
}
]
}
}The hook receives the tool call as JSON on standard input. Exit code 2 blocks the call and shows Claude the reason from standard error. Exit code 0 lets the call continue through the normal permission flow.
#!/usr/bin/env bash
# PreToolUse: stdin holds the tool call, exit 2 blocks it.
cmd=$(jq -r '.tool_input.command // ""')
if printf '%s' "$cmd" | grep -qE '(^|[;&|]|\s)(curl|wget|nc|ncat)(\s|$)'; then
echo "Blocked: this repository does not allow outbound network commands." >&2
exit 2
fi
exit 0Now the honest part. This is a denylist over a shell string, and denylists over shell strings leak. python3 -c opens a socket without using the word curl. A make deploy target hides the same call one level further down. Write hooks for the mistakes you can name, and put the boundary you actually rely on in the kernel or on the network.
Permission deny rules carry a matching limit worth knowing. Read and Edit deny rules cover Claude's own file tools and the file commands it recognises in Bash, such as cat, head, tail and sed. They do not cover a Python or Node script that opens the file itself. Rules are evaluated deny first, then ask, then allow, so a deny rule cannot carry an allowlist exception.
{
"permissions": {
"deny": [
"Read(.env)",
"Read(./secrets/**)",
"Bash(git push *)"
]
}
}Watch what leaves, and read the diff
An agent run produces a diff and a set of network calls. Both deserve a look before anything merges or deploys. A self-hosted security review pass over the diff catches a different class of change than a human skim does, and knowing what a coding agent sends off the box tells you what normal traffic looks like, so an odd request stands out.
What is still unsolved
There is no reliable separation between content and instruction today. Every defence that ships is either a filter with a failure rate or a limit on consequences. Nothing in the stack marks a span of text as data that must never be obeyed.
Filters do help and they do fail. A classifier that catches most injection attempts still has to be right every time, while an attacker has to be right once. That asymmetry is why a published success rate for a defence is a starting point for the next attempt rather than a guarantee.
The most promising work sits at the design level rather than the model level. CaMeL, from Defeating Prompt Injections by Design (Debenedetti and colleagues, 2025), extracts the control flow and data flow from the trusted request first, so untrusted data cannot change what the program does, then enforces capability checks when tools are called. The paper's own figures on the AgentDojo benchmark show what that costs.
The data behind this chart
[
{
"label": "Undefended agent",
"tasks_solved_pct": 84
},
{
"label": "CaMeL",
"tasks_solved_pct": 77
}
]The undefended agent solved 84 percent of tasks. CaMeL solved 77 percent with a security guarantee attached. Those are the paper's published figures on one benchmark and not a measurement of your workload. The gap between them is roughly what a real guarantee costs today.
Until a design like that ships in the tools you use every day, plan for the agent to be compromised at some point and make that event boring. That is the whole argument for a disposable machine, scoped credentials, controlled egress and a habit of reading the diff.
FAQ
Can I stop prompt injection by telling the agent to ignore instructions in files?
No. That sentence is text in the same context window as the attack, and it competes with the attacker's text on equal terms. Claude Code's documentation draws the line clearly: instructions in your prompt or CLAUDE.md shape what the agent tries to do, and they do not change what the tool allows. Treat an instruction file as a statement of intent, and put anything you rely on into permission rules, a PreToolUse hook or a firewall rule.
Is prompt injection a real risk if the agent only touches my own repository?
Yes, because your repository is full of text you did not write. Dependency README files, lockfile URLs, test fixtures, vendored code and the output of npm install all arrive during an ordinary task. Anything pulled in from an issue tracker or a documentation site arrives the same way. The risk grows with how much the agent reads, and a useful agent reads a lot.
Does running the agent in a container solve this?
It limits the damage, and only if you take the credentials away as well. A container with your SSH agent forwarded, cloud credentials in the environment and unrestricted network access hands an attacker almost everything the host would. What the container really buys you is a filesystem you can delete and a clean place to enforce egress rules. Pair it with a token scoped to one repository.
What single change reduces the risk most?
Remove long-lived credentials from the environment the agent's commands inherit, then give that machine a default-deny egress policy. Together they break the third condition in the lethal trifecta: text can still hijack the agent, and the data it reaches has nowhere useful to go. Approval prompts and diff review help too, and they depend on a human staying alert through a long run, which is why they rank below those two changes.