# 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.

URL: https://www.matthewswong.com/en/learn/ai-agents-mcp/prompt-injection-through-tool-output/

AI agents and MCP · Lesson 3 of 4 · Reviewed on 17 October 2026 · Checked against MCP specification 2026-07-28 · By Matthews Wong (https://www.matthewswong.com/en/about/)

> 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.

## 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.

1. **Meet the parts.** 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. **Text goes in.** 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 agent fetches it.** 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 planted
   ```

   *Predict:* 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. **Data becomes orders.** 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 text
   ```

   *Predict:* 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. **Stop it below the model.** 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. **Descriptions can change too.** 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.

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.

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.

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.

## The real config behind this lesson

`app/api/chat/route.ts:246-254`

```typescript
  // 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`

```typescript
  // 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

- **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

| Control | What it stops | What it misses |
| --- | --- | --- |
| A prompt saying ignore untrusted data | Nothing you can rely on | It failed in the agentjacking tests |
| Human approval for shell and installs | The action, if a person reads it | A saved allow rule removes the prompt |
| Deny rules on curl and wget | Those two programs | The same program by path or inside sh -c |
| A sandbox with no outbound network | Shell commands sending data out | File tools and MCP servers run outside it |
| Fingerprinting tool definitions | Descriptions that change after approval | A definition hostile from the start, and tool results |

## Try it yourself

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.

   ```bash
   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.

   ```bash
   {
     "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.

   ```bash
   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.

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

   ```bash
   /permissions
   ```

## Quick quiz

1. **An agent reads an error event through an MCP server. Who could have written the text in it?**
   A. Only your own application
   B. Anyone holding the tracker's public ingest key
   C. Only the MCP server's author
   Answer: B. A Sentry DSN is a write-only key that often ships in frontend code, so anyone who finds it can submit events. Agentjacking used exactly that.

2. **Which control still holds if the model is fooled?**
   A. A system prompt saying to ignore instructions in tool results
   B. A sandbox that blocks network access for shell commands
   C. A vendor filter for the published payload
   Answer: B. The operating system enforces the sandbox whatever the model decides. Prompts are advice, and string filters miss reworded text.

3. **A server behaved well for three calls, then changed its tool descriptions. What would have caught that?**
   A. Calling each tool once before approving it
   B. Reading its README
   C. Fingerprinting definitions and re-checking during the session
   Answer: C. Deadbugz was built to pass install-time reviews. Only a check that keeps running, and treats a changed definition as a security event, sees the change.

## Think it through

- **Why can't an agent simply be told to ignore instructions inside tool results?** 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.
- **Which leg of the lethal trifecta is easiest to remove for an agent that triages error events?** 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.
- **What does fingerprinting tool definitions not protect against?** 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

- [Sentry MCP Prompt Injection: Agentjacking Defence Guide](https://www.matthewswong.com/en/blog/sentry-mcp-agentjacking-prompt-injection-incident-agents/)
- [Deadbugz MCP Server: Runtime Tool Metadata Poisoning Defence](https://www.matthewswong.com/en/blog/deadbugz-mcp-runtime-metadata-poisoning/)
- [Prompt Injection Attacks on AI Coding Agents](https://www.matthewswong.com/en/blog/prompt-injection-ai-coding-agents/)

## Sources

- [Tenet Security: agentjacking coding agents with fake Sentry errors](https://tenetsecurity.ai/blog/agentjacking-coding-agents-with-fake-sentry-errors/)
- [Cloud Security Alliance: agentjacking via Sentry MCP research note](https://labs.cloudsecurityalliance.org/research/csa-research-note-agentjacking-sentry-mcp-20260614-csa-style/)
- [Pillar Security: Deadbugz, an MCP supply chain campaign](https://www.pillar.security/blog/deadbugz-currently-active-mcp-supply-chain-campaign)
- [OWASP Community: MCP tool poisoning](https://community.owasp.org/attacks/MCP_Tool_Poisoning)
- [Simon Willison: the lethal trifecta for AI agents](https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/)
- [MCP specification 2026-07-28: tool security considerations](https://modelcontextprotocol.io/specification/2026-07-28/server/tools)
- [Claude Code docs: permissions, enforced by Claude Code and not the model](https://code.claude.com/docs/en/permissions)

---

- HTML version: https://www.matthewswong.com/en/learn/ai-agents-mcp/prompt-injection-through-tool-output/
- Bahasa Indonesia: https://www.matthewswong.com/id/learn/ai-agents-mcp/prompt-injection-through-tool-output/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
