Prompt injection defense for agents: what actually works

Prompt injection defense for agents works when the controls sit outside the model. Instructions hidden in a web page, file or tool result can steer an agent into a call nobody asked for. Deny rules, PreToolUse hooks and a sandbox decide what runs and what it can reach, without relying on the model to ignore the injected text.
Checked against the Claude Code documentation at code.claude.com, fetched 2026-10-30. The pages do not give a product version for the behaviour described, except where noted for the hook onFailure field.
What indirect injection changes for agents
Indirect prompt injection arrives through content the agent reads on its own: a page it fetches, a README in a cloned repository, or the output of a tool. The Claude Code security documentation describes the attack as one where someone “attempts to override or manipulate an AI assistant’s instructions by inserting malicious text.” The MCP documentation names the source of the risk: servers that fetch external content can expose you to prompt injection.
That changes the threat model. Asking the model to disregard instructions it finds in fetched content is not a control, because the injected text reaches the model through the same channel as your own instructions. A control has to stop the action itself, whatever the model decided to do. The table below sorts the controls by that test.
Which controls stop a call before it runs
| Control | What it decides | Documented limit |
|---|---|---|
| Deny rules in permissions | Blocks matching tool calls. Rules are checked in the order deny, ask, then allow | A rule matches the command text the agent writes, not the program itself |
| PreToolUse hook | Runs your own check on each tool call and can block it | Exit code 1 does not block. A timed-out hook does not block unless onFailure is set to "block" |
| Sandbox | Operating-system limits on shell commands for filesystem and network | Covers shell commands only. File tools, MCP servers and hooks run outside it |
| MCP server approval | Which project-scoped servers load | Headless and Agent SDK runs load them without asking |
| Output limits | How much tool output enters the context | Limits size, not content. The docs describe no check on what the output says |
The Claude Code security documentation lists its protections as the permission system, command approval, sandboxing and web page summaries. Content scanning for injected text is not on that list. Web page summaries reduce how much raw page text reaches the agent, but the summary is itself a model call over the page.
Step 1: Shrink the tool surface before the first prompt
- List every source of tools the session can load. The MCP documentation says reviewing
.mcp.jsondoes not show every server a session can load, because servers at other scopes, claude.ai connectors and plugins can add servers too. - Deny outbound fetch tools you do not need. The security documentation says commands such as
curlandwgetare not auto-approved by default, and that you can add them topermissions.deny. - Write the deny entries into the settings file that the repository shares, so every developer gets them.
- Check the matching limit. A deny rule for one spelling of a program does not match the same program invoked in another form, so treat deny rules as a first layer, not the boundary.
- Move rules that no developer should be able to remove into managed settings. The security documentation recommends managed settings to enforce organizational standards.
{
"permissions": {
"deny": ["Bash(curl *)", "Bash(wget *)"]
}
}
Step 2: Gate every call with a PreToolUse hook
PreToolUse hooks run before a tool call executes, and they can block it. Register one for the Bash tool, with a failure mode set to block so a broken hook stops the call instead of letting it through.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.sh\"",
"args": [],
"onFailure": "block"
}
]
}
]
}
}
The hook script reads the event from its standard input, where tool_input.command holds the command the agent wants to run. On a match it exits with code 2, which blocks the call. Exit code 1 without valid JSON output is only a non-blocking error, so a policy hook must not rely on it. If the script wants to explain the decision, it can return JSON instead. The documented shape for a deny is:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Destructive command blocked by hook"
}
}
Keep the order in mind. A hook that returns allow does not override a deny rule, because the permission documentation says a matching deny rule blocks the call whatever the hook returns.
Step 3: Contain what a hijacked run can reach
- Turn the sandbox on with
/sandbox. The sandbox is an operating-system boundary around the shell commands Claude runs and the processes they start. - Leave the network allowlist empty and add only the domains a task needs. The sandboxing documentation says allowed domains start empty, and pre-allowing a domain with
allowedDomainsavoids the prompt for it. - Keep filesystem isolation on. With it off and commands auto-allowed, the docs say a command can write shell startup files or
~/.claude/settings.jsonand use them to widen its access on the next run. - Protect credentials separately. Default read access covers the whole computer except denied directories, and the sandbox still allows reading credential files.
Limits that matter in practice
- Deny rules and hook matchers work on command text. The permission documentation says a rule for one invocation form does not match the same program called another way, so pair rules with a hook that inspects the full command.
- The
iffilter on a hook is best-effort. When Claude Code cannot tell which commands a Bash input runs, it runs the hook regardless of the pattern. The hooks documentation tells you to use the permission system for a hard allow or deny. - A hook that times out does not block the call unless its failure mode is set to block. The call then continues through the normal permission flow, so a stalled hook is not a gate.
- The sandbox covers shell commands only. File tools, MCP servers and hooks run outside it, so a file write made through a file tool is not limited by the sandbox.
- An excluded command runs with your full access, so review the exclusions as carefully as the rules.
Treat fetched content as untrusted
No control removes injected text from the content itself, and the security documentation says no system is completely immune to all attacks. Its best practices are the rest of the defense:
- Review suggested commands before you approve them.
- Avoid piping untrusted content directly into the agent.
- Verify proposed changes to critical files.
- Run scripts and tool calls in a virtual machine when they touch external web services.
- Report suspicious behaviour with
/feedback.
Treat these as layers that reduce the blast radius. None of them is a fix on its own.
A test you can run this week
Set up a throwaway repository. Add a file containing a harmless planted instruction, such as a request to create a marker file outside the repository. Run the agent with your rules and hook in place, then check two things: the marker file is absent, and the session shows the blocked attempt. Run the same test after each change to the rules. This is our suggested test method, not a documented behaviour of the product, so record which version you tested against.
Decision checklist
- Every tool source is listed, including plugin and connector servers.
- Outbound fetch tools are denied or need explicit approval.
- Rules that must hold are in managed settings, not in a user’s own settings.
- The hook exits with code 2 on a match and sets
onFailureto block. - The sandbox is on, the network allowlist is explicit and filesystem isolation is on.
- Headless and Agent SDK runs use a reviewed set of MCP servers.
- Fetched content is reviewed before it changes critical files.
Enforcing this with AIControls
AIControls covers the data path around the agent. Claude Code can be routed through the gateway, both from the CLI and from VS Code, and per-user tokens are supported. PII in a prompt can be held for approval, or redacted or blocked. Those controls limit what leaves the agent. They do not replace the tool-level rules above, and this post does not claim that AIControls detects injected instructions. The MCP security overview covers the wider picture, and the AIControls platform page describes the product.
For related steps, see how to block destructive commands in Claude Code, how to stop Claude Code from reading secrets and env files and how to stop PII and national IDs from reaching LLMs.
Frequently asked questions
Does an instruction in the prompt or CLAUDE.md stop an agent from doing something?
No. The Claude Code permissions documentation says permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or CLAUDE.md shape what the agent tries to do, but they do not change what it is allowed to do. A limit that must hold needs a permission rule, a PreToolUse hook or a sandbox boundary.
Is exit code 1 enough for a hook that enforces a policy?
No. The hooks documentation says exit code 1 without valid JSON output is a non-blocking error, and that a hook meant to enforce a policy should exit with code 2. An exit code 0 with no output means the hook has no decision, so the tool call continues through the normal permission flow.
Does the sandbox stop a malicious command on its own?
Only partly. The sandbox is enforced by the operating system around shell commands, and it holds regardless of which command the model chose to run. The sandboxing documentation also says not to rely on its restrictions to stop a malicious command, because in auto-allow mode that command can run without a prompt. Pair it with deny rules and hooks.
Do MCP servers in a project need approval before they run?
In an interactive Claude Code session, yes: the client prompts before it uses project-scoped servers from .mcp.json. Headless runs, Agent SDK sessions and cloud sessions outside a project load those servers without asking, and a cloned repository cannot approve its own servers in an untrusted folder. Review the server list for those runs separately.