Chapter 3 established that an agent becomes dangerous when three conditions hold at once: private data, untrusted content, and a way to communicate externally. It also observed that of the three, only the last is usually available to cut.
Leg one is the product. Leg two is your customers. Leg three is a rendering decision, and rendering decisions are things you own outright.
Close it properly and a large share of published exploits stop working. Not because the attacker fails to compromise the model, they still do that, but because the data has nowhere to go.
Wider than most teams assume, and the gap is where the incidents live.
The one everyone protects. A tool that makes an HTTP request to a URL the model supplies. Obvious, usually locked down.
Markdown images. Output containing  fetches on display. No click, no interaction, no warning. This is EchoLeak's mechanism against M365 Copilot, and it is the single most common real exfiltration path in shipped systems.
Links the user clicks. Willison's formulation includes this and it is the one that catches people. Your agent needs no network access at all. It renders a plausible link, a human clicks it because they asked for the answer it appears to support, and the query string carries the payload.
Tool arguments. A search tool that sends its query to a third-party API is an outbound channel. So is a translation tool, a geocoder, a spell-checker. Any tool whose argument leaves your perimeter will carry whatever the model puts in it.
Error paths. An exception that includes the offending value, shipped to an external logging service. Attacker-controlled input reaching an external system through your observability stack, which nobody audits because it is not a feature.
DNS. A hostname lookup carries a few dozen bytes per query. Slow, entirely sufficient, and invisible to anything watching HTTP.
Anything a third party can read. A ticket comment, a shared document, a public bucket, a webhook, a Slack message in a channel with guests.
Egress control is not a product you buy. It is three rules applied consistently.
Destinations are allowlisted, and the list does not come from the model. Every outbound call goes to a host chosen by your configuration. If a tool needs to fetch a URL the model supplied, that is a different and much more dangerous tool, and it needs its own justification.
Attacker-controlled data never reaches a URL. This is provenance doing work again. A URL assembled from a tainted value is refused, wherever it appears: a tool argument, a rendered image, a link, a log field.
Rendering strips what it cannot verify. The renderer is a security component. Treat it like one.
public sealed class SafeRenderer(IUriPolicy policy)
{
public string Render(Tagged<string> markdown)
{
var doc = Markdown.Parse(markdown.Value);
foreach (var link in doc.Descendants<LinkInline>().ToList())
{
var text = Text(link);
if (link.IsImage)
{
Replace(link, text.Length > 0 ? $"[image removed: {text}]" : "[image removed]");
continue;
}
if (policy.IsAllowed(link.Url ?? "", markdown.Origin)) continue;
Replace(link, text.Length > 0 ? $"{text} [link removed]" : "[link removed]");
}
// Autolinks are a different node type. Handling only LinkInline leaves them rendered.
foreach (var auto in doc.Descendants<AutolinkInline>().ToList())
{
if (policy.IsAllowed(auto.Url ?? "", markdown.Origin)) continue;
Replace(auto, "[link removed]");
}
return doc.ToHtml();
}
private static string Text(ContainerInline node) =>
string.Concat(node.Descendants<LiteralInline>().Select(l => l.Content.ToString()));
private static void Replace(Inline node, string text) =>
node.ReplaceBy(new LiteralInline(text), copyChildren: false);
}Note what that does with images: it removes them unconditionally. Auto-fetching remote images in model output buys very little and supplies the most reliable zero-click channel in the field. If a product genuinely needs images, proxy them through your own host after fetching them server-side, with the URL checked against the allowlist first.
The second loop is there because the first draft of this book did not have it. Markdig parses <https://host/path> into AutolinkInline, which is not a LinkInline, so a renderer that walks only the obvious node type strips every link an attacker writes the normal way and passes the one they write in angle brackets. The parser's type hierarchy became the security boundary, which is a bad place for a security boundary to live. Whatever library you use, enumerate its link-bearing node types from its source and write a test per type. Do not trust this listing to be complete for your version.
public bool IsAllowed(string url, Provenance origin)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme is not ("https" or "mailto")) return false;
// mailto carries no host, so the allowlist applies to https only.
if (uri.Scheme == "https" && !_allowedHosts.Contains(uri.Host)) return false;
// A URL built from untrusted content is a channel, whatever the host.
if (origin is Provenance.Tainted && uri.Query.Length > 0) return false;
return true;
}The last check is the one that distinguishes this from an ordinary allowlist. An attacker who finds a permitted host that reflects query parameters, or any endpoint they can observe, has a channel through your allowlist. Refusing tainted query strings closes that without needing to enumerate which permitted hosts are safe, which is not a list anyone can maintain.
Some outbound traffic is legitimate and unavoidable. Bound it.
public sealed class EgressBudget(int maxCalls, long maxBytes)
{
public bool TryConsume(int bytes)
{
// Both counters advance on every attempt. Short-circuiting here would let a
// caller past the call ceiling spend bytes that never got counted.
var c = Interlocked.Increment(ref _calls);
var b = Interlocked.Add(ref _bytes, bytes);
return c <= maxCalls && b <= maxBytes;
}
}A task that legitimately sends two emails is not harmed by a limit of three. An injected agent trying to page through a customer table and exfiltrate it in chunks hits the ceiling on the fourth call, and the budget breach is a high-quality alert because ordinary work never triggers it.
Scope the budget to the task, alongside the capability from chapter 8. They expire together.
Aria's audit in chapter 3 found leg three supplied four times over: SendEmail, IssueRefund (which notifies the customer), the markdown renderer, and the document search tool, which calls an external index.
Work through them and the asymmetry in cost becomes obvious.
The renderer is free to fix. Strip images, verify links, ship it. Nobody outside the team notices and the EchoLeak-class attack is gone.
SendEmail is the interesting one. Support replies have to reach customers, so an allowlist of domains is useless: the domain is whatever the customer's address is. The answer here is not the destination list but the provenance rule. A recipient address derived from a tainted value is refused, which means Aria can reply to the address on the authenticated ticket but not to an address it read out of an uploaded PDF. That single rule is most of the value of this chapter for this tool.
IssueRefund notifies through a payment provider, so its egress is fixed by the integration and needs no policy beyond the gate already refusing tainted arguments.
The search index was nobody's idea of an outbound channel, and it takes a query string assembled by the model and sends it to a third party. Anything the model knows can be encoded in a search term. This is the row that never appears on a first-draft audit, and the fix is a budget plus refusing tainted query content.
Four tools, four different answers, and only one of them was a destination allowlist. The rule that carried the most weight was the provenance check, which is chapter 6 paying for itself again.
Download the full PDF for free?
Free download — no account required