In 2025, a researcher hid a block of instructions inside a PDF, white text on a white background, invisible to anyone skimming the page. Notion's AI agent read the document, followed the hidden instructions, gathered a customer list with names, companies, and revenue figures, and shipped the whole thing to an attacker's server through its own web-search tool. Reviewing the attack, Bruce Schneier did not soften it: "We have zero agentic AI systems that are secure against these attacks."
Here's the part that should bother you. The agent had permission to do every step. Reading the PDF: permitted. Reading the customer list: permitted. Making an outbound request: permitted. Grant that the agent needed each of those permissions for its real job, and the attack still works, because it misuses paths the agent was right to have. The breach didn't come from excess access. It came from the path the data took on the way out.
That is the gap I want to close. Least-privilege scoping is the right first move for a production agent, and it's the one teams reliably make. It's also where they stop. The lethal trifecta, Simon Willison's name for the dangerous combination, has three legs: access to private data, exposure to untrusted content, and a way to communicate outward. Scope governs the first leg. The breach uses the other two. This is the practitioner companion to the agentic security cornerstone, narrowed to the two boundaries that scope review leaves open.
What scope actually controls (and what it doesn't)
Start with the move everyone gets right, because it is genuinely necessary. Before you connect an agent to anything, you decide what it can reach: which repositories, which tickets, which tables, which tools. Get it wrong in the permissive direction and you have handed a future attacker a wider surface for free. I wrote a whole post on running that review before you connect an MCP server, not after the incident, and I stand behind every word of it.
The belief that quietly rides along with that work is the problem. It goes: once I have scoped the agent to least privilege, the access side is handled, and the agent is safe to connect. The first clause is true. The second doesn't follow from it.
Watch what scope is and is not. Scope answers one question, "what is this agent allowed to touch," and it answers it well. It says nothing about what the agent does with that access once an instruction arrives, and it says nothing about where the results of that access can go. OWASP makes the same cut from the other direction. In its 2025 LLM Top 10, the Excessive Agency entry names three distinct root causes: excessive functionality, excessive permissions, and excessive autonomy. Permissions are one of the three. Tighten them to the bone and you have closed one cause and left the other two standing.
The Notion attack is the clean demonstration. Every action the agent took was inside its grant. The injection didn't escalate privilege. It redirected behavior. A scoped agent is a loaded tool pointed in a safe direction, right up until untrusted input turns it.
I am not theorizing about someone else's deployment. I run the Jira, GitHub, and Confluence MCP servers every working day, and across a company-wide rollout they collapsed cross-tool context assembly from a couple of hours of clicking between systems to a couple of minutes. That agent reads private data across three systems and can write, comment, and tag. It's the lethal trifecta as a standing fixture in my workflow, and scoping it, which I did, governs exactly one of the three legs. Claude Code in GitHub Actions hits the identical wall on a single CI surface: a read-only agent can still be talked into reading a secret it has access to and writing it somewhere an attacker can see. Necessary is not the same word as sufficient.
The breach happens on the outbound path
So follow the data, not the permissions. An agent that has been steered by a malicious instruction doesn't need new access to do damage. It needs a way out. Willison's framing is precise about this: the third leg of the trifecta is "the ability to externally communicate," and once it is present alongside private data and untrusted content, the exfiltration channels are almost limitless. An HTTP request. A tool call that takes a URL. An image that loads from an attacker's domain. A comment posted to a public issue. Each one is a legitimate capability. Each one is also a door.
The reason this is hard, rather than merely overlooked, is that the obvious fix doesn't hold. The instinct is to allowlist the destinations: the agent may talk to these domains and no others. Anthropic tried exactly that on its own products and then wrote up where it broke. In a May 2026 engineering post, the team described an incident where an attacker-controlled API key let a request ride out through an already-allowlisted endpoint. The egress filter checked the destination and waved it through, because the destination was on the list. It didn't check whose request it was carrying. The filter wasn't misconfigured. The model of "trusted destination" was simply too coarse to catch a trusted destination being used against you.
The protocol layer won't save you either. The Model Context Protocol specification, in its current 2025-11-25 release, is blunt about its own limits: "MCP itself cannot enforce these security principles at the protocol level." Its authorization framework governs what a client may request from a server. It is silent on what the agent does with the response, and it does not apply at all to the stdio transport, the local process-pipe mode for MCP servers running on your own machine. Authorization is an inbound control. Egress is an outbound problem. They are different boundaries, and only one of them tends to get a review.
A domain allowlist that checks only the destination is not an egress control. It is a destination filter, and an allowlisted destination is exactly where a steered agent will route data, because that path is the one you pre-approved.
Three legs, and one dimension that cuts across all of them
Put the pieces in one frame so the gaps are impossible to misplace. One caveat first, because it changes what you do next: the trifecta models prompt-injection-driven exfiltration, not the whole of agent security. A poisoned connector, a stolen credential, a compromised MCP server, or a destructive write that never needs to leave the building are real, adjacent, and out of scope for this frame. They need their own controls (connector provenance, short-lived scoped tokens, dependency pinning, secret isolation). Inside the exfiltration story, the trifecta has three legs, and each maps to a control surface:
- Access and authority. What the agent can read, and what it can do with a connector's identity: write, comment, merge, send, spend. Governed by the data-access scope review you run before connecting. This is the leg you already cover, with one catch worth holding onto: broad write authority lets a steered agent act as a confused deputy even when its read scope looks narrow.
- Untrusted input. What unvetted content the agent ingests, including the tool results it reads back. The boundary between instruction and data lives here, and the model can't reliably tell which is which.
- Egress. What the agent can send, and where. Egress control lives here, and the data-access review does not touch it.
Blast radius is not a fourth leg. It is the dimension that cuts across all three: how much damage results when any one of them is compromised. Scope shapes it but doesn't settle it, because segmentation, rate limits, reversibility, and human gates all move the number too. It is the separate question that decides whether a breach is an incident report or a company-ending event. Microsoft's defense-in-depth guidance for autonomous agents lands on the same split from the builder's side: bound each agent's capabilities, and enforce the high-stakes gates with code rather than model judgment. As the Microsoft team puts it, "the application layer is the decisive one because it is the only layer builders fully control."
| Trifecta leg | What governs it | Failure mode if you stop at scope |
|---|---|---|
| Access and authority | Data-access scope review | Mostly closed, with residue: stale grants, over-broad reads, sensitive-field aggregation, and broad write authority that enables a confused deputy |
| Exposure to untrusted input | Input boundary, tool-result quarantine | Injected instructions ride in on tool output and the model treats them as commands |
| External communication | Egress control, destination provenance | A steered agent ships data through a permitted, pre-approved channel |
| Blast radius (cuts across all three) | Sandboxing, human gates, observability | A single compromised leg has unbounded reach: irreversible actions, full data sets, no audit trail |
Read down the last column. Scope closes the top row, and every row beneath it is work that a permission review never scheduled. That work is the rest of this post.
Closing the two open legs, and bounding the blast radius
The controls follow the frame. Take the two ungoverned legs first, then the dimension that wraps them.
The untrusted-input leg is an inbound problem. Treat every tool result as untrusted content, not as trusted context, before it re-enters the agent's reasoning. A web page the agent fetched, a ticket comment it read, the description field of an issue it was asked to triage: all of it is attacker-reachable, and tool-poisoning attacks live precisely in that gap. This is the leg most security writeups and team reviews talk about least, and the one that feeds the other two, because a clean injection here is what redirects the agent in the first place. Quarantine it: strip or flag instructions that arrive through data channels, and never let a tool result silently rewrite the agent's goals. The untrusted label has to survive the trip, too. The same content gets summarized, written to memory, or handed to a subagent, and it can re-enter later looking trusted unless the provenance travels with it.
The egress leg is an outbound problem, and it is where the extra scrutiny goes: not just where a request is headed, but who inside the session initiated it and what data is riding along. Constrain destinations, but check the request, not just the host. As of June 2026, Claude Code's permission system gives you concrete hooks for this: domain-scoped WebFetch(domain:*.example.com) rules, deny rules that take precedence over allows in a six-step evaluation, and, in a recent release, parameter-level Tool(param:value) rules. The permission flow is worth understanding precisely, because the allowlist is one of those six steps and not the whole system. An allowlist alone will not save you under a permission-bypass mode either: unlisted tools get approved at the permission-mode step. Deny rules, explicit ask rules, and hooks still run ahead of that check, so they are what survives bypass, which is the case for not leaning on the allowlist by itself. And destinations are only half the problem. The harder case is an approved destination carrying a forbidden payload, the Slack channel or GitHub comment the agent is allowed to post to, which is why sensitive data is worth a classification that travels with it rather than a domain that is simply on or off the list.
Blast radius is the dimension you bound regardless of which leg fails. Gate the actions that leave the machine or cannot be undone. I keep a short list of actions that always earn a human, and outward reach sits at the top. That is the approval budget worth spending: the few decisions where judgment is irreplaceable, with the approval showing the raw destination and payload rather than a model-written summary of them. Enforce those gates with runtime mechanisms rather than written policy, the same wrong-layer-trust problem I keep running into when governance lives in a document instead of a hook. Wire them to fail closed: a check that errors or never runs on the route it should guard has to deny by default, not wave the action through. Pair them with the deterministic limits that cap a bad run before a human is even in the loop: rate limits, a transaction cap, a kill switch, a rollback path. And instrument the boundary so you can see what crossed it, because a control you can't observe is a control you can't trust after the fact, which is the whole argument for agent observability, worked out in full in the observability cornerstone.
I built exactly this kind of containment for my own repository, and the cost was an afternoon. A write-path guard hook that blocks an agent from writing outside its assigned worktree, a dispatch preamble that pins every subagent to a known root, and a serialized merge wrapper. Roughly an hour of structural work. Since it landed I have run four to eight concurrent Claude Code sessions on a normal working day with zero recurrence of the cross-contamination failure that prompted it. That's not an egress war story. It is the smaller, cheaper truth underneath one: bounding what an agent can touch is a one-time investment that keeps paying, and it doesn't require getting the model to behave.
Running that scope review and configuring the data boundary is the core of how I run an MCP Server Setup engagement, and it is the half of the work that does not fit on a permissions checklist. The access scoping is the part teams know to ask for. The egress and input boundaries are the part they find out about later.
But you can't egress-proof everything
The strongest objection to all of this is also the most honest, so I want to put it in its sharpest form. It runs: you can't reliably stop prompt injection, since the model can't tell instructions from data, and you just admitted that egress controls get bypassed, since Anthropic's own filter was. There is a credible line of security research arguing that injection may never be fully solved. So if the input leg leaks and the egress leg leaks, why pretend either control matters? Put a human in front of every sensitive action and stop dressing up bypassable filters as security.
I take the premise seriously and reject the conclusion. The premise is right: no single boundary holds. That's the argument for layering, not against it. An egress control that an attacker can sometimes defeat still raises the cost of the attack and shrinks the set of destinations a steered agent can reach. An input quarantine that catches the known classes, hidden text, instruction-shaped tool output, untrusted markup, still cuts the volume that reaches the reasoning loop, even as it misses a novel semantic attack until you have a red-team fixture for it. Stack an imperfect scope, an imperfect input boundary, an imperfect egress control, and observability that catches what slips through, and the combined surface is far smaller than any one layer suggests on its own.
That holds for bounded-risk workflows. It doesn't hold everywhere. When the data is regulated exports, live credentials, or anything whose disclosure cannot be walked back, the honest answer is sometimes no agent on that data at all, no outbound path, or a deterministic pipeline a model never touches. Layered controls are for the workflows worth running with an agent, not a license to point one at everything.
The "put a human on every sensitive action" alternative is the right instinct, and it holds until the sensitive class grows. Define it too broadly and the human approving a flood of actions stops reading the approvals. That's the approval-fatigue trap, and it converts a security control into a rubber stamp within days. The discipline is to keep the human in the loop on the narrow set with outward reach or no undo, push the rest into deterministic allow and deny layers, and show the reviewer the raw action rather than a model's summary of it, so the rare wrong call is caught instead of waved through. Layered and imperfect beats single and absolute, every time the absolute one is a person who stopped paying attention.
FAQ
Isn't least privilege enough to secure an AI agent? No. Least-privilege scoping governs what an agent can access, which is one leg of the lethal trifecta. A scoped agent can still be steered by untrusted input into sending data it is legitimately allowed to read, through a channel it is legitimately allowed to use. The Notion exfiltration attack used only permitted tools.
What is the lethal trifecta? It is Simon Willison's term for the combination that makes an AI agent exploitable: access to private data, exposure to untrusted content, and the ability to communicate externally. When all three are present, a single injected instruction can turn the agent into a data-exfiltration tool.
How do I bound egress for an MCP-connected agent? Constrain the outbound destinations, not just the inbound access. Use destination allowlists that check request provenance and not only the domain, treat every tool output as untrusted before it re-enters the agent's reasoning, and gate outward-reach actions behind a human or a deterministic check. Claude Code's permission rules, as of June 2026, support domain-scoped WebFetch and parameter-level tool rules.
What's the difference between permission scope and blast radius? Scope is what the agent is allowed to access and do. Blast radius is how much damage results when one of those permissions is misused. Scope is a boundary you set before connecting; blast radius is a containment dimension you bound regardless of which boundary fails, through sandboxing, human gates on irreversible actions, and observability.
Where to start
If you take one thing from this, make it the reframe: a permission review is the first leg of three, not the finish line. Walk your own agents and ask the two questions scope does not answer. What untrusted content reaches the reasoning loop, and what can the agent send once it has been steered.
The agentic security cornerstone is where I would start for the full threat-model treatment, and the MCP authorization-scope post covers the inbound half in depth. If you would rather have a second set of eyes find the open legs in your own setup before they find you, book a 15-minute call. The review surfaces the egress and input paths a scope check is built to skip.