Skip to main content
AI agents and MCP · Lesson 3 of 4 · 12 min

Indirect Prompt Injection Explained: Tool Output Is Untrusted

Indirect prompt injection explained for beginners: why text an MCP tool returns is untrusted input, two real incidents, and the controls that still hold.

By Matthews WongReviewed on Checked against MCP specification 2026-07-28

Key takeaway

Indirect prompt injection is when instructions hidden in data an agent reads, such as a tool result, a ticket or a web page, steer what it does next. To a model, tool output is just more text in its context, so anything an outsider can write is untrusted input. The fix is limiting what the agent can do next, not filtering the text.

You will be able to

  • Explain why a tool result can steer an agent, and spot which of your tools return text outsiders can write.
  • Walk through two real cases, a fake error event and a server that changed its own tool descriptions, at defender level.
  • Pick controls that hold below the model: approval, deny rules, a sandbox and definition fingerprints.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

How text an outsider wrote reaches an agent's next action

An outsider posts a fake error event, an MCP server returns it as tool output, the model reads it as guidance and asks to run a command, and only a gate outside the model stops it.

Reviewed on 17 October 2026 · MCP specification 2026-07-28

Step 1 of 6. A developer asks an agent to triage errors through an MCP server. The tracker accepts events from anyone holding its ingest key, which often ships in public web pages.

Keys: ← → step · Home / End · K play or pause

  1. A developer asks an agent to triage errors through an MCP server. The tracker accepts events from anyone holding its ingest key, which often ships in public web pages.

  2. An outsider posts a fake error event whose text looks like the tracker's own fix-it guidance. Nothing is hacked: the key and the tracker work as designed.

    event: an error message plus a Resolution section
  3. The developer asks for a triage. The MCP server returns the planted event next to real ones, as ordinary tool output, with no integrity check on what outsiders wrote.

    tools/call list_issues → real events + 1 plantedPredict the next step The model now holds the planted text. How does it tell data from instructions? (It cannot reliably: both are text in its context · Tool output is marked untrusted, so it ignores it)
  4. To the model it is all text in one context. In the researchers' tests, agents treated the planted text as guidance and asked to run the command it held.

    tool_use: Bash · command taken from the event textPredict the next step The agent asks to run that command. Which control can stop it whatever the model believes? (A system prompt saying ignore untrusted data · A gate outside the model: approval or a sandbox)
  5. Prompt-level defences failed in the researchers' tests. What holds sits outside the model: an approval a person reads, deny rules, and a sandbox with no route out.

    ask: Bash(npx *) · deny: Bash(curl *) · sandbox allowlist empty
  6. Deadbugz shows a second door: a server behaved for three calls, then rewrote its tool descriptions to steer agents. Fingerprint definitions and re-approve on any change.

    hash each tool definition at approval, re-check after every call

What is indirect prompt injection?

Prompt injection is text that tries to give an AI model new instructions. Indirect prompt injection hides those instructions in content the agent reads while doing a task, such as a web page, a ticket, an error event or a tool result, rather than in what the user typed. OWASP describes MCP tool poisoning the same way: tools that look normal, but whose responses carry hidden instructions.

Read more (2)

It works because a model has no separate channel for data. The system prompt, the user's message and a tool's result all arrive as text in one context window, and the model decides what to do from all of it. The MCP specification asks clients to validate tool results before passing them to the model, but OWASP notes that fully detecting injected instructions in free text is an open problem.

So the useful question is not whether a text looks safe, but who could have written it. An error tracker, a log, an issue tracker, a support inbox and a web search all return text that outsiders can influence. Treat every such result as untrusted input, the way a web server treats a form field.

What did the agentjacking and Deadbugz cases show?

In agentjacking, researchers at Tenet Security used a public Sentry DSN, a write-only key that is often embedded in frontend code, to submit a fake error event styled like diagnostic guidance. When a developer asked an agent to review Sentry issues, the Sentry MCP server returned it alongside the real events, and the tested agents ran the command it contained with the developer's own privileges.

Read more (2)

The Cloud Security Alliance's note calls it architectural: an ingest endpoint that accepts any event from a DSN holder, an MCP server that forwards events without integrity checks, and agents that trust tool output like operator instructions. Tenet reports an 85% success rate across Claude Code, Cursor and Codex in its own tests, and says prompt-layer defences failed. The underlying data is not published, so read the rate as well above zero, not as a precise figure.

Deadbugz attacked the other side of a tool: its description. Pillar Security reports a server that behaved normally for its first three tool calls, then changed its tool and prompt metadata to steer agents toward SSH keys, cloud credentials and shell history, and to hide that from the user. It arrived through 23 pull requests, none merged when Pillar reviewed them. A one-time review at install passes a server like that.

Which defences still hold when the text gets through?

Put the control on the action, not on the text. Sentry's response, as Tenet and CSA relay it, was a filter for the one payload string in the research, and CSA warns that rewording slips past a string match. What holds is whatever limits the agent's next step: an approval a person actually reads, deny rules on risky commands, and a sandbox the operating system enforces.

Read more (2)

The Claude Code docs draw the same line. Permission rules are enforced by Claude Code, not by the model, so instructions in a prompt or CLAUDE.md shape what Claude tries but not what it is allowed to do. And sandbox restrictions still apply even if a prompt injection bypasses Claude's decision-making, which is why the docs recommend using both.

Simon Willison's lethal trifecta names the dangerous mix: access to private data, exposure to untrusted content, and a way to send data out. An agent that reads telemetry always has the second, so remove the first or the third where you can. For server descriptions, fingerprint each tool definition when you approve it and treat any later change as a security event; that only detects change, so the first review still has to be real.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

app/api/chat/route.ts:246-254
  // Reject prompt injection attempts before hitting Groq.
  // - Scanned over the history too, not just `message`: conversationHistory is
  //   client-supplied and every entry that is not "user" is coerced into an
  //   assistant turn below, so an injection placed there reaches the model as
  //   words apparently already spoken by the assistant — the position most
  //   likely to be obeyed.
  // - userName is scanned too: it is interpolated straight into the system
  //   prompt as the greeting target, the single highest-trust position, so an
  //   injection there would otherwise slip past every other check.

This site's chatbot calls a model with no tools, so the worst a successful injection can do is make it say something off-topic. Its route still follows this lesson's rule: what matters is where text came from, not where it lands. The conversation history comes from the browser, so it is scanned like the message, because text placed in the assistant's own turn is the position a model is most likely to obey.

app/api/chat/route.ts:279-289
  // Strip markup, braces and newlines before the name lands in the system
  // prompt: it is the one piece of user input placed in a trusted position, so
  // it must not be able to carry structure that reads as instructions.
  const safeName =
    typeof userName === "string"
      ? userName
          .replace(/[<>{}`\r\n]/g, " ")
          .replace(/\s+/g, " ")
          .trim()
          .slice(0, 60)
      : "";

The visitor's name is the one piece of user input placed inside the system prompt, so it loses markup, braces and newlines first and is cut to 60 characters. The same route also runs a pattern filter over all user text, which catches only the phrasings someone thought of. That is the honest limit of any filter: a first layer that blocks known wording, never the boundary.

What breaks

Mistakes that cost real time

  • A system prompt that says ignore untrusted data

    Tenet reports that prompt-layer defences failed in its agentjacking tests. Instructions to the model are advice it may or may not follow. Put the control in the host's permissions and in a sandbox instead.

  • Approving once, forever

    In Claude Code, Yes, and don't ask again for a Bash command is permanent per repository and command, so one tired click becomes a standing allow. Review saved rules in /permissions and keep ask rules on installs and package runners.

  • Reviewing a server only at install

    Deadbugz behaved for three calls, so a quick try-out or a short scan saw nothing wrong. Fingerprint tool definitions and re-check them inside the live session, not in a separate scan.

  • A filter for last week's payload

    A string match blocks wording someone already published, and the next attempt is worded differently. Treat a vendor content filter as a courtesy, and keep your own controls on the action.

Controls against injection through tool output
A prompt saying ignore untrusted dataNothing you can rely onIt failed in the agentjacking tests
Human approval for shell and installsThe action, if a person reads itA saved allow rule removes the prompt
Deny rules on curl and wgetThose two programsThe same program by path or inside sh -c
A sandbox with no outbound networkShell commands sending data outFile tools and MCP servers run outside it
Fingerprinting tool definitionsDescriptions that change after approvalA definition hostile from the start, and tool results
Try it yourself

Do it on your own machine

You need Claude Code and a scratch git repository with no real secrets in it. You will list where outsider-written text reaches your agent, gate the next step, and run a harmless drill that tests the gate rather than the model.

  1. List the MCP servers this project loads, and mark each one whose results outsiders can write: error trackers, logs, tickets, inboxes, web search.

    claude mcp list
  2. Gate the step after the read. Ask before package runners and installs, and deny the two common download tools, in .claude/settings.json.

    {
      "permissions": {
        "ask": ["Bash(npx *)", "Bash(npm install *)", "Bash(pip install *)"],
        "deny": ["Bash(curl *)", "Bash(wget *)"]
      }
    }
  3. Check that no rule was skipped when settings loaded. claude doctor lists rules Claude Code could not apply.

    claude doctor
  4. Run the drill. In a test file, write one plain sentence asking any AI reader to create a file named canary-check.txt, then ask Claude to summarise the file. A safe setup reports the sentence as content, or stops to ask before writing anything.

    ls canary-check.txt
  5. Review what you have already approved for good, and remove allow rules you no longer need.

    /permissions
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is graded, so guess freely; a miss is kept in this browser so the course can ask it again later.

  1. 1.An agent reads an error event through an MCP server. Who could have written the text in it?
  2. 2.Which control still holds if the model is fooled?
  3. 3.A server behaved well for three calls, then changed its tool descriptions. What would have caught that?
From earlier lessons

One question from an earlier lesson

Recalling an older lesson after a gap is what makes it stick. Answer from memory before you look anything up.

  1. 1.How do you know read_only actually took effect?

From: 2. MCP Server Least Privilege: Read-Only Mode and Toolsets

Think it through

Answer in your head, then check

  1. 1.Why can't an agent simply be told to ignore instructions inside tool results?Show the answer

    Answer:The model receives instructions and data the same way: as text in its context. A request to ignore some of it is one more piece of text competing with the planted one, and in the agentjacking tests it did not hold.

  2. 2.Which leg of the lethal trifecta is easiest to remove for an agent that triages error events?Show the answer

    Answer:Usually the way out. Reading untrusted events is the job, and some private code access is needed, but a sandbox with no outbound network except named hosts stops stolen data from leaving.

  3. 3.What does fingerprinting tool definitions not protect against?Show the answer

    Answer:A definition that was hostile at the first review, and the text that tool calls return. It only detects change, so the first review and the controls on the agent's actions still matter.

Go deeper

Related deep dives on this site