- Book: AI Agents Pocket Guide
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
A source file with injected instructions in it. A coding agent asked to look at that file. An API key sitting in a .env next to it. That was the whole setup behind CVE-2025-55284, written up in August 2025: the injected text got Claude Code to read the key and send it out as a subdomain in a DNS lookup triggered by ping, which, like nslookup, host and dig, was on the agent's auto-approve list at the time. No prompt appeared. Anthropic fixed it in v1.0.4, per the researcher's write-up.
No model bug was needed. The agent did what any shell can do. It had your secrets in reach, untrusted text in its context, and a way to talk to the outside world. Simon Willison calls that combination the lethal trifecta, and his advice is to never give one agent all three: "avoid that lethal trifecta combination entirely." The first two legs are the job description of a coding agent. The third you can take away.
The fix below: a dev container for the agent, a default-deny firewall that allows your package registry and your model API and nothing else, secrets mounted read-only outside the workspace, and a test that fails loudly if curl can reach a host it shouldn't.
What you are building
Four files in .devcontainer/, plus one settings file for the agent:
-
Dockerfile: the image, with the agent CLI,iptables,ipset, and a non-root user. -
devcontainer.json: grants the network capabilities, mounts secrets read-only, runs the firewall on every start. -
egress.sh: the default-deny firewall with a domain allowlist. -
egress-test.sh: proves the allowlist works and the agent can't flush it.
This is the same shape as Anthropic's own reference devcontainer, whose init-firewall.sh builds an ipset of allowed destinations, sets the default policy to DROP, and checks that example.com is unreachable before it exits. The version here is smaller, agent-agnostic, and closes a couple of doors the reference leaves open. The Claude Code devcontainer docs are blunt about why the firewall matters: run with --dangerously-skip-permissions and "dev containers do not prevent a malicious project from exfiltrating anything accessible inside the container."

One gate out: the registry and the model API. Everything else bounces off the wall.
The container
The image installs the firewall tools and the agent, and gives the node user permission to run exactly one root command: the firewall script.
FROM node:22-bookworm-slim RUN apt-get update \ && apt-get install -y --no-install-recommends \ iptables ipset conntrack curl ca-certificates \ sudo git \ && rm -rf /var/lib/apt/lists/* # Your agent CLI. Swap in Codex CLI, Gemini CLI, etc. RUN npm install -g @anthropic-ai/claude-code COPY egress.sh egress-test.sh /usr/local/bin/ RUN chmod 0755 /usr/local/bin/egress*.sh \ && echo 'node ALL=(root) NOPASSWD: /usr/local/bin/egress.sh' \ > /etc/sudoers.d/egress \ && chmod 0440 /etc/sudoers.d/egress USER node The scripts are owned by root and live outside the workspace, so the agent can run egress.sh (which re-applies the same rules and keeps egress shut while it does) but can't edit it or call iptables directly.
{ "name": "agent-sandbox", "build": { "dockerfile": "Dockerfile" }, "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"], "remoteUser": "node", "postStartCommand": "sudo /usr/local/bin/egress.sh", "mounts": [ "source=${localEnv:HOME}/.agent-env,target=/secrets,type=bind,ro" ], "containerEnv": { "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" } } NET_ADMIN and NET_RAW are what let root inside the container manage iptables; the reference container adds the same two. The non-root node user does not get them. CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC turns off Claude Code's optional telemetry hosts, per its network requirements, which keeps the allowlist short.
The secrets mount does two things. It keeps the real .env out of the bind-mounted workspace, so it can't be committed or picked up by a stray grep -r. And ro means nothing in the container can rewrite those files. It does not stop the agent from reading them. That is the network layer's job. It's a file and not containerEnv because, as Docker's secrets guide puts it, with keys in environment variables "you risk unintentional information exposure". Put dev-scoped, short-lived keys in that directory, never production ones, and create ~/.agent-env on the host first or the container won't start. Anthropic's docs say the same about ~/.ssh and cloud credential files: don't mount them. To hand the key to Claude Code, point its apiKeyHelper setting, which runs a shell script that returns an API key, at the file, for example cat /secrets/anthropic_key. It goes in the same settings file as the deny rules further down.
The firewall script
#!/usr/bin/env bash # Default-deny egress for the agent container. set -euo pipefail ALLOWED_DOMAINS=( registry.npmjs.org api.anthropic.com ) # Shut first, so nothing leaves while rules are rebuilt. iptables -P OUTPUT DROP iptables -F OUTPUT ip6tables -P OUTPUT DROP 2>/dev/null || true ip6tables -F OUTPUT 2>/dev/null || true ip6tables -A OUTPUT -o lo -j ACCEPT 2>/dev/null || true iptables -A OUTPUT -o lo -j ACCEPT iptables -A OUTPUT -m conntrack \ --ctstate ESTABLISHED,RELATED -j ACCEPT # DNS only to the resolvers the container was given. for ns in $(awk '/^nameserver/ {print $2}' /etc/resolv.conf); do case "$ns" in *:*) continue ;; esac iptables -A OUTPUT -p udp -d "$ns" --dport 53 -j ACCEPT iptables -A OUTPUT -p tcp -d "$ns" --dport 53 -j ACCEPT done ipset create -exist allowed hash:ip ipset flush allowed for d in "${ALLOWED_DOMAINS[@]}"; do ips=$(getent ahostsv4 "$d" | awk '{print $1}' | sort -u) [ -n "$ips" ] || { echo "cannot resolve $d" >&2; exit 1; } for ip in $ips; do ipset add -exist allowed "$ip" done done # HTTPS to the allowlist only. iptables -A OUTPUT -p tcp --dport 443 \ -m set --match-set allowed dst -j ACCEPT iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited # Forget flows opened before this run. conntrack -F The policy goes to DROP before anything else, so the script fails closed: if a domain doesn't resolve or a rule errors, set -e stops it with egress already shut, and re-running it never opens a window for new connections. The conntrack -F at the end matters because the ESTABLISHED rule would otherwise keep alive any connection opened before the run. HTTPS is allowed on port 443 only, so plain HTTP and odd ports to an allowed IP still bounce. And DNS goes only to the resolvers in /etc/resolv.conf, where the reference script allows UDP 53 and TCP 22 to any destination.
The REJECT rule exists so blocked calls fail in milliseconds instead of hanging until a timeout, which matters when an agent is retrying in a loop.
If you sign in with a claude.ai account instead of an API key, add claude.ai and platform.claude.com, which the network requirements table lists for authentication. For other agents, add their model API host and nothing broader.
The test that proves it
A firewall you haven't tested is a hope. This script runs as the agent user and exits non-zero on any surprise:
#!/usr/bin/env bash # Run as the agent user. Non-zero exit on any failure. set -u fail=0 reach() { curl -s -o /dev/null --max-time 5 "$1"; } expect_open() { if reach "$1"; then echo "ok $1" else echo "FAIL $1 should be reachable"; fail=1; fi } expect_shut() { if reach "$1"; then echo "FAIL $1 should be blocked"; fail=1 else echo "ok $1 blocked"; fi } expect_open https://registry.npmjs.org/ expect_open https://api.anthropic.com/ expect_shut https://example.com/ expect_shut https://webhook.site/ expect_shut http://registry.npmjs.org/ if sudo -n iptables -F OUTPUT 2>/dev/null; then echo "FAIL agent can rewrite the firewall"; fail=1 else echo "ok agent cannot rewrite the firewall" fi if getent hosts example.com >/dev/null; then echo "warn DNS still resolves arbitrary names" fi exit "$fail" curl without -f exits zero on any HTTP response, including a 404, so expect_open tests connectivity, not status codes. webhook.site is in the list because it's the kind of request catcher an injected prompt would name. The port-80 check on an allowed host proves the rule is scoped to 443.
Run it with the Dev Containers CLI, locally or in CI, so a change to the Dockerfile can't quietly drop the firewall:
npm install -g @devcontainers/cli devcontainer up --workspace-folder . devcontainer exec --workspace-folder . egress-test.sh Every line except warn should start with ok. The warn line is expected with the script as written: DNS to your resolver is still open, and that is a channel too.
What this does not block
DNS is still a channel. Look back at the CVE in the opening: the key left as a DNS query. Restricting DNS to your resolver doesn't stop that, because the resolver forwards lookups for <secret>.attacker.example to the attacker's nameserver on the agent's behalf. To close it, pin the allowlisted names in /etc/hosts and drop DNS entirely. Add this to egress.sh just above the conntrack -F line:
# Pin allowlisted names, then close DNS. grep -v 'egress-pin$' /etc/hosts > /tmp/hosts || true cat /tmp/hosts > /etc/hosts for d in "${ALLOWED_DOMAINS[@]}"; do ip=$(getent ahostsv4 "$d" | awk 'NR==1 {print $1}') echo "$ip $d # egress-pin" >> /etc/hosts ipset add -exist allowed "$ip" done for ns in $(awk '/^nameserver/ {print $2}' /etc/resolv.conf); do case "$ns" in *:*) continue ;; esac iptables -D OUTPUT -p udp -d "$ns" --dport 53 -j ACCEPT iptables -D OUTPUT -p tcp -d "$ns" --dport 53 -j ACCEPT done The first two lines drop pins from a previous run, so re-runs don't stack stale entries. The snippet writes through cat because Docker bind-mounts /etc/hosts, and it adds each pinned IP to the set in case the earlier loop resolved an old pin. The cost: when a CDN rotates an IP, requests fail closed until egress.sh runs again, which happens on every container start. One more catch: on a user-defined Docker network, which Compose sets up by default, lookups go to Docker's embedded resolver at 127.0.0.11 over loopback, which the lo rule allows. The warn line in the test tells you which case you're in.

A sealed box with one keyhole: DNS lookups carry data out unless you close that path too.
Allowed hosts are channels too. If you allowlist github.com and the container holds a token with push rights, a push to a public repo is an upload. The same goes for npm publish with a write token. Claude Code's sandbox docs warn that "allowing broad domains such as github.com can create paths for data exfiltration", and that a hostname-based proxy can potentially be bypassed with domain fronting. Keep write tokens out of the container.
The model API is an upload channel. api.anthropic.com accepts anyone's key, not only yours. Johann Rehberger showed an injected prompt making Claude upload user data to the Anthropic Files API with an attacker-supplied key, so the file landed in the attacker's account (SecurityWeek). That attack ran in Claude's own sandbox, but the shape carries over: an allowlisted host that takes arbitrary credentials is an exfiltration path.
An IP allowlist is not a hostname allowlist. ipset matches addresses. If your registry sits on a shared CDN edge, other sites served from that same IP are reachable too.
The workspace is still yours to lose. The project is bind-mounted, so the agent can delete or rewrite files and the change lands on your host, as the devcontainer docs note. That includes .devcontainer/egress.sh itself: an edit there takes effect on the next rebuild. Review diffs to .devcontainer/ like any other code. The sudoers line is a trust decision too: the agent can run egress.sh as root whenever it likes, so that script has to stay short, root-owned and safe to run at any moment.
Pair it with approval gates
The firewall decides where bytes can go. Approval gates decide which actions inside that boundary happen without a human. You want both, because each one fails where the other holds.
For Claude Code, a project .claude/settings.json like this blocks the obvious upload paths and puts a human in front of new dependencies. Claude Code's permission rules evaluate deny before allow, and // anchors a path at the filesystem root:
{ "apiKeyHelper": "cat /secrets/anthropic_key", "permissions": { "deny": [ "Bash(git push *)", "Bash(npm publish *)", "Read(//secrets/**)" ], "ask": [ "Bash(npm install *)" ] } } These rules are easy to route around. The same docs say a Bash(git push *) rule doesn't match git -C . push, and Read deny rules cover cat and head but not a Node or Python script that opens the file itself. The CVE exploit used strings and grep to get at the key. That's why the network layer carries the weight and the rules catch the clumsy cases.
Other agents ship pieces of this. OpenAI's Codex CLI docs state: "By default, the agent runs with network access turned off." You opt in with network_access = true under [sandbox_workspace_write]. Claude Code has a built-in Bash sandbox whose proxy allowlist starts empty. Docker Sandboxes moved to a dedicated microVM per agent, with network allow and deny lists, in a January 30, 2026 release.
The container in this post is the portable floor under all of them. It works for any CLI you drop into it, and its network boundary still applies when someone passes --dangerously-skip-permissions, which Anthropic's docs say to pair with exactly this kind of egress restriction.
Start with the allowlist and the test in CI. Close DNS when your agent runs unattended. Then add deny rules for every write token you can't keep out of the box.
If this was useful
The AI Agents Pocket Guide covers agent loops, tool calling and guardrails for agents; egress control is the network half of that conversation. If you're about to hand an agent a shell and walk away, it's a short read for the design review that should come first.

