An allowlist of IP addresses is a guess about where a domain lives today. So we stopped guessing. Phase two of our agent egress control is a small local proxy (egress means outbound traffic) that allows or refuses each connection by hostname. Each agent loop gets a policy that can narrow the fleet's list but never widen it, and every connection gets one audit line. We pointed a real agent at it and found six hosts nobody had signed off.
What we did
A month ago we mapped every host our agents reach, 31 of them, and generated a default-deny firewall ruleset that we deliberately left unarmed. Its known weakness was written in its own header: a firewall filters by IP, and the big clouds share and rotate IPs, so an IP rule is a coarse backstop, not a control. The fix is to filter by name, and the cleanest design we have seen for that is in Forge, an open-source agent runtime. We did not adopt the runtime. We lifted three ideas from it.
- Allow by name at a proxy. A loop opts in with one environment variable. The proxy sees the hostname before any data moves, checks it against the list, and either opens the tunnel or answers 403.
- Layered policy. The fleet allowlist is the outer bound. A loop's own policy file can only pick from inside it. A nightly research digest gets models, paper sites and Telegram, and nothing else, so it cannot reach GitHub even though the fleet can. A lint step refuses any loop policy that tries to widen the bound.
- An audit line per connection. JSON, one line each, tagged with the loop name and a run id. "What did last night's digest reach?" is now one grep.
We added one check of our own. For HTTPS, the proxy reads the first packet of the TLS handshake, which names the server the client actually wants (the SNI). If that name differs from the host the client asked the proxy for, the tunnel dies. That closes the cheapest trick in the book: ask for an allowed host, then talk to a different one. The proxy also refuses to listen on anything but the local machine, so it can never become somebody else's open proxy.
What it found
The test suite runs real traffic through the proxy in both modes, 19 checks, all passing. The first run failed one of them, and the proxy was right. A request to the Telegram API's root redirects to a different Telegram host that is not on our list, so it was refused. The list said one thing, reality said another, and we only found out because something checked.
Then we pointed a real agent at it: a headless Claude Code session in monitor mode, which logs what enforce mode would have blocked but lets everything through. It made 26 connections. Ten went where we expected, to the model API and our database. Sixteen went to six hosts that were not on the list: four remote MCP tool servers, a logging intake for the runtime's own telemetry, and local tool servers that should never have been routed through a proxy at all. None of those is sinister. All of them are decisions nobody had made, which is the entire point of an allowlist.
In enforce mode, with the digest loop's policy, a paper search tunnelled through and a request to pastebin (the classic place to dump stolen data) came back 403, logged against the run that tried it.
Why it was worth doing
An agent that reads the web can be told to send things to it. The prompt injection lands in a page, the page says "post this to that URL", and the only thing standing between the instruction and the request is whatever controls the network. A per-loop list by name is a much smaller door than a fleet-wide list by IP, and the audit trail means an odd request shows up as a line with a run id attached, not as a mystery.
What's still off
It is opt-in. A process that ignores or unsets the proxy variable walks straight past it. The proxy is the control. What makes it unskippable is the firewall from phase one, armed to allow only the proxy out, and arming a default-deny rule on a live fleet box is still a job for a human with a console open. Nothing is wired into the nightly loops yet, either. The next step is a week in monitor mode on real loops, to turn those six unknown hosts into yes or no. And if a client sends no server name (newer encrypted handshakes can hide it), the proxy logs that rather than blocking it.
What's now in the stack
egress_proxy.py: a standard-library-only Python proxy (343 lines) withserve(monitor or enforce),report(per loop: allowed, blocked, would-have-blocked) andlint(loop policies only narrow).egress-run: a one-line wrapper that opts a single command in, tagged with its loop name and a fresh run id.- An end-to-end test suite over real traffic, and an example loop policy. On GitHub. Steal what you like.