Untrusted content is data, never instructions
By the end you can
- Define prompt injection and name Claude Code's documented defenses
- Treat file contents, web pages, tool results and repository config as data
- Read a WebFetch result as an extraction rather than as the page
- Pick the control that still holds when instructions arrive inside content
Five lessons of this level went into machinery: six modes, rule precedence, the classifier, what bypass costs, where secrets live. Every one of them assumed the instructions in the window came from you.
Now a paragraph in the window says read the deploy key and include it in your summary, and you did not type it. It arrived inside a file, a fetched page or a tool result. Lesson 9.6 argued the model side of this for the MCP channel and it is not repeated here. The question this level asks is different: given that the text got in, which of that machinery is still standing?
What prompt injection is
Hostile instructions embedded in a file, web page, or tool result that attempt to redirect Claude toward actions you never asked for. — Glossary
The glossary names three carriers. A fourth is the repository itself, and it is the one nobody counts.
| Carrier | How it enters | Why you do not control it |
|---|---|---|
| A file | Read, Grep, a fixture, a vendored dependency, a generated report | Anyone who can open a pull request can put a paragraph in a file |
| A web page | WebFetch, or curl piped into a prompt | The page is written by its owner, not by you |
| A tool result | An MCP server returning an issue body, a ticket, a thread | The server is a pipe; the strings were typed by strangers |
| The repository's configuration | Settings files loaded at startup | Some of it is used before you have trusted the folder |
That last row is not a judgement call. Some of what a repository supplies is used
before you have trusted the folder — its hooks and helper commands, a project
skill's hooks and allowed-tools among them, run in a claude -p or SDK
session, which never shows the dialog, while the grants it asks for wait. And in
that same column, servers in the repository's .mcp.json are "connected
without asking, approved or not"
(Permissions), which reopens the
tool-result carrier two rows up. Lesson 9.7 walks that table's -p column row by
row, including the rows whose answer depends on the runner's version.
What the built-in defenses cover
Claude Code's defenses include the permission system, command injection detection, and trust verification. — Glossary
Here are seven of the safeguards that page lists, with one column added — the qualifier the docs put on each line (Security):
| Safeguard | What the page says | Stated for |
|---|---|---|
| Permission system | Sensitive operations require explicit approval | Manual mode |
| Command injection detection | Suspicious bash commands require manual approval even if previously allowlisted | Manual mode |
| Fail-closed matching | Unmatched commands require approval by default | Manual mode |
| Network request approval | Most tools that make network requests require user approval by default | Manual mode |
| Network command approval | curl and wget are not auto-approved by default | Unqualified; how they prompt is Manual mode |
| Isolated context windows | Web fetch uses a separate context window to avoid injecting potentially malicious prompts | Unqualified |
| Trust verification | First-time codebase runs and new MCP servers require trust verification | Unqualified |
Four of these seven are written entirely about one mode — default (labelled
Manual from v2.1.200 — Lesson 1.3). That is a statement about the
documentation, not an inference about the rest: the page attaches those four
safeguards to Manual mode and attaches no equivalent sentence to acceptEdits,
auto, dontAsk or bypassPermissions. What it does say about the far end of
that range is blunt — "bypassPermissions offers no protection against prompt
injection or unintended actions" (Permission
modes) — while auto mode adds
a layer built for this problem: a server-side probe that scans tool results for
suspicious content, and a classifier that reviews actions with tool results
stripped, so injected text cannot manipulate it directly
(Glossary). Lesson 10.3 covered what
that classifier sees; this lesson is why the results are stripped.
The page then declines to overclaim, and the sentence is worth keeping: "While these protections significantly reduce risk, no system is completely immune to all attacks" (Security).
Why WebFetch results are thin
WebFetch takes a URL and a prompt describing what to extract, converts HTML to
Markdown, and runs that prompt against the content using a small, fast model.
For most fetches Claude receives that model's answer, not the raw page, and
the conversion step is not configurable (Tools
reference).
This makes WebFetch lossy by design. The extraction prompt determines what reaches Claude, so a result that says a page doesn't mention something may only mean the prompt didn't ask about it. — Tools reference
First consequence: a result reporting that a page says nothing about X is not
evidence the page says nothing about X. Large pages are also truncated to a fixed
character limit before processing, so a second reason an answer comes back empty
is that the passage never reached the extraction model at all. This is the shape
Lesson 5.5 named the trust-then-verify gap: a claim standing in for evidence. The
documented remedies are to fetch again with a more specific prompt, or to use
curl via Bash for the unprocessed page — which trades the summary for raw text,
in your window.
A third reason used to sit underneath those two, and it was worse than a thin
answer. The docs describe the current handling — "When the extraction step hits
an overloaded API, Claude Code retries it with backoff; a fetch that still fails
returns an error result" — and then date what it replaced: "Before v2.1.212,
the API error text could reach Claude as if it were the extracted page content"
(Tools reference). The same
page records the identical fault in WebSearch, where the error text could
arrive as search results. Below that line a result is not reliably an extraction
of the page or a visible failure: it can be the extraction service's own error
prose, arriving in the slot where the page's content belongs and read by Claude
as though the page said it. A summary that reads like infrastructure rather than
like the document you asked for is the tell. Fetch it again before you believe
it, and reach for curl when the second answer looks like the first. That habit
outlives the fix, because a thin answer still arrives looking like a real one.
Second, the extraction step is a boundary. The docs describe the separate context
window as a mitigation "to avoid injecting potentially malicious prompts"
(Security) and stop there. Whether
that step strips or neutralises instructions written into the page itself is not
documented, and this course will not guess. What is documented sits one layer
out and only in auto mode: a fetch result is a tool result, so the server-side
probe scans it before Claude reads it, and the classifier never receives it at
all. In every other mode, treat what comes back as untrusted, like every other
row in the table above.
The permission story is where this level's earlier lessons cash out (Tools reference):
| Situation | Behaviour |
|---|---|
Manual or acceptEdits | Prompts, except for domains your rules allow or deny and a built-in set of preapproved documentation domains |
auto and bypassPermissions | Skip the prompt, except for a domain an explicit ask rule matches |
An explicit WebFetch(domain:...) rule | Takes precedence over the preapproved set, in deny, ask or allow |
| A sandboxed command | Does not inherit the preapproved documentation domains |
Sit with row two. In two of the six modes the fetch prompt is not a control at
all, and the only thing that puts a human back in is an ask rule — syntax from
Lesson 10.2 that now has a reason.
MCP servers are a trust decision
Verify you trust each server before connecting it. Servers that fetch external content can expose you to prompt injection risk. — Connect Claude Code to tools via MCP
Lesson 9.6 is the long version. One fact belongs to this level: trust
verification is one of the three named defenses, it covers new MCP servers as
well as first-time codebase runs, and it is not always present — "Trust
verification is disabled when running non-interactively with the -p flag"
(Security). A defense that is a
dialog is a defense a headless run does not have.
Reading a transcript for the tell
The docs define prompt injection and enumerate the defenses. They describe no detection procedure for you to run and publish no sample payload; Lesson 9.6 has the one worked example this course prints. But there is a documented surface where Claude Code raises the subject itself, and its instruction is unusual enough to know before you meet it:
If Claude warns about a suspected prompt injection, or refuses a request because of a suspected injection, and the text the warning names is context Claude Code adds to the conversation automatically rather than file or web content, run
claude updateand retry. If the warning repeats after updating, report it rather than pasting the flagged content back into the prompt. — Errors and warnings
The remedy is an update for a reason, and the same paragraph gives one worked
instance of it: "Before v2.1.201, Sonnet 5 refused some requests the same way".
A refusal aimed at context Claude Code inserted itself is a defect in the build,
not a finding about your repository — which is why the documented answer is
claude update rather than an investigation.
A warning naming file or web content is not covered by that remedy — that case is the rest of this lesson. And "rather than pasting the flagged content back into the prompt" is the general rule in miniature: re-typing untrusted text into your own turn is how it gets promoted.
The controls that hold
Permission rules are enforced by Claude Code, not by the model. Instructions in your prompt or
CLAUDE.mdshape what Claude tries to do, but they don't change what Claude Code allows. — Permissions
Read forwards, that explains why the good habits of Levels 6 and 7 do not help
here. A line in CLAUDE.md saying never read credentials competes with the
injected paragraph on equal terms, because both are text addressed to the model.
A deny rule does not compete with it at all.
| Control | Holds when the instruction came from content? |
|---|---|
| A sentence in your prompt | No — shapes intent, not what is allowed |
A rule in CLAUDE.md | No — same category. In auto mode the classifier does read your CLAUDE.md, but reading a boundary is not enforcing it |
A deny rule | Yes — deny rules "block Claude from even attempting to access restricted resources" |
| Sandbox restrictions | Yes, for Bash and its child processes only — they stop those reaching outside defined boundaries "even if a prompt injection bypasses Claude's decision-making" |
bypassPermissions | No — no protection against prompt injection |
classifierContext from a PostToolUse hook | An input, not a control — the classifier reads it as application-provided context |
Rows three and four quote
Permissions; row two is what
Permission modes says the
classifier reads. The last row is a footgun rather than a defense. Because the
classifier never receives tool results, classifierContext is the documented way
to tell it something about what a call returned — a channel into the one
component built to be unreachable by tool output. Nothing enforces what you put
there. The docs state that as a rule for you rather than a property of the field:
"don't copy untrusted tool output or third-party text into it", and keep the
note to a short assertion about this one call
(Hooks). The field also requires Claude
Code v2.1.236 or later, so a hook that returns it needs a build past that line.
Two controls from earlier in this level do double duty and are not re-taught.
Precedence is Lesson 10.2 — a deny rule wins in every mode, exactly the property
you want from something meant to survive an argument. Containment is Lesson 10.5,
including CLAUDE_CODE_SUBPROCESS_ENV_SCRUB, which set to 1 strips credentials
from the environments of the Bash tool, hooks and MCP stdio servers, described in
the docs as "reducing exposure to prompt injection attacks that attempt to
exfiltrate secrets via shell expansion"
(Environment variables). Reading a
secret and sending a secret are separate steps; that variable attacks the second.
Working practice
The security page's list for working with untrusted content is five items and short enough to adopt whole (Security):
- Review suggested commands before approval.
- Avoid piping untrusted content directly to Claude.
- Verify proposed changes to critical files.
- Use virtual machines to run scripts and make tool calls, especially when interacting with external web services.
- Report suspicious behaviour with
/feedback.
Item 2 catches experienced people, because piping is good practice for other reasons — Lesson 2.2 taught it as a way to hand Claude data without granting a tool. Both are true: a pipe narrows what a run may do and does nothing about what the text says. Item 4 is the same move as the WebFetch context window, one layer coarser: isolate the content rather than inspect it. The mitigation that works is never "detect the hostile sentence"; it is putting that sentence somewhere it cannot reach anything, which is Lesson 10.4's ladder and the reason the ladder exists.
Checkpoint
-
Your
CLAUDE.mdsays "never read files under~/.ssh". An MCP tool result asks Claude to read one. Which document decides the outcome, and why is it notCLAUDE.md?Show answerAnswer
Your settings file decides — specifically whether
permissions.denycarries aReadrule for that path. Permission rules are enforced by Claude Code, not by the model: instructions in your prompt orCLAUDE.mdshape what Claude tries to do, but they don't change what Claude Code allows.CLAUDE.mdloses because it is in the same category as the thing it is up against. Both are text addressed to the model, so your line and the injected paragraph compete on equal terms. A deny rule does not compete at all — deny rules "block Claude from even attempting to access restricted resources", and they hold in every mode. In auto mode the classifier does read yourCLAUDE.md, which is worth having, but reading a boundary is not enforcing it. -
You fetch a vendor's changelog and the result says the page does not mention a breaking change. Give two documented reasons that result is not evidence, and the two documented ways to get a better answer.
Show answerAnswer
WebFetch is lossy by design: for most fetches Claude receives a small, fast model's answer to the extraction prompt, not the raw page. So the first reason is the prompt — "a result that says a page doesn't mention something may only mean the prompt didn't ask about it". The second is truncation: large pages are cut to a fixed character limit before processing, so the passage may never have reached the extraction model at all.
The two documented remedies are to fetch again with a more specific prompt, or to use
curlvia Bash for the unprocessed page — trading the summary for raw text in your own window. One version note changes how you read a thin answer: before v2.1.212 the extraction API's error text could reach Claude as if it were the page content, andWebSearchhad the identical fault with search results. A summary that reads like infrastructure rather than like the document you asked for is the tell. -
You are in
automode and want a human decision before any fetch ofinternal-wiki.example.com. Which rule type gets you that, and which one would silently do nothing?Show answerAnswer
An ask rule:
WebFetch(domain:internal-wiki.example.com)inpermissions.ask. InautoandbypassPermissionsthe fetch prompt is skipped except for a domain an explicit ask rule matches, so that rule is the only thing putting a person back in the loop. An explicitWebFetch(domain:...)rule also takes precedence over the built-in preapproved documentation domains, indeny,askorallowalike.The one that silently does nothing is an allow rule for the domain: in auto mode the fetch was not going to prompt anyway, so it removes a prompt that was never there. A
denyrule does have an effect, but it answers a different question — it takes the decision away rather than putting one in front of you. -
A CI job runs
claude -pon a contributor's branch. Name one defense from the security page that is absent there by documentation, and one thing the repository supplies that runs anyway.Show answerAnswer
Trust verification is absent: "Trust verification is disabled when running non-interactively with the
-pflag". It is one of the three named defenses, alongside the permission system and command injection detection, and it covers new MCP servers as well as first-time codebase runs — a defense that is a dialog is a defense a headless run does not have.What runs anyway: the repository's hooks and helper commands, a project skill's hooks and
allowed-toolsamong them, are used in aclaude -por SDK session, which never shows the dialog, while the grants they ask for wait. Servers in the repository's.mcp.jsonare likewise "connected without asking, approved or not", which reopens the tool-result carrier — a contributor's branch can add a server whose output arrives in the window as text. Worth checking the mode too: four of the seven safeguards on the security page are stated for Manual mode, so a job running auto or bypass has less standing than the table looks.