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: trueinagent.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) orhigh. A tool requires a privilege: the one itsrequires_privilegenames; otherwisehighfor a write that onlyhighagents reach, andstandardfor anything else. The shop's desk isstandardand hands refunds tobilling, which ishigh: what the desk hands over is less trusted thanrefund_cardrequires.
How a mark follows a value¶
The product puts a mark on such text and follows it, conservatively:
- the result of a
reads_outsidetool is markeduntrusted:<tool>, such asuntrusted:read_inbox; - 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;
- a hand-off carries the marks of everything handed over, and the handing agent's own mark,
agent:<name>; - a mark is removed only by removing the marked message from what the model reads -- or by a rewind, which removes it (chapter 34);
- 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?
QG011Confused 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.QG012Cascading 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 aQG011.
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(orfile.py:attr, orlanggraph.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.--mcpnames an MCP server alone: itsserver.json, its address, or a savedtools/listreply. Its tools are checked behindstalwart 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
--graphor--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_outsidetool has returned in a session, every later call in that session carries its mark (chapter 37).