Agentic architecture
Verified by ROOTVIEL12 minData Exfiltration (the Lethal Trifecta)
When one agent can read private data, is exposed to untrusted input, and can egress to the outside world — all three at once — an injected instruction can turn the agent into an exfiltration channel. The three legs together are what make it lethal.
Classification
- Domain
- Agentic architecture
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Structural property — Checked over a static authority/dataflow graph — the property holds in the code; no exploit was run.
- Analysis family
- FAM-GENAI-STATIC
- CWE
- CWE-200, CWE-668
- OWASP
- LLM02, LLM06, LLM01
Definition
The lethal trifecta is the co-occurrence in a single agent of three capabilities: access to private data, exposure to untrusted input, and the ability to egress (communicate to the outside world). Any one or two of them is ordinarily survivable; all three in the same agent compose an exfiltration channel, because untrusted input can instruct the agent to read private data and send it out. The term is Simon Willison’s.
This article treats the co-located form — all three legs in one agent — as the primary subject. That form is the one ROOTVIEL verifies as a structural finding. The distributed form, where the three legs are split across a swarm, is handled separately and never surfaced as a reported finding (see below).
Context
The trifecta appears in capable, convenient agents: an assistant that can read your files or inbox (private data), browse the web or read incoming messages (untrusted input), and send email or make network requests (egress). Each capability is added for a good reason; their combination in one agent is what creates the channel. The more capable the agent, the more likely all three legs are present.
- ▸Private data: file/inbox access, database reads, secrets, retrieved internal documents.
- ▸Untrusted input: web pages, incoming emails, tickets, tool results, retrieved chunks.
- ▸Egress: sending email, HTTP requests, posting to a channel, writing to an external store.
Technical fundamentals
The trifecta is a composition property over the agent’s code graph. Each leg is a reachability fact: a dataflow from a private-data source, a dataflow from an untrusted source, and a call path to an egress sink. The vulnerability exists when all three are co-reachable within one agent — the private read and the egress are both reachable, and untrusted input can steer the path between them.
Soundness demands that each leg rest on a proven path, not an assumption. A verifier that infers a leg (“there is probably an egress somewhere”) without a concrete dataflow or call path has not established the trifecta. The honest unit of the analysis is “three proven legs, co-located”; anything less is incomplete.
- Private-data leg
- A proven dataflow from a private-data source into the agent’s reachable state.
- Untrusted-input leg
- A proven dataflow from an untrusted source into the agent’s context.
- Egress leg
- A proven call path to a sink that communicates outside the trust boundary.
- Co-reachability
- All three legs reachable within one agent such that untrusted input can connect the private read to the egress.
- Assumed leg
- A leg inferred rather than proven; its presence degrades the verdict to UNKNOWN and must be named.
Root cause
The defect is capability composition without a separating control. Private-data access, untrusted-input exposure, and egress are each legitimate; combining all three in one agent with no deterministic barrier between the private read and the egress is what creates the channel. The fix is to break the composition — remove a leg, or interpose a control so untrusted input cannot drive private data to an egress — rather than to make the agent “resist” injection.
How the attack works, step by step
- 1The agent ingests untrusted input — a web page, an email, a retrieved document — that carries an injected instruction.
- 2The instruction tells the agent to read a specific piece of private data it has access to.
- 3The agent, unable to separate instruction from data, performs the private read.
- 4The same instruction tells the agent to egress the data — email it, POST it, post it to a channel.
- 5The agent invokes its egress capability, sending the private data to an attacker-controlled destination.
- 6All three legs were present and co-reachable; the untrusted input connected the private read to the egress.
Vulnerable example
A synthetic, illustrative agent that holds all three legs: it reads untrusted web content, can read a private file, and can make an outbound request — with nothing between the private read and the egress:
synthetic — do not use; illustrates the trifecta# UNSAFE: untrusted input + private read + egress, co-located, with no separating control. def agent(task): page = fetch(task.url) # untrusted input leg reply = model(SYSTEM + "\n" + page + "\n" + task.text) if reply.read_file: data = open(reply.path).read() # private-data leg (model chose the path) if reply.send: http_post(reply.dest, data) # egress leg (model chose the destination) return reply.text # An injected instruction in 'page' can set read_file, path, send, and dest.
Corrected example
The durable fix breaks the composition. Either remove a leg (an agent that touches untrusted input does not also hold both private-read and egress), or interpose a deterministic control so untrusted input cannot drive private data to an external destination — egress destinations are allow-listed and private data is never permitted to leave through them.
synthetic — break the composition with a deterministic egress control# SAFER: untrusted input cannot drive a private read to an uncontrolled egress. def agent(task): page = fetch(task.url) # untrusted input, consumed as data reply = model(build_prompt(SYSTEM, task.text, data=[page])) # Private read is gated and labelled; egress is allow-listed and refuses tainted data: data = read_private(reply.proposed_path, actor=task.user) if policy.read_ok(reply, task.user) else None dest = reply.proposed_dest if data is not None and egress_policy.allows(dest) and not carries_private(data, dest): http_post(dest, data) # only an allow-listed, non-exfiltrating egress else: deny("egress blocked: destination or payload not permitted") return reply.text
Limit of the fix: allow-listing egress and gating the private read reduce the channel but only break the trifecta if at least one leg is genuinely severed or the control is airtight. The most robust design removes a leg from the agent entirely rather than trying to police the path between all three.
Impact
- ▸Confidentiality: direct exfiltration of private data — files, secrets, internal documents — to an attacker-controlled destination.
- ▸Severity: the three legs together are decisively worse than any one; this is the canonical high-impact agentic failure.
- ▸Stealth: the egress can be a benign-looking request, making the leak hard to spot in traffic.
- ▸Breadth: any private data the agent can reach is in scope once the channel exists.
Detection
Static analysis can establish the trifecta structurally: prove a private-data dataflow, prove an untrusted-input dataflow, and prove an egress call path, then test whether all three are co-reachable within one agent. The honest unit is three proven legs; a verdict must name any leg it could only assume. Dynamic testing can plant a canary secret and an injected instruction to exfiltrate it, then observe whether the canary leaves the boundary — confirming the channel end to end.
How ROOTVIEL analyzes it
- Support level
- Verified — a composite verifier for the co-located trifecta, grounded in proven dataflow and call-path analysis; it is a structural (code-graph) finding, not a demonstrated exfiltration.
- Family
- GenAI (static), Python-centric — the analysis reasons over the agent’s code graph.
- Declared property
- The three legs — private-data read, untrusted input, and egress — must not be co-reachable within a single agent without a separating control.
A composite verifier confirms the trifecta only when every leg rests on a proven path: a proven private-data dataflow, a proven untrusted-input dataflow, and a proven egress call path, all co-reachable in one agent. When any leg can only be assumed rather than proven, the verifier degrades the verdict to UNKNOWN and names the assumed leg, so a consumer can see exactly what was not established.
Honest discipline: this is a structural, code-graph finding — it shows the channel exists in the code, not that an exfiltration was demonstrated. A confirmed finding requires all three legs proven and co-located; an assumed leg yields a named UNKNOWN, never “secure”. The distributed form — the trifecta split across a swarm — is out of scope for a reported finding and is adjudicated only in the NEMESIS arena.
Mitigation
Break the composition; do not try to make a trifecta-holding agent resist injection.
- ▸Preventive (remove a leg): keep untrusted-input handling, private-data access, and egress in separate agents or stages so no single agent holds all three.
- ▸Preventive (interpose a control): if a leg cannot be removed, allow-list egress destinations deterministically and forbid private data on those paths; gate private reads by caller authority; treat untrusted input as data, never instructions.
- ▸Detective: statically test for all three legs co-reachable in one agent with proven paths; plant canary secrets and confirm they never leave the boundary.
- ▸Corrective: minimise each agent’s capabilities; scope and rate-limit egress; monitor outbound traffic for private-data markers.
Verifying the mitigation
Verify by re-establishing the structural fact and then probing it: re-run the composite verifier and confirm the three legs are no longer co-reachable in one agent (a leg removed, or a deterministic control on the path); plant a canary secret with an injected instruction to exfiltrate it and confirm the canary does not leave the trust boundary. Report as “the channel was not reachable within the tested scope”, and name any leg the static analysis could only assume rather than prove.
A structural pass and a dynamic canary test answer different questions: one proves the channel is absent in the code, the other proves a specific payload did not escape. Prefer both; never read one as a guarantee of the other.
Common mistakes
- ▸Adding all three capabilities to one agent for convenience and relying on the model to refuse misuse.
- ▸Allow-listing egress destinations but still letting private data flow to an allowed-but-attacker-reachable endpoint.
- ▸Treating a two-leg agent as safe and then adding the third leg later without re-checking the composition.
- ▸Reporting a trifecta as confirmed when a leg was only assumed — an assumed leg is a named UNKNOWN.
- ▸Confusing a structural (code-graph) finding with a demonstrated exfiltration — they are different claims.
References
- [1]Simon Willison — the lethal trifecta for AI agents
- [2]OWASP Top 10 for LLM Applications — LLM02 Sensitive Information Disclosure
- [3]OWASP Top 10 for LLM Applications — LLM06 Excessive Agency
- [4]MITRE CWE-200 (Exposure of Sensitive Information to an Unauthorized Actor)
- [5]MITRE CWE-668 (Exposure of Resource to Wrong Sphere)
References point to public, well-known technical sources for further study.
Related articles
