Agentic Security / agent / capabilities / data flow
The Lethal Trifecta
That tools can be risk-assessed one at a time, so a read-only server is safe to add. Every leg of the path is individually defensible: reading your files is the point, reading a web page is the point, rendering an image is a UI feature. Risk appears only in the union, and it appears the moment the third leg lands — which means the change that creates the vulnerability is usually the change that looked most harmless. The exit is also badly under-counted: a rendered Markdown image is a GET request with the attacker's data in the path, and it needs no shell, no network tool and no click.
An agent leaks data when three things are true at once: it can reach something private, it reads text somebody else wrote, and it can emit bytes that somebody else can observe. Each of those is a feature. None of them is a vulnerability. The vulnerability is the path, and the path appears the moment the third one lands — usually in the change that looked most harmless.
Simon Willison named this the lethal trifecta in June 2025: access to private data, exposure to untrusted content, and the ability to externally communicate. It is a useful name because it moves the unit of review. You cannot look at a tool and say whether it is safe. You can only look at the set of tools sharing one context window and ask whether a path runs through it.
Below is that question made mechanical. Each toggle adds edges to a data-flow graph: some tools bring private bytes in, some bring attacker-controlled text in, some let bytes out, and a few do more than one. The simulation searches for a path from a private source to an attacker-observable sink through a context the attacker can steer, and if it finds one it computes how many requests the exfiltration takes at that channel's capacity. Start from the default — a code assistant with a repository, an issue reader and a web fetcher — then try to close the path by switching off exactly one thing.
Channel capacities are computed from what each carrier can actually hold: a DNS query name is capped at 253 characters in labels of 63, base32 gives 5 bits per character, an HTTP request path is bounded by the server's header limit, and a posted comment is bounded by the tracker's body limit. They are order-of-magnitude figures, not benchmarks.
leg is closed — nothing enabled supplies it · leg is open, and the tools listed are what open it. A leg opened by two tools cannot be closed by removing one.
What is real and what is a model. The graph search is real: sources, sinks and taint are edges, the path is found by walking them, and the minimum cut is found by testing every subset of the enabled toggles up to size three. The network policy is applied as a real filter over sink hosts, including the cases where it does not apply — a sink executed by the chat client is outside the agent's network namespace, and a DNS query leaves through the resolver rather than through the HTTP stack. What is modelled is the channel capacity: the figures come from protocol limits, not from measurements of your stack.
At the defaults the hero number reads 1. One request. The path is
fs.read → context → web.fetch, and the tainting text came from
an issue anybody can file. A 40-byte key is 320 bits; a URL path carries
thousands, so the whole thing goes out in a single GET to a host the
attacker owns, before the agent has finished its answer.
Now try to close it by removing the exit. Switch off web.fetch: the hero number stays at 1, because the chat client's Markdown image renderer fetches URLs too, at the same capacity. Switch that off as well and it is still 1, over DNS. Three removals, and you have given up browsing documentation, showing a diagram, and resolving hostnames — which is to say, the agent. Read the toggles to close the exit leg alone readout as you go; it counts every surviving sink, because the attacker only needs one.
Then read the other readout. The smallest set of toggles that closes every path says 1: fs.read. The cheap cut was never on the exit leg. It is on the leg nobody proposes cutting, because reading the working tree is what the agent is for — and that is the actual trade this framing puts in front of you.
Why reviewing tools one at a time cannot find this
Enable only fs.read. The panel says one leg is open, no path exists, the hero number is "no path". Enable only github.get_issue: same verdict, different leg. Enable only web.fetch: two legs, still no path, because there is nothing private to send. Every tool passes review on its own. All three together take one request.
This is why "is it safe to add this MCP server" has no answer. MCP is the Model Context Protocol, the standard interface for handing an agent a set of tools, and the question is not well-formed. A server that only reads is a server that supplies the private-data leg; a server that only fetches supplies untrusted content and an exit at once. The server's own tool descriptions are a fourth thing to worry about — see what a poisoned tool description does. The property being asked about does not live in the server, and a review process that examines servers one at a time is structurally incapable of finding it — not because reviewers are careless, but because the thing they are looking for is not in the object they are looking at.
Watch both cut readouts as you enable tools. With the defaults the minimum cut is 1 and the exit-leg cut is 3. Add db.query and the minimum cut becomes 2, because the private leg now has two suppliers and removing either one leaves the other. Add github.comment and mail.send and the exit-leg cut climbs to 5 while the minimum cut stays at 2. Every convenience anybody adds moves one of those numbers up, and nobody is tracking either of them, because nobody computes them.
There is a second reading of the same panel that matters more for triage. A leg listing two tools cannot be closed by removing one. That is the difference between a finding you can fix in an afternoon and a finding that requires an architectural argument, and it is visible from the readout before anyone writes a ticket.
The exits nobody counts
Set the network policy to deny all outbound from the container, leave every tool on, and read the hero number. It is not "no path". Two exits survive a total network block, and both of them survive it for the same reason: they are not executed by the process you blocked.
The first is Markdown image rendering. The model emits
 in its reply, that
reply is delivered to the chat client, and the client — a browser tab
on the user's laptop, or a desktop app, or a Slack renderer — makes the GET.
The agent's network namespace is not involved at any point. This is the
shape of EchoLeak, disclosed against Microsoft 365 Copilot in June 2025 and
assigned CVE-2025-32711 — recorded as "AI command injection in M365 Copilot
allows an unauthorized attacker to disclose information over a network",
scored 9.3 critical, with UI:N in its severity vector, which is
that standard's way of saying the victim did not have to click anything. As
the researchers who reported it described the chain, an inbound email
supplied the untrusted content, the tenant's own documents supplied the
private data, and image rendering supplied the exit.
The second is DNS. Switch the policy to the HTTP allowlist and leave
the container still resolves DNS on. The log prints the query the
resolver is asked to look up — <64 base32
characters>.exfil.example for the default 40-byte secret — and
says why it survives: a resolver is not an HTTP client and never consults an
HTTP allowlist. RFC 1035 §2.3.4 caps a label at 63 octets and a name at 255,
which is 253 characters once written out; base32 encodes 5 bits per
character, and some of those characters have to spell the attacker's own
domain, so one query carries on the order of 150 bytes. The panel uses 1,240
bits, or 155 bytes. A 40-byte key fits in a single query. Turn the DNS
toggle off and the log tells you what that costs: not just the exit, but
every hostname the agent resolves, including the allowlisted ones — which is
why almost nobody turns it off.
Leave only fs.read, github.get_issue and DNS on, so DNS is the single surviving exit, and move bytes of secret to move to 512. The hero number becomes 4 requests: 4,096 bits at 1,240 bits per query. Now move the requests the attacker gets before someone notices slider down to 3 and the verdict flips to outside budget. That is the only regime in which channel capacity is a control: when the thing you are protecting is much larger than the channel and you are actually watching. For an API key, a session cookie or a private key, the channel is always wide enough and the count is always one.
Where cutting a leg fails
The trifecta framing suggests an easy remedy — remove any one leg — and the simulation is honest about how often that is available. Work through the three.
Removing private data works and is the most under-used option. An
agent that reads a public repository and files issues needs no credentials
at all, and the correct response to "it needs the database for one report"
is usually a narrow, pre-authorised endpoint rather than
db.query with a service role. This is the
excessive agency question, and it is decided
by the permissions on the identity the tool authenticates with, not by the
tool's name.
Removing untrusted content almost never works, because reading things other people wrote is the job. An agent that triages issues reads issues. A coding agent reads dependencies, test output and documentation. Every one of those is bytes chosen by someone else, and the boundary of the category is much wider than the list anyone writes down — a filename is untrusted content, an HTTP header is untrusted content, a DNS TXT record is untrusted content. See indirect prompt injection for why the model cannot be asked to sort them.
Removing the exit is the one people attempt, and it is the one the panel keeps refusing to let them finish. There is no single place to enforce it: the process, the client and the resolver are three different enforcement points with three different owners, and that is the subject of prompt-level versus process-level isolation. The realistic version is narrower than "block egress": strip image URLs and links from model output before rendering, pin the resolver and log every query name, and put the agent's HTTP traffic through a proxy that logs full URLs rather than hostnames.
The honest position is that on most real agents you will not close the trifecta. What you can do is know the number. An agent where the minimum cut is 3 and the widest channel is a posted comment is a different risk from one where the cut is 1, and today almost nobody can tell you which one they are running.
Checking this on a system you actually run
Build the graph from your own manifest, once, by hand. For every tool the agent can call, write three booleans: does it return bytes the user could not otherwise read; does it return bytes chosen by someone outside your trust boundary; can an attacker observe a side effect of calling it. Include the client in the inventory — Markdown rendering, link unfurling, iframe embeds, any auto-preview — because those are tools the model can invoke by emitting text, and they never appear in a tool manifest. Then count paths and count the minimum cut. If the answer is not on a page somewhere, the answer is 1.
Instrument the exits rather than trying to close them. Route the agent's
HTTP through an egress proxy that logs the full URL, not the hostname —
github.com in a log tells you nothing, and
github.com/api/v3/repos/x/issues/1/comments with a 4 KB body
tells you everything. Point the container at a resolver you control and log
every query name with its length; a query name over 100 characters that
resolves to a domain registered last week is the single highest-signal alert
available in this whole area, and it costs nothing to add. For the client
side, render model output through a sanitiser that rewrites image sources to
a proxy, or drops remote images entirely in agent transcripts.
In an incident, the question you will need to answer is which fetch introduced the untrusted bytes into the decision that made the outbound call. Keep the tool-call id and the resource identifier on every span of every model call, as described in the injection lesson, and keep the outbound request log joined to the same trace id. Without that join you have two logs that both look normal: an agent that read an issue, and an agent that fetched a URL.