Skip to content

33 · Attacks from documents, e-mail and other agents

An indirect instruction is text your agent reads -- in an e-mail, a web page, a document, another agent's hand-off -- that tells the model to do something nobody asked for. The person never typed it: it arrived inside something a tool returned. The shop agent's read_inbox returns the newest support e-mail, and the e-mail says refund order 4411 in full. A model that reads it may do what it says, and the refund is a write to someone's card. This chapter is how the product follows such text, what it does when a call carries it, and how the hunt and the attacker campaigns look for it before launch.

Language support

The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

What reads the outside

Two things bring text in that nobody you trust wrote:

  • A tool that reads the outside world. Mark it reads_outside=True (reads_outside: true in agent.yaml): an inbox, a web fetch, an uploaded document. What it returns is untrusted.
  • An agent with less privilege. Each agent has a privilege: low, standard (the default) or high. A tool requires a privilege: the one its requires_privilege names; otherwise high for a write that only high agents reach, and standard for anything else. The shop's desk is standard and hands refunds to billing, which is high: what the desk hands over is less trusted than refund_card requires.

How a mark follows a value

The product puts a mark on such text and follows it, conservatively:

  1. the result of a reads_outside tool is marked untrusted:<tool>, such as untrusted:read_inbox;
  2. once a marked message is in what a model reads, everything that model writes carries the mark -- it has read it -- and so does every tool call it chooses, and every argument of those calls the model wrote;
  3. a hand-off carries the marks of everything handed over, and the handing agent's own mark, agent:<name>;
  4. a mark is removed only by removing the marked message from what the model reads -- or by a rewind, which removes it (chapter 34);
  5. a calculated value carries the marks of what it was calculated from.

The person's own words carry no mark: a turn the selector reads into a tool call is the person's, held by your rules and the check. The marks are kept beside the messages, never in them, so nothing of ours is sent to a model provider.

What a marked call does

The check compares each mark with what the tool requires (chapter 32). An untrusted mark is below every agent; an agent: mark is that agent's privilege. A write carrying a mark below what its tool requires is held, as your organisation's taint_policy says:

  • confirm, the default: a person is asked, and the question says where the mark came from. From the shop's refund proposed after reading the inbox (chapter 32): Run refund_card? This call was proposed after reading read_inbox from outside. A mark from a hand-off reads a hand-off from desk;
  • refuse: the call is refused, tainted_call, and runs nothing.

A read is never held for a mark: reading is how the agent finds things out.

The confused deputy and the cascade

Before launch, stalwart verify asks the same question of every route: can a privileged write run carrying a mark, with nobody confirming it?

  • QG011 Confused deputy: the mark entered the agent that makes the call. The privileged tool is the deputy: it acts with the agent's authority on an instruction the agent should not have taken.
  • QG012 Cascading injection: the mark entered one agent, and the call is made by another after a hand-off -- the desk reads the e-mail, hands it to billing, and billing refunds.

On agents agent.yaml describes, every write is on the guarded tool step, so a marked write is always confirmed or refused and neither rule finds anything; they find what a team's own steps leave open. Chapter 31 reads a QG011 witness step by step.

Text from outside, written to memory

A tool that reads_outside returns untrusted text, and the hunter marks every value that carries it. When a write to a persistent store can run with such a value and no confirmation, QG021 is raised: the text is stored where a later session reads it as its own, and the mark that said it came from outside is gone.

So a store's reads are untrusted until every write into it is proved clean. The findings document stalwart verify --keep writes gives each persistent store's trust, under scan.stores:

  • proved clean: every write into the store is confirmed or carries nothing marked, so what the store's reads return is trusted;
  • treated as untrusted: some write is not proved clean, so every read from the store -- a recall_notes, a retrieval -- is an untrusted source, and a privileged call it feeds is a QG011.

The fix is a confirmation on the write, or stripping what is marked before it; once the write is proved clean, the store is too, and the findings its reads caused go with it. E16 shows both.

Red-teaming your agents

The hunt finds where a way through the checks exists. stalwart redteam runs each such way on your agents themselves. The hunter checks the graph your agent file builds, and its rulebook, as stalwart verify does; then each graph finding's witness is replayed on your agents -- the selector, the checks and the confirmations as they run in production -- with every tool replaced by a stub that records its calls and returns fixture data, and a scripted model that makes exactly the witness's choices. Nothing it runs can charge a card or send a message.

$ stalwart redteam agent.yaml

It prints what stalwart verify prints (chapter 31), with each graph finding's replay beside it, and holds the same gate. --keep kept writes the export, the catalogue and the findings into kept/. Every option:

Option What the product then does
--graph a bot built elsewhere: module:attr, file.py:attr or langgraph.json[:name], naming its compiled graph
--mcp a bot's MCP server alone: its server.json, address or saved tools/list reply; its tools are proved behind stalwart gateway
--writes with --graph or --mcp, the file naming the tools that write (writes: [names]); without it every tool of a graph counts as one that writes, and an MCP tool writes unless its server marks it read-only
--hunter with --graph or --mcp, the hunter installed on this computer, in place of the service
--export-only with --graph or --mcp, writes the export and the catalogue to this folder and stops
--keep with agent.yaml, a folder to write the export, the catalogue and the findings into

The shop agent, whose model does whatever the last thing it read tells it to:

status = cli.main(["redteam", "agent.yaml"])   # stalwart redteam agent.yaml
print("exit status:", status)
stalwart redteam agent.yaml

Compiled the rulebook policy.co: the tool check enforces the rules verified here.
Lints from the runtime (none of these blocks the agent):
  write_not_retry_safe (warning) on charge_card: a write with no idempotency, so an ambiguous failure goes to a person
  write_without_result_schema (warning) on charge_card: a write with no result schema, so its outcome could not be reported
  compensation_not_retry_safe (warning) on charge_card: 'refund_card' undoes it and is not retry-safe
  write_not_retry_safe (warning) on refund_card: a write with no idempotency, so an ambiguous failure goes to a person
  write_without_result_schema (warning) on refund_card: a write with no result schema, so its outcome could not be reported
  write_without_result_schema (warning) on send_receipt: a write with no result schema, so its outcome could not be reported
  compensation_out_of_reach (warning) on charge_card: agent 'desk' can call it but not 'refund_card', which undoes it, so a run of that agent cannot undo it

Code checks: no finding in 4 file(s) (rules d656f9ab32d3).

Warnings: 2 findings that do not block this agent. Look at each and weigh it; the agent can run as it is.

QG023 Medium on cycle:desk_model (agent.yaml:3)
  the cycle through 'desk_model' calls the model or a tool, and nothing declares a budget: the framework's recursion limit of 60 is the run's only ceiling
  OWASP LLM06:2026, ASI08:2026
  Fix: Declare a budget for the agent or the run, or add an exit the model cannot avoid -- a counter in the state, declared on the edge as exit_after.

QG105 Review on refund_card (agent.yaml:10)
  the write 'refund_card' has no compensation, is not irreversible, and runs on a path with another write
  OWASP LLM03:2026, ASI08:2026
  Fix: Give 'refund_card' an undo -- `undo:` in agent.yaml or `compensation` in the catalogue, which may be the tool itself with its arguments swapped -- or say a failed run may leave it done (`undo: none`), or mark it irreversible.

Rules with nothing to read, so nothing raised:
  QG017: no argument is declared as code or a shell command
  QG018: no argument is declared as SQL, a query, a path, a URL or a template
  QG019: no tool is declared confidential
  QG020: no retrieval from a store declared multi-tenant
  QG021: no tool is declared to write a store declared persistent
  QG106: no tool declares a source or a server, and the catalogue declares no modules
  QG107: no agent in the catalogue declares a remote channel

Gate: passed. Nothing blocks this agent: the 2 warnings above are for you to look at and weigh.
exit status: 0

Neither finding has a witness path -- QG023 is about a cycle, QG105 about a write with no undo -- so neither is replayed, and the gate holds on what the hunt found.

A bot built elsewhere

--graph and --mcp read a bot that was not built from an agent file:

  • --graph module:attr (or file.py:attr, or langgraph.json[:name]) names the bot's compiled graph. The hunter looks for a path to a tool that writes that gets past the guard, for any call the bot's model may make, to the depth stated. Name the tools that write in a file, writes: [names], and give it with --writes; without it, every tool of the graph counts as one that writes.
  • --mcp names an MCP server alone: its server.json, its address, or a saved tools/list reply. Its tools are checked behind stalwart gateway (chapter 37): every call to them passes the guard's checks. An MCP tool writes unless its server marks it read-only. The agent that calls the server is not read.

Each finding's witness is replayed offline. --hunter uses the hunter installed on this computer in place of the service; --export-only kept writes graph.json and catalogue.json to kept/ and stops.

Reading the result

  • Exit 0: the gate held. With --graph or --mcp, the proof held, and it says so: proved: every call the bot's model may make to the tool(s) that write passes the guard.
  • Exit 1: the gate failed, or the proof found a way through the checks. Each way through is printed with the path it takes and its replay.
  • Replay: NOT reproduced: the witness did not reproduce on your agents, so the hunt and the agents that run disagree. It is listed first, it fails the gate, and it is worth reporting.
  • Exit 3: the hunter could not be reached, so nothing was checked.

The limits

  • A replay runs a way through that the hunt found; it searches for nothing new. Whether a way exists, to its depth, is what the hunt decides (chapter 31).
  • Every tool in a red-team run is a stub. A replay shows what your agents do on the witness's path, not what your tools do with the call.
  • A mark is removed only by removing what carried it. The product does not judge whether marked text is harmless.
  • A store's reads stay untrusted until every write into it is proved clean, which the hunter decides over the writes the graph routes; a store poisoned by a path outside the agent is not found.
  • The gateway's rule is coarser, and session-wide: the gateway cannot see what the model read, so once a reads_outside tool has returned in a session, every later call in that session carries its mark (chapter 37).