Break the lethal trifecta: six agent designs that survive prompt injection
You can't reliably detect prompt injection, so design agents where it can't trigger anything that matters. How to find the agents at risk, the six patterns that fix them, and how to choose between them.
By Moses OtienoSep 15, 202613 min read
The uncomfortable conclusion of the data your agent reads can give it orders is that you can't reliably detect a malicious instruction hidden in a web page, an email or a document. Detection is a filter, and a filter that stops most attacks is a filter that lets some through.
Which raises the question every team building agents eventually asks: if we can't stop the model being fooled, what do we do? Two pieces of work, both from 2025, give the most practical answer available. One is a way to diagnose which agents are actually at risk. The other is a catalogue of designs that make the risk go away by construction. Used together, they turn an unsolvable problem into an engineering decision.
Step one: find the agents that hold the trifecta
In June 2025, Simon Willison named the combination that turns prompt injection from an annoyance into a data breach. He called it the lethal trifecta: "access to your private data," "exposure to untrusted content," and "the ability to externally communicate in a way that could be used to steal your data." His summary of why it matters is one sentence: "If your agent combines these three features, an attacker can easily trick it into accessing your private data and sending it to that attacker."1The lethal trifecta for AI agents: private data, untrusted content, and external communication Open source (opens in a new tab)
The framing is useful because it's checkable. Each leg is a property of the tools an agent holds, not of the model or the prompt, so you can inventory it. Two of the legs are wider than they look:
- Untrusted content is anything an attacker can write that the model will read: web pages, inbound email, support tickets, shared documents, CRM notes customers can edit, and the output of other agents.
- External communication is any channel that can carry data out: sending email, posting a comment, opening a pull request, fetching a URL with data in the query string, or a markdown image in the reply that the client renders automatically.
Once each tool declares its legs, the check is a few lines of code, and it belongs in CI so the combination can't be completed quietly:
type Leg uint8
const (
PrivateData Leg = 1 << iota // reads data the attacker shouldn't see
UntrustedContent // returns text an attacker could have written
ExternalComms // can move data somewhere an attacker can read it
)
type ToolSpec struct {
Name string
Legs Leg
}
type AgentSpec struct {
Name string
Tools []ToolSpec
Pattern string // the design that breaks the combination, if the agent has all three
}
// Runs in CI over every registered agent. Naming a pattern doesn't enforce it,
// but it forces a reviewed decision: a new tool can't quietly complete the
// trifecta in a pull request nobody looked at twice.
func TestNoUnreviewedTrifecta(t *testing.T) {
for _, a := range registeredAgents() {
var legs Leg
for _, tool := range a.Tools {
legs |= tool.Legs
}
if legs == PrivateData|UntrustedContent|ExternalComms && a.Pattern == "" {
t.Errorf("%s holds private data, untrusted content and external comms with no pattern declared", a.Name)
}
}
}Run that over your agents and sort them into two groups. Agents missing a leg are, for this class of attack, already safe — keep them that way. Agents holding all three need a design that breaks the combination. Willison is blunt about the alternative: guardrail products that claim to catch 95% of attacks are, in his words, "very much a failing grade" for security, because "we still don't know how to 100% reliably prevent this from happening."1The lethal trifecta for AI agents: private data, untrusted content, and external communication Open source (opens in a new tab)
The principle every fix follows
The same month, a group of fourteen researchers — with affiliations including Google, Microsoft, IBM, ETH Zurich, EPFL and Invariant Labs — published Design Patterns for Securing LLM Agents against Prompt Injections. Its guiding principle is the most useful single sentence in agent security:2Design Patterns for Securing LLM Agents against Prompt Injections§3 (the guiding principle), §3.1 (the six patterns), §4 (case studies and trade-offs) Open source (opens in a new tab)
"Once an LLM agent has ingested untrusted input, it must be constrained so that it is impossible for that input to trigger any consequential actions."
Note the word impossible. Not unlikely, not detected, not flagged. The paper then offers six patterns for achieving it, each trading away some flexibility in exchange for a guarantee. Here they are in the paper's order, with where each one fits.2Design Patterns for Securing LLM Agents against Prompt Injections§3 (the guiding principle), §3.1 (the six patterns), §4 (case studies and trade-offs) Open source (opens in a new tab)
1. Action-Selector
The model acts as a switch. It maps a request to one of a fixed set of predefined actions, and nothing those actions return is fed back into it. No feedback loop means no channel for injected content to steer the next decision.
type Action string
const (
ResetPassword Action = "reset_password"
ShowInvoices Action = "show_invoices"
HandToHuman Action = "hand_to_human"
)
// The model's only output is a choice from a closed list. Whatever an action
// returns goes to the user, never back into the model, so there is no path
// for content inside an invoice to steer what happens next.
func (a *SupportAgent) Handle(ctx context.Context, msg string) error {
choice, err := a.model.Choose(ctx, msg, []Action{ResetPassword, ShowInvoices, HandToHuman})
if err != nil {
return fmt.Errorf("choose action: %w", err)
}
return a.run(ctx, choice)
}Fits: routing and triage — support desks, intake forms, "which of these workflows does this request need". Gives up: any ability to reason over what the action found. It's the most restrictive pattern, and for a surprising number of real deployments it's also enough.
2. Plan-Then-Execute
The agent turns the user's request into a fixed list of actions before it touches any external data, then executes that list. Untrusted content encountered during execution can't add, remove or reorder steps.
// The plan is fixed from the user's request, before any tool reads external
// data. Content read later can influence what goes into an argument, but it
// can't add a step, reorder the plan, or reach a tool the plan didn't name.
plan, err := a.planner.Plan(ctx, userRequest) // validated against the tool registry
if err != nil {
return fmt.Errorf("plan: %w", err)
}
state := newStepResults()
for _, step := range plan.Steps {
if err := a.execute(ctx, step, state); err != nil {
return fmt.Errorf("step %s: %w", step.Tool, err)
}
}Fits: known workflows where external data is material to work on rather than a source of new instructions. Gives up: the ability to change course based on what's discovered. Be clear-eyed about its limit, too: it controls which actions run, not what goes into them. If step three is "email the summary to the client", a poisoned document can still corrupt the summary. Pair it with an approval gate on consequential steps.
3. LLM Map-Reduce
For work over many independent items, each item is processed by its own isolated model call, and only a constrained result crosses back. A malicious item can compromise its own result and nothing else. The aggregation step works on typed values, ideally in plain code.
// Each document is judged by its own isolated model call, and all that crosses
// back is a boolean and an enum. A malicious invoice can corrupt its own
// verdict. It can't reach the other 499, or the step that acts on the totals.
type InvoiceVerdict struct {
MatchesPurchaseOrder bool `json:"matches_purchase_order"`
Discrepancy string `json:"discrepancy" jsonschema:"enum=none,amount,vendor,quantity"`
}
func (a *Reviewer) Review(ctx context.Context, invoices []Invoice) (Summary, error) {
verdicts := make([]InvoiceVerdict, len(invoices))
for i, inv := range invoices {
v, err := a.isolated.Judge(ctx, inv) // fresh context, no tools, schema-constrained output
if err != nil {
return Summary{}, fmt.Errorf("invoice %s: %w", inv.ID, err)
}
verdicts[i] = v
}
return summarise(verdicts), nil // plain code over typed values, not a model over text
}Fits: screening, classification and extraction at volume — invoices, CVs, contracts, tickets. Gives up: tasks where items have to be understood in relation to one another. The narrower the returned type, the stronger the isolation: a boolean can't carry instructions, a free-text field can.
4. Dual LLM
Two models with different privileges. A privileged model holds the tools and never reads untrusted content. A quarantined model reads the untrusted content and holds no tools. The paper's requirement is that there is no feedback loop between them. A common way to implement that is to have the privileged model work with references instead of text:
// The privileged model plans with references and never reads their contents.
// It can say "summarise $DOC_1 and put the summary in the reply", but because
// it never sees $DOC_1, nothing written inside the document can address the
// model that holds the tools.
//
// The variable store is turn-scoped: a reference minted in one user's turn must
// never resolve in another's.
type TurnVars struct {
mu sync.Mutex
values map[string]string
next int
}
func (v *TurnVars) Put(content string) string {
v.mu.Lock()
defer v.mu.Unlock()
v.next++
ref := fmt.Sprintf("$DOC_%d", v.next)
v.values[ref] = content
return ref
}
// The quarantined model reads untrusted content and has no tools. Its output
// is stored as another opaque reference rather than handed back to the planner.
func (a *Agent) Quarantined(ctx context.Context, instruction, ref string) (string, error) {
out, err := a.quarantined.Generate(ctx, instruction, a.vars.Get(ref))
if err != nil {
return "", fmt.Errorf("quarantined model: %w", err)
}
return a.vars.Put(out), nil
}Fits: assistants that must both read untrusted material and act — the inbox assistant, the research agent. Gives up: some capability, because the planner never sees the material it's planning around. Two implementation details decide whether it works. The quarantined model's output must stay opaque to the planner. And the reference store must be scoped to the turn, for exactly the reason in concurrency-safe isn't tenant-safe: a $DOC_1 that resolves in someone else's session is a cross-tenant leak.
This is also the pattern behind splitting reading from acting across narrow specialist agents — the reader holds no write tools, and the actor never reads raw content.
5. Code-Then-Execute
Think of it as Plan-Then-Execute with real control flow. The privileged model writes a small program for the task, and that program runs. It can call tools and invoke quarantined models on untrusted data, but the structure of what happens is fixed in code before any untrusted content is read.
Fits: multi-step tasks that need branching and data passed between steps, where a flat plan is too rigid. Gives up: simplicity. You need a restricted execution environment the model writes against, and the result is only as good as the model's program. It's the most powerful pattern here and the most work to build safely.
6. Context-Minimization
Content that has done its job is removed from the context. The typical shape: the user's request is converted into a structured query, the query runs, and the answer is produced from the results without the original request text still present. The paper frames it primarily as protection against injection arriving through the user's own prompt.
Fits: customer-facing agents, where the user is also the untrusted party. Gives up: conversational nuance, since later steps can't consult the original wording.
Choosing one
Start from the shape of the task, not the pattern:
- The agent picks one of a known set of things to do → Action-Selector.
- The workflow is known; external data is just material → Plan-Then-Execute, with approval on consequential steps.
- Many independent documents, one decision each → LLM Map-Reduce.
- It must read untrusted material and act on the user's behalf → Dual LLM.
- That, plus branching logic and data flowing between steps → Code-Then-Execute.
- The user is the untrusted party → Context-Minimization, alongside any of the above.
Combining them is normal. A research agent might use Map-Reduce to screen sources, a Dual LLM split to write the report, and Plan-Then-Execute for the one action it's allowed to take at the end.
What these patterns don't do
They stop injected content from triggering actions. They don't stop poisoned content from being wrong. An isolated reviewer fed a doctored invoice can still return a doctored verdict, and a quarantined summariser can still summarise a lie faithfully. That's a data quality problem, and it's the reason consequential results still deserve a human in the loop — the approval, identity and audit controls in letting an agent act as you, safely apply unchanged.
They also don't cover exits that aren't tools. A rendered markdown image is external communication even if no tool was called, so output sinks need the same scrutiny as the tool registry.
Retrofitting an agent you already run
Rebuilding every agent around a new pattern isn't realistic in one go. The order that gets the most risk out soonest:
- Inventory the legs for every agent and add the CI check, so the list doesn't grow while you work.
- Remove a leg where you can. This is the cheapest fix and often the most overlooked: does the agent that reads the inbox really need to send email, or only draft it?
- Split the reader from the actor for agents that genuinely need all three — the Dual LLM shape.
- Constrain what crosses back. Replace free text between components with typed, narrow results wherever the task allows.
- Gate what remains. Anything consequential that still runs after reading untrusted content waits for a person.
None of this requires a better model, and none of it depends on winning an arms race against the next phrasing of "ignore your previous instructions". It's architecture — which is exactly why it holds.
Sources
- Design Patterns for Securing LLM Agents against Prompt Injections (opens in a new tab)
§3 (the guiding principle), §3.1 (the six patterns), §4 (case studies and trade-offs)
Thinking about an agent like this for your team?
Describe the job you want automated and our Automation Architect blueprints it in seconds — or talk it through with the engineers who ship them.
