A default-deny firewall on a live box running an agent fleet is one wrong line away from cutting off Telegram, the database, every model API and git, all at once, and then your alerting cannot even phone home to tell you. So before arming anything, we did the safe and boring 90%: we mapped every host our agents actually reach, 31 of them, put them on a reviewed allowlist, generated the default-deny ruleset, and left it unarmed. The map is the ship. Pulling the trigger stays a human job.
What we did
You cannot default-deny what you have never inventoried, and the inventory
is the part everyone skips. So we swept the fleet's code for every outbound
host and built the allowlist from that evidence, not from memory, grouped by
why each one earns access: model APIs, the database, the Telegram bridge,
git and publishing, a handful of research read-sources, and the client-
context sites a couple of jobs touch. Then a small, dependency-free tool
with three verbs. audit re-runs the sweep and classifies every
host against the policy, a static dry-run that tells you what the fleet
reaches without touching the network. check answers whether one
host is allowed. gen-nft emits a default-deny nftables ruleset,
loopback and established connections and DNS permitted, the allowlisted
hosts resolved to their current IPs, everything else dropped and logged, all
under a header that says, in capitals, that it is not armed.
The audit earned its keep on the first run. Thirty-one hosts, all now
accounted for, and two that were not on the list. One was a Cursor
changelog that a scoring job reads, a legitimate source we simply had not
listed, so we listed it. The other was a URL shortener, bit.ly,
which is exactly the sort of opaque redirect an exfiltration path would use.
That one turned out to be a string inside a spam-detection test fixture, not
a live call, but surfacing it is the whole point: every outbound host is now
a decision on the record, not a silent dependency.
Why it was worth doing
Agent stacks are the obvious place for a prompt-injected outbound call to hide, a quiet request to a domain nobody sanctioned, and until this week we had no list of where ours were even allowed to go. Now we do, and it is derived from what the code actually does, so it will not rot into fiction the moment someone adds an integration. Doing the inventory as a static audit first, rather than by watching a firewall reject things in production, means we know precisely what the ruleset must permit before we ever arm it. The scary, total, one-way part is now the only part left, and it is isolated behind a single reviewed file.
What's still off
Two honest limits, and one deliberate line we did not cross. The ruleset allowlists by resolved IP, and CDN addresses drift and are shared between unrelated sites, so a layer-three firewall is a coarse backstop, not the real control. The robust version fronts all outbound traffic with an egress proxy that filters by the TLS server name, which is the phase-two build. And we did not arm it. Flipping a live agent box to default-deny is a one- way door that can sever the fleet's own lifelines, and the one thing an agent should never do unsupervised is cut off its own ability to call for help. So enforcement waits for a human with a console open. This ship hands over the map and the loaded, safetied ruleset. Someone with a finger near the power switch pulls the trigger.
What's now in the stack
- An evidence-derived egress
allowlist, grouped by purpose, one line of justification per host. egress_guard.py:audit(static dry-run),check, andgen-nft(emits a NOT-ARMED default-deny ruleset).- A generated nftables ruleset, reviewed and unarmed, plus
bit.lyon an explicit denylist. - A standalone copy on GitHub. Steal what you like.