Exfiltration
Three ingredients make a data-leak path: access to private data, exposure to untrusted content, and a way to send something outward. Any two of them are inert. All three in the same run means the run can be induced to carry data out, and no amount of care in the prompt changes that arithmetic.
The useful property of a three-part condition is that you only have to break one part. Defending the combination means arguing about text again, with the same unbounded failure rate as everything else in this chapter. Removing a leg is a design change, it is permanent, and it holds against attacks nobody has written yet.
The Three Legs
Say the three out loud for your own agent, because most teams discover they have all three and have never written it down. Private data is anything the run can read that the outside world should not see. Untrusted content is anything in context written by someone who is not you — Topic 66's channel inventory is the list. An outbound channel is anything that leaves the process, which is a much larger category than "the tools that send messages".
| Leg | Sundry's version, before the fix | How it could be removed |
|---|---|---|
| Private data | Orders, addresses, charges, seller balances readable in the run | Scope reads to the one ticket's customer and drop balances from the payload |
| Untrusted content | Seller descriptions, carrier strings, buyer uploads, remote tool results | Read it in an isolated pass with no write tools (Chapter 10) |
| Outbound channel | message_seller with free text, plus everything in the next section | Template the message, or forbid it on runs that read third-party text |
Sundry's support agent had all three on the day it launched, and the honest reading is that this was not carelessness — each leg arrived for a good reason and nobody drew the triangle. That is the normal case. The exercise costs an hour, produces a diagram with three boxes, and is the single most useful hour of security work available on an agent that talks to anyone outside the company.
The Channels You Did Not Count
Enumerating outbound channels is where the exercise gets uncomfortable, because several of them do not look like sending. Anything that leaves the process is a channel, whatever the tool is called and whichever direction the developer had in mind.
| Channel | Why it is missed |
|---|---|
| A free-text message to a seller | It is a communication feature, so it is reviewed as one |
| A URL the agent fetches | It reads like a read; the request line carries whatever is in the path |
| An image or link a client renders | The agent writes a reply and somebody else's software makes the request |
| An error report or crash payload | Context is attached for debugging and forwarded to a third-party service |
| A webhook or callback to another system | It was configured by an integration team, not by the agent team |
| A sandbox with open egress | The network policy is a container setting nobody reads as a data path |
The second row is the one to internalize. A tool that takes a URL and returns its content is filed under reading in every design document ever written, and the outbound half — a request to a host of somebody else's choosing, carrying whatever the path contains — is exactly as real as a message. Chapter 11 said the same thing about browser sessions from the other direction. If a component can cause a request to leave your network with content in it, it is an outbound channel and belongs on this list.
Removing a Leg
Three moves, in rough order of how often they are available. Read untrusted content in a subagent with no write tools and no outbound tools, which removes the second leg from the context that holds the first and third — Chapter 10's mechanism again, doing its third job in this chapter. Drop the outbound channel for the runs that touched untrusted content, which is a single condition in the dispatcher and costs nothing on the runs that did not. Or scope the data so the run can only see the ticket in front of it, which removes the first leg and is the version that also improves everything else, because a smaller payload is a cheaper and clearer context.
Pick per product rather than per principle. Sundry could not remove message_seller — asking a seller whether a replacement is available is half of what the damage flow does — so it removed the free-text half and kept the capability. An internal research agent with no outbound tools at all can happily read the whole document store. The question is never "which leg is the security one"; it is which leg the product can live without, and there is usually exactly one.
Egress Control in the Sandbox
For agents that execute code, the third leg is a network policy, and Chapter 11 already argued it: default deny, an explicit allowlist, every allowed destination logged with the run id and a byte count. The reason it belongs here too is that a sandbox is the place where the third leg is cheapest to remove — a batch job that reads two CSV files and writes one output file needs no internet whatever — and also the place it is most often left open, because a missing package makes a run fail and opening egress makes the failure go away in thirty seconds. Same triangle, one layer down, and the same answer: install the dependency at image build time and keep the box closed.
Detection
Detection is what you build for the leg you could not remove, and it should be labelled that way in the design note so nobody mistakes it for the control. Three things earn their place. Scan outbound content for identifiers that have no business being in it — order ids from other customers, email addresses, card fragments — and block on a match. Log every destination and alert on ones that have not been seen before. Count outbound volume per run, because one run producing forty messages is a shape no legitimate ticket has.
Then state the limits plainly. Scanning matches patterns, and anything that can be paraphrased or encoded walks past a pattern matcher; the scan catches accidents and lazy attempts, which is worth something and is not a boundary. Alerting on destinations only works if somebody triages the alert. Volume anomalies are found after the volume happened. All three are detective controls: they shorten the time between an incident and the discovery of it, and they never prevent one. Design that closes the path is what prevents one.
Sundry's Position
The rule Sundry ships with is one sentence in the dispatcher: no run that has read seller-supplied text may send a free-text message in the same run. What it may send is one of nine templates, with slots the code fills from Sundry's own records — an order id, a date, an item title, a named reason from a fixed list. The model chooses the template and the slot values are validated against the ticket; nothing the model writes travels outward as prose.
SELLER_TEMPLATES = {
"replacement_available": "Is a replacement available for {item_title} "
"on order {order_id}, delivered {delivered}?",
"damage_report": "Buyer reports {reason} on order {order_id}.",
# ... nine in total, each with a fixed slot list
}
def message_seller(template, slots, run):
if template not in SELLER_TEMPLATES:
return error("unknown template", choices=list(SELLER_TEMPLATES))
slots = validate_against_ticket(slots, run.ticket) # ids, dates, reasons
return send(SELLER_TEMPLATES[template].format(**slots))
Read what that costs and what it buys. It costs expressiveness: the agent can no longer write a seller a nuanced paragraph, and about 4% of damage tickets now go to a human who can. It buys a channel whose contents are drawn from a fixed set of strings and validated slot values, which means the outbound leg cannot carry arbitrary text no matter what the model was persuaded of. Free text still exists at Sundry — it goes to the buyer, through the reply path, where the recipient is the person whose data it already is. That distinction is the whole design: the leg was not removed everywhere, it was removed on the path where it met the other two.
- Counting only the tools whose names contain "send" — the URL an agent fetches is an outbound channel that every design document files under reading.
- Allowing free-text outbound on a run that has read third-party content — that is the exact shape this topic exists to prevent, and it is the default configuration of most support agents.
- Treating an outbound scanner as the control — it matches patterns, anything encoded or paraphrased walks past it, and it is a detective control reported as a preventive one.
- Giving the run access to the whole customer database when the ticket concerns one customer — the first leg is usually the easiest to shrink and the last one anybody looks at.
- Draw the three legs for your own agent explicitly, name the tool or path that provides each, and remove one wherever the product allows it.
- Scope data access to the current ticket, and drop fields the run does not need from the payload before they ever reach context.
- Prefer templated outbound messages with code-filled slots wherever the content is predictable, and route the rest to a person.
- Log every outbound destination with the run id and byte count, alert on unfamiliar ones and on volume, and label all of it as detection rather than as a boundary.
Knowledge Check
Why is removing one of the three legs preferable to defending the combination?
- Auditors require one of the three to be absent, so a system with all three cannot be certified for production use
- A leak needs all three, so a missing leg holds against attacks nobody has thought of yet, without judging any text
- Layered defences conflict with each other, so a system with several overlapping controls is weaker than one with a single control
- Removing a leg is always the cheaper option, because every control that inspects content adds latency to every ticket
Which outbound channel is most often left off the list, and why?
- A message to a seller, because messaging tools are added late in a project and rarely get a security review
- The reply to the customer, because nobody thinks of the support answer itself as data leaving the company
- The application log, because it holds the whole context and is retained far longer than any individual ticket
- A URL the agent fetches, because a fetch is filed as a read even though the request itself leaves the network
Sundry replaced free-text seller messages with nine templates whose slots are filled by code. What is the trade?
- Lost expressiveness and about 4% of damage tickets going to a human, in exchange for a channel that cannot carry arbitrary text
- The channel is closed entirely, so the agent no longer communicates with sellers and the damage flow lost a step
- A cheaper run, since the model no longer generates message text and the saved output tokens pay for the isolation pass
- Guaranteed correctness, because a template cannot be sent to the wrong seller or chosen for the wrong situation
What is the honest status of scanning outbound content for customer identifiers?
- Security theatre with no value, since anything it can match is something the model would not have sent anyway
- A replacement for removing a leg, once the false-positive rate is measured and the block threshold is tuned
- A detective control that catches accidents and shortens discovery, defeated by paraphrase and encoding
- A preventive control that bounds what a compromised run can send, provided the identifier list is kept current
You got correct