The Scarier Permission Was Reach, Not Write: Locking an Agent's Outbound Traffic to an Allowlist
When I hand real work to an agent, the thing I guard most isn't file edits, it's where it connects. Here is an 80-line deny-by-default egress gate, plus what 21 nights of unattended runs revealed about the traffic I never saw.
One morning, reading through the previous night's scheduled run, I stopped on a hostname I didn't recognize. A dependency's postinstall hook was quietly pinging a telemetry endpoint I'd never heard of. The agent hadn't done anything wrong. It had run the npm install I asked for, and something two layers down reached out.
I had approval gates on file writes through the unified permission system. But on where things connect, I had nothing. Since I started running agents unattended as an indie developer, this is the spot I've come to see as the real soft underbelly. Anything that gets edited, I can diff and revert. A request that has already left the machine, I cannot.
I was only watching write permissions
When people talk about delegating real work to an agent, the conversation almost always turns to what it can change: delete files, commit, deploy. Every one of those can be stopped at an approval dialog, and the unified permission system made managing them much cleaner.
But half the risk of delegation lives on the reach side. The builds, tests, package installs, and tool calls an agent runs all carry outbound traffic, and those destinations never surface in an approval dialog. In my own setup, the following were passing straight through in silence.
Origin of the call
Example destination
Shows in approval dialog?
Dependency postinstall hook
Vendor analytics beacon
No
Telemetry bundled in a tool
Analytics collection endpoint
No
Verification script the agent wrote
Any external API
No
Real calls to billing endpoints
Ads / store / payment APIs
No
The row I cared about most is the last one. Running iOS and Android apps solo, there are moments when the agent's shell holds keys — as environment variables — that can hit ad-serving or store-management APIs. I want the nightly automation to use those keys, but the one thing I can't tolerate is those keys pointing at an unexpected host. I decided the boundary to draw isn't the write; it's the destination.
Constraining reach, deny-by-default
The policy is a single sentence. Route all traffic under the agent through a local gate, and let that gate pass only hosts on an allowlist, denying everything else by default. Deny-by-default is the whole point: a host you forgot to list simply doesn't get through. The opposite posture — allow by default, block the scary ones — leaves you chasing destinations that grew without you noticing.
The implementation trick is to not decrypt TLS. I don't need to read the payload. All I want to know is which host something tried to reach, and that's visible in the hostname of an HTTPS CONNECT request. Because the gate never touches the body, legitimate traffic keeps its privacy.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦An 80-line deny-by-default egress gate that only tunnels CONNECT to allowlisted hosts (working code)
✦A procedure for growing a per-project allowlist from empty, driven by the denial log
✦Measured results from 21 unattended nights: 41 denials across 9 hosts, and the trade-off of not decrypting TLS
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementation: a small gate that tunnels only allowlisted CONNECTs
It's about 80 lines of standard-library-only forwarding proxy. It matches the CONNECT host against the allowlist and, if it's absent, returns 403 and logs it.
# egress_gate.py — a deny-by-default gate that limits an agent's outbound traffic to allowed hostsimport asyncio, fnmatch, timedef load_allow(path="egress_allowlist.txt"): with open(path) as f: return [ln.strip() for ln in f if ln.strip() and not ln.startswith("#")]ALLOW = load_allow()LOG = open("egress_denied.log", "a")def allowed(host: str) -> bool: # exact match, or a *.example.com wildcard return any(fnmatch.fnmatch(host, pat) for pat in ALLOW)async def pipe(reader, writer): try: while data := await reader.read(65536): writer.write(data) await writer.drain() except Exception: pass finally: writer.close()async def handle(client_r, client_w): try: head = await client_r.readuntil(b"\r\n\r\n") except Exception: client_w.close(); return line = head.split(b"\r\n", 1)[0].decode("latin1") parts = line.split(" ") # handle only CONNECT (HTTPS tunnels) so we never see payloads if len(parts) < 2 or parts[0] != "CONNECT": client_w.write(b"HTTP/1.1 405 Method Not Allowed\r\n\r\n") await client_w.drain(); client_w.close(); return host, _, port = parts[1].partition(":") port = port or "443" if not allowed(host): LOG.write(f"{time.strftime('%F %T')}\tDENY\t{host}:{port}\n") LOG.flush() client_w.write(b"HTTP/1.1 403 Forbidden\r\n\r\n") await client_w.drain(); client_w.close(); return try: up_r, up_w = await asyncio.open_connection(host, int(port)) except Exception: client_w.write(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") await client_w.drain(); client_w.close(); return client_w.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") await client_w.drain() await asyncio.gather(pipe(client_r, up_w), pipe(up_r, client_w))async def main(): server = await asyncio.start_server(handle, "127.0.0.1", 8899) print("egress gate on 127.0.0.1:8899 / allow:", ALLOW) async with server: await server.serve_forever()asyncio.run(main())
On the agent side, you just route through the gate with environment variables. Pass these to the Antigravity task/terminal config, or to the shell the agent launches.
export HTTPS_PROXY="http://127.0.0.1:8899"export https_proxy="http://127.0.0.1:8899"# for tools that speak plain HTTP (keep this minimal; prefer 443)export HTTP_PROXY="http://127.0.0.1:8899"
Because CONNECT is the request to open an HTTPS tunnel, the gate decides on the hostname alone. Once it lets a connection through, it simply relays both directions and never inspects the contents. That is what "don't decrypt" means in practice.
Growing a per-project allowlist from empty
My habit is to not write a big allowlist up front. Run one night nearly empty, read the denials in egress_denied.log the next morning, and add back only the legitimate ones, one line at a time. Starting from the denial log is what makes deny-by-default pay off.
# egress_allowlist.txt — one allowed host per line# code fetch / packagesgithub.com*.githubusercontent.comregistry.npmjs.org# the generative API I usegenerativelanguage.googleapis.com# my own app-ops API (spelled out precisely because it holds a key)androidpublisher.googleapis.com
The procedure folds into four steps.
Step
Action
Why
1
Allow only code-fetch hosts, run one night
Learn the minimal happy-path set
2
Next morning, judge each DENY one by one
Separate legitimate reach from the unexpected
3
Append only the legitimate ones
Widen access exactly as far as needed
4
Leave the unexpected denied and keep watching
Stay able to notice reach that grows
The discipline that matters is resisting the urge to "just add it." Left denied, a host shows up again the next morning. One host appeared three days running before I finally understood it (a tool's version check) and added it deliberately.
What 21 nights taught me
I ran the gate across 21 nights of automation around my four apps. Against the total connection attempts, 41 were denied, spread over 9 unique hosts. The breakdown says more about the policy than the raw count does.
Category of denied host
Unique count
My call
Legitimate hosts I'd forgotten (CDN, mirror)
3
Added to allowlist
Telemetry / analytics bundled in dependencies
4
Left denied (no functional impact)
Tool version / update checks
2
Added one, left one denied
The numbers look modest, but the payoff for me was being able to say, with evidence, that across those 41 denials there was zero sign of a payment or ad key reaching an unexpected host. Before, it was "probably fine." Now I just read egress_denied.log. The three forgotten hosts did stall a legitimate step temporarily, but a five-minute edit the next morning cleared each one, and the unattended run itself never fell over.
Where this approach can't protect you
Let me draw the line honestly. It filters at host granularity only, so it can't distinguish an allowed path from a forbidden path within the same host. That's the cost of not decrypting TLS.
It also won't catch tools that ignore the proxy variables and dial an IP directly, or binaries that open their own sockets. For the processes I most want to contain — the heavy ones holding keys — I layer a second measure: isolate them in a separate network namespace, or drop all outbound except port 8899 at the host firewall. Restricting DNS to allowlisted hosts helps too. But for indie-scale work, just making the reach visible with this gate already changed the ground my judgment stands on.
Wrapping up
When we delegate real work to an agent, we tend to squint at what it can change. But for something you've handed a key to, the quieter and less reversible permission is where it can reach.
Your next step can be small. On one project, cut the allowlist down to a few code-fetch hosts and slip the gate in front for a single night. The names that line up in egress_denied.log the next morning will probably tell you, more honestly than anything else, what was actually leaving your machine.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.