Prompt injection is a tool-access problem, not a wording problem
Prompt injection is when text an LLM reads as data contains instructions it then follows. The dangerous variant for automation is indirect prompt injection: the instruction arrives inside content the agent fetched on its own — a scraped page, an RSS item, an email body — so no human chose to send it. The term and the lethal-trifecta framing named in the intro are both Simon Willison's, from years of writing on this.
The instinct is to worry about what the model might say. That's the wrong axis. Remove any one leg of the trifecta and the attack has nowhere to land, and the cheapest leg to remove is the model's ability to act. If the step reading untrusted content holds no tools and can only produce text, a successful injection can't call an endpoint, write a file, or send a message. The design question isn't “how do I phrase the guard rail?” but “what can this step actually do if it's fully compromised?”
One boundary to fix first: a trusted source is not trusted content. A reputable feed or a shared drive is a trusted transport; what flows through it usually isn't.
| Trusted source | Untrusted content |
|---|---|
| A reputable RSS or forum API | Individual items are user-submitted or aggregated |
| A shared drive you own | A document an outside collaborator can edit |
| Your own email inbox | The body of a message from anyone |
| Decides whether the pipe is reliable | Decides whether the text can be an instruction |
Make the reading step inert, and hardcode where the code sends
Willison's quarantined LLM, in practice: call the model with no tools at all, so its only output is text. A separate, deterministic piece of code takes that string and does anything real — and its destination is hardcoded, never derived from the model's output or the fetched content. Even if an injection makes the model write “send this to attacker@evil.com,” nothing reads that as an address; the sending code already knows where the digest goes.
# the whole safety property in one shape text = llm(prompt) # inert: no tools, text only post_to(HARDCODED_CHANNEL, text) # deterministic: destination is a constant
Inside that inert step, keep the fetched text from blurring into your instructions: wrap it in an explicit tag (<transcript>…</transcript>), precede it with a note that everything inside is data and not instructions, and escape any closing tag in the payload so it can't break out. That's a probabilistic layer, not a proof — it earns its place only because the step behind it has no tools.
Text-only output is not the same as safe output
Here is the trap in “it can only emit text.” Text is harmless only until something renders it. The moment the output is displayed as markdown or HTML — in a chat client, a dashboard, an email — an injected image beacons the instant it loads:  ships whatever the model was steered into appending, with no click required. An injected link is the same attack waiting for a human. This is the exfiltration leg of the trifecta hiding in the render, not in a tool, and it is more automatable than social-engineering a reviewer. So “inert” has to reach the output sink too: render untrusted-derived text as plain text, or strip and escape markup before anything displays it.
What I turn off, and why one switch isn't enough
“No tools” has to be enforced by the runtime, not assumed. On the setup these jobs run on, it took three things together, not one: start the model with no connectors loaded at all (no mail, drive, or calendar surface to reach), deny the built-in and control tools by name, and run a permission mode that refuses anything not explicitly allowed. Each alone left a hole — an empty allow-list is not deny-everything, and a background control action still fired once when I thought everything was off.
That is the real lesson, and the honest disclaimer with it: I trust a step is inert only after I have tried to make it act and watched it fail, not after reading the flags. To be clear, these defenses are proactive — built from how the attacks work, not forensics after a breach. The one incident I can point to isn't a caught injection; it is discovering, while auditing my own jobs, how much a single “off” switch quietly left on.
Where this still fails
Isolation narrows the blast radius; it doesn't close it.
- It's probabilistic, not proven. Wrapping lowers the odds of the model being steered; a clever enough injection still can. The value is the tool-less step behind it, not the wording.
- Inert leaks if any tool sneaks back in. Add one convenience tool to “help it fetch a detail” and the untrusted content is back in reach of a live capability.
- The human gate can be socially engineered. A persuasive output a person rubber-stamps reached a human instead of a tool. The gate works only if the reviewer stays skeptical of content the agent didn't originate.
- Poisoned memory is a side door. If a compromised run writes into files a later step reads, the injection persists. Anything that ingests untrusted content and later feeds an acting step needs the same treatment, or the isolation just moves downstream.
The takeaway isn't a fortress. The safety of an autonomous agent lives in its architecture — which steps can act, toward which hardcoded destinations, behind which human, into which render — not in a paragraph asking the model to behave.
Related. This pattern is one half of how I run an AI operating layer unattended; the other half is how the agent remembers what it learned between runs, covered in persistent memory without a vector database. A broader field report is in the case study.