docs: add research note on container network egress guards
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,799 @@
|
|||||||
|
# Network egress guard for claude-bottle containers
|
||||||
|
|
||||||
|
Research into preventing data exfiltration from Docker containers running
|
||||||
|
Claude Code (`--dangerously-skip-permissions`), with a focus on approaches
|
||||||
|
that work on both macOS Docker Desktop and Linux Docker Engine.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- The container currently has unrestricted internet egress. This is the
|
||||||
|
primary remaining threat surface after the SSH key and credential isolation
|
||||||
|
work done in earlier PRDs.
|
||||||
|
- The canonical reference implementation is Anthropic's own
|
||||||
|
`.devcontainer/init-firewall.sh`, which uses **iptables + ipset inside
|
||||||
|
the container** to build a default-deny allowlist resolved at launch time.
|
||||||
|
Both `RchGrav/claudebox` and the `IVIJL/devbox` fork adapt the same
|
||||||
|
pattern, the latter adding a dnsmasq layer.
|
||||||
|
- An in-container iptables approach works on macOS Docker Desktop and on
|
||||||
|
Linux Engine but requires `NET_ADMIN` and `NET_RAW` capabilities, which
|
||||||
|
expand the container's privilege surface by a well-understood, bounded
|
||||||
|
amount.
|
||||||
|
- A proxy sidecar (HTTP CONNECT, no TLS termination) is the cleanest
|
||||||
|
architecture for a low-trust threat model: the agent process cannot
|
||||||
|
reconfigure the proxy, and the proxy enforces domain-name semantics
|
||||||
|
rather than IP addresses that rotate. Stripe's
|
||||||
|
[`smokescreen`](https://github.com/stripe/smokescreen) is the most
|
||||||
|
security-hardened option in this space.
|
||||||
|
- DNS exfiltration (encoding data into query subdomains) is real and is not
|
||||||
|
stopped by IP-based allowlists alone. A controlled resolver (dnsmasq or
|
||||||
|
CoreDNS) that answers NXDOMAIN for non-allowlisted names is the
|
||||||
|
complementary control.
|
||||||
|
- eBPF/Cilium and host-side iptables are impractical on macOS Docker Desktop
|
||||||
|
and should not be pursued for v1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The agent runs as the `node` user inside the container with
|
||||||
|
`--dangerously-skip-permissions`. It can execute arbitrary shell commands,
|
||||||
|
read any file the user mounts into the container, and make arbitrary network
|
||||||
|
connections. The host system already isolates it through the container
|
||||||
|
boundary. The specific threats we are closing in this work are:
|
||||||
|
|
||||||
|
1. **HTTP(S) data exfiltration** — agent POST-ing sensitive files or
|
||||||
|
environment variables to an attacker-controlled endpoint on the public
|
||||||
|
internet.
|
||||||
|
2. **DNS tunnel exfiltration** — encoding data into subdomains of an
|
||||||
|
attacker-controlled zone; the data escapes via DNS queries even when
|
||||||
|
outbound TCP/UDP is blocked.
|
||||||
|
3. **Lateral movement to host services** — agent reaching RFC 1918 addresses
|
||||||
|
(host metadata service at 169.254.169.254, host Docker socket via bridge
|
||||||
|
IP, LAN hosts). The Docker bridge IP is typically `172.17.0.1` and is
|
||||||
|
reachable by default.
|
||||||
|
4. **Arbitrary outbound TCP** — the agent opening sockets to unexpected
|
||||||
|
hosts; e.g., a package dependency phoning home to an attacker's C2.
|
||||||
|
|
||||||
|
What we are *not* defending against in this work:
|
||||||
|
|
||||||
|
- Kernel-level container escape (a separate, much harder problem).
|
||||||
|
- A compromised SSH remote host leaking data indirectly through a
|
||||||
|
permitted SSH session (the per-agent allowlist covers this by design).
|
||||||
|
- Supply-chain attacks in the container image itself (defend via pinned
|
||||||
|
digests and image scanning, not egress rules).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 1: `--network none`
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
`docker run --network none` removes the container from all networks except
|
||||||
|
the loopback interface. No default bridge, no host route, no DNS resolver.
|
||||||
|
|
||||||
|
### Egress decisions expressible
|
||||||
|
|
||||||
|
Binary: either the container has no network or it has full network. There is
|
||||||
|
no middle ground without additional plumbing (e.g., connecting the container
|
||||||
|
to a second, controlled network after creation).
|
||||||
|
|
||||||
|
### DNS handling
|
||||||
|
|
||||||
|
With `--network none`, Docker does not inject a DNS stub resolver. The
|
||||||
|
container cannot resolve any hostnames.
|
||||||
|
|
||||||
|
### macOS Docker Desktop compatibility
|
||||||
|
|
||||||
|
Fully compatible; `--network none` is a namespace operation inside the Linux
|
||||||
|
VM and works identically on both platforms.
|
||||||
|
|
||||||
|
### Implementation effort
|
||||||
|
|
||||||
|
Trivial to add `--network none` to `docker run`. Making Claude Code
|
||||||
|
functional again requires either:
|
||||||
|
|
||||||
|
- Connecting the container to a second, tightly controlled Docker network
|
||||||
|
that has a dedicated DNS resolver and outbound proxy, or
|
||||||
|
- Using `docker network connect` after launch to attach a controlled bridge.
|
||||||
|
|
||||||
|
### Failure modes / bypasses
|
||||||
|
|
||||||
|
Complete loss of connectivity until reconnected. Claude Code will fail at
|
||||||
|
startup trying to reach `api.anthropic.com`. This option is not viable as a
|
||||||
|
standalone solution; it is only the starting point for an allowlist
|
||||||
|
architecture where the container gets precisely the network it needs.
|
||||||
|
|
||||||
|
### Protocol coverage
|
||||||
|
|
||||||
|
Blocks all IP (TCP, UDP, ICMP) by removing the interface. Also blocks DNS
|
||||||
|
exfiltration — there is no resolver to send queries to.
|
||||||
|
|
||||||
|
### Agent bypass surface
|
||||||
|
|
||||||
|
An agent with shell access cannot add a network interface (no `CAP_NET_ADMIN`
|
||||||
|
without explicit grant). Hard to bypass.
|
||||||
|
|
||||||
|
**Verdict**: impractical standalone; useful as a foundation for a proxy-only
|
||||||
|
network architecture where the container's only route is through a sidecar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 2: iptables inside the container
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
Run `iptables` and `ipset` inside the container at startup. Set default
|
||||||
|
OUTPUT policy to DROP, then selectively ACCEPT outbound traffic to a set of
|
||||||
|
resolved IP addresses. This is the approach taken by Anthropic's own
|
||||||
|
`.devcontainer/init-firewall.sh` ([source](https://github.com/anthropics/claude-code/blob/main/.devcontainer/init-firewall.sh))
|
||||||
|
and adopted verbatim (or with minor modifications) by
|
||||||
|
[`centminmod/claude-code-devcontainers`](https://github.com/centminmod/claude-code-devcontainers/blob/master/.devcontainer/init-firewall.sh)
|
||||||
|
and [`IVIJL/devbox`](https://github.com/IVIJL/devbox).
|
||||||
|
|
||||||
|
The script at container startup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# flush existing rules
|
||||||
|
iptables -F && iptables -X
|
||||||
|
iptables -t nat -F && iptables -t nat -X
|
||||||
|
iptables -t mangle -F && iptables -t mangle -X
|
||||||
|
ipset destroy allowed-domains 2>/dev/null || true
|
||||||
|
|
||||||
|
# default deny
|
||||||
|
iptables -P INPUT DROP
|
||||||
|
iptables -P FORWARD DROP
|
||||||
|
iptables -P OUTPUT DROP
|
||||||
|
|
||||||
|
# unconditional allows
|
||||||
|
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT # DNS
|
||||||
|
iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT # SSH
|
||||||
|
iptables -A INPUT -i lo -j ACCEPT
|
||||||
|
iptables -A OUTPUT -o lo -j ACCEPT
|
||||||
|
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||||
|
|
||||||
|
# allowlisted domains
|
||||||
|
ipset create allowed-domains hash:net
|
||||||
|
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
|
||||||
|
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||||
|
|
||||||
|
# populate ipset by DNS resolution at launch
|
||||||
|
for domain in \
|
||||||
|
"api.anthropic.com" \
|
||||||
|
"statsig.anthropic.com" \
|
||||||
|
"sentry.io" \
|
||||||
|
"registry.npmjs.org" \
|
||||||
|
# ... per-agent additions
|
||||||
|
; do
|
||||||
|
while read -r ip; do
|
||||||
|
ipset add allowed-domains "$ip/32" 2>/dev/null || true
|
||||||
|
done < <(dig +noall +answer A "$domain" | awk '$4=="A"{print $5}')
|
||||||
|
done
|
||||||
|
|
||||||
|
# GitHub IP ranges (dynamic, fetched from GitHub meta API before lockdown)
|
||||||
|
curl -s https://api.github.com/meta | jq -r '.web[], .api[], .git[]' | \
|
||||||
|
while read -r cidr; do ipset add allowed-domains "$cidr" 2>/dev/null || true; done
|
||||||
|
```
|
||||||
|
|
||||||
|
Anthropic's own script also hardcodes their published CIDR (`160.79.104.0/23`)
|
||||||
|
to handle the case where `api.anthropic.com` resolves differently.
|
||||||
|
|
||||||
|
`RchGrav/claudebox` stores the per-project allowlist at
|
||||||
|
`~/.claudebox/<project-name>/firewall/allowlist` and exposes a
|
||||||
|
`claudebox allowlist` command to view and edit it. The underlying mechanics
|
||||||
|
are the same iptables + ipset pattern.
|
||||||
|
|
||||||
|
`IVIJL/devbox` adds dnsmasq as an in-container resolver. It seeds
|
||||||
|
`allowed-domains.conf` with the domain list, configures dnsmasq to respond
|
||||||
|
only to those names, and also builds the ipset from the same list. This
|
||||||
|
provides dual-layer protection: DNS-level blocking and IP-level blocking.
|
||||||
|
Its CLI (`devbox allow <domain>`, `devbox deny <domain>`) reloads dnsmasq and
|
||||||
|
updates ipset without restarting the container.
|
||||||
|
|
||||||
|
### Capabilities required
|
||||||
|
|
||||||
|
`NET_ADMIN` and `NET_RAW`. Without these, `iptables` and `ipset` commands
|
||||||
|
inside the container fail with `Operation not permitted`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --cap-add NET_ADMIN --cap-add NET_RAW ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`NET_ADMIN` is a meaningful privilege escalation: it allows the process to
|
||||||
|
modify network interfaces and routing tables, load kernel modules that affect
|
||||||
|
networking, and so on. However, the scope is limited to the container's
|
||||||
|
network namespace (not the host's), and this capability is already well
|
||||||
|
understood in the Docker security literature. The threat model here (misbehaving
|
||||||
|
LLM agent, not a kernel-exploit attempt) makes this a reasonable trade-off.
|
||||||
|
|
||||||
|
### Egress decisions expressible
|
||||||
|
|
||||||
|
IP address or CIDR. The allowlist is populated at launch by resolving domain
|
||||||
|
names to IPs; the IPs are what the firewall actually checks. CDNs and cloud
|
||||||
|
providers that serve many hostnames behind shared IP ranges can cause
|
||||||
|
over-permissioning (adding `api.github.com` CIDR also covers all of GitHub
|
||||||
|
Pages, for example).
|
||||||
|
|
||||||
|
### DNS handling
|
||||||
|
|
||||||
|
Outbound UDP port 53 is explicitly allowed, so the container can reach any
|
||||||
|
resolver. This means **DNS exfiltration is not prevented by this approach
|
||||||
|
alone**. The IVIJL/devbox variant closes this gap by routing DNS to an
|
||||||
|
in-container dnsmasq instance that only answers for allowlisted names; queries
|
||||||
|
for non-allowlisted names return NXDOMAIN, and the default-deny iptables rule
|
||||||
|
prevents the agent from reaching a third-party resolver directly.
|
||||||
|
|
||||||
|
### macOS Docker Desktop compatibility
|
||||||
|
|
||||||
|
The container iptables approach works on Docker Desktop for macOS. The
|
||||||
|
iptables rules run inside the Linux VM's container namespace, not on the macOS
|
||||||
|
host kernel. This is distinct from approach 2b below (host-side iptables),
|
||||||
|
which does not work on macOS Desktop.
|
||||||
|
|
||||||
|
### Implementation effort
|
||||||
|
|
||||||
|
Moderate. The script itself is well-understood and can be lifted nearly
|
||||||
|
verbatim from Anthropic's devcontainer repo. The integration points in
|
||||||
|
`cli.sh` are:
|
||||||
|
|
||||||
|
1. Pass `--cap-add NET_ADMIN --cap-add NET_RAW` in the `docker run` invocation.
|
||||||
|
2. `docker cp` an `init-firewall.sh` script into the container (alongside
|
||||||
|
the per-agent allowlist entries derived from the manifest's `ssh` array and
|
||||||
|
any agent-level `allowlist` key).
|
||||||
|
3. Run the script via `docker exec` before handing off to claude.
|
||||||
|
|
||||||
|
### Failure modes
|
||||||
|
|
||||||
|
- **Rotating IPs**: DNS-resolved IPs are captured once at container startup.
|
||||||
|
If the upstream service rotates IPs (common for CDNs), connections begin
|
||||||
|
failing silently. Periodic re-resolution (a cron inside the container) or
|
||||||
|
using CIDR ranges instead of individual IPs mitigates this.
|
||||||
|
- **Duplicate IPs from DNS**: Domains served by large CDNs return many
|
||||||
|
A records; some resolve to IPs already in the set from a different domain.
|
||||||
|
Use `ipset add --exist` to suppress the error (this is the fix for
|
||||||
|
[GitHub issue #15611](https://github.com/anthropics/claude-code/issues/15611)
|
||||||
|
and [#35197](https://github.com/anthropics/claude-code/issues/35197)).
|
||||||
|
- **Agent reconfigures iptables**: If the agent can run `sudo iptables -F`
|
||||||
|
it can tear down the firewall. Mitigation: the `node` user should not have
|
||||||
|
passwordless sudo for iptables after the init script completes. The init
|
||||||
|
script runs as root in an entrypoint; the main claude process runs as `node`.
|
||||||
|
Do not grant `node` user NET_ADMIN or sudo access to iptables commands.
|
||||||
|
- **DNS exfil** (as noted above): mitigated by adding dnsmasq.
|
||||||
|
|
||||||
|
### 2b: iptables on the Docker host (DOCKER-USER chain)
|
||||||
|
|
||||||
|
On Linux Docker Engine, the host kernel runs iptables rules in a `DOCKER-USER`
|
||||||
|
chain that is evaluated before Docker's own FORWARD rules. This allows
|
||||||
|
host-level per-container egress control without giving the container any
|
||||||
|
elevated capabilities.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the Linux host: block all forwarded traffic from a specific container subnet
|
||||||
|
iptables -I DOCKER-USER -s 172.28.0.0/24 -j DROP
|
||||||
|
# Then add exceptions per IP
|
||||||
|
iptables -I DOCKER-USER -s 172.28.0.0/24 -d 160.79.104.0/23 -j RETURN
|
||||||
|
```
|
||||||
|
|
||||||
|
On **macOS Docker Desktop**, this is not available from the macOS host.
|
||||||
|
Docker Desktop runs inside a Linux VM (LinuxKit). The macOS host has no
|
||||||
|
`iptables` binary, and there is no supported mechanism to inject rules into
|
||||||
|
the LinuxKit VM's iptables from the outside. Multiple Docker Desktop
|
||||||
|
GitHub issues (e.g., [#2489](https://github.com/docker/for-mac/issues/2489),
|
||||||
|
[#5547](https://github.com/docker/for-mac/issues/5547)) document that host
|
||||||
|
network namespace manipulation does not work as expected on macOS Desktop.
|
||||||
|
|
||||||
|
**Verdict for 2b**: Linux-only. Not viable for this project which must support
|
||||||
|
macOS Docker Desktop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 3: HTTP(S) egress proxy sidecar
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
A second container (or a process in the same container) runs an HTTP forward
|
||||||
|
proxy. The agent container is configured with `HTTPS_PROXY` and `HTTP_PROXY`
|
||||||
|
pointing at the proxy. The proxy enforces a hostname-based allowlist: it
|
||||||
|
passes CONNECT requests for allowed hostnames and rejects all others. No TLS
|
||||||
|
termination is required — the proxy sees only the hostname from the CONNECT
|
||||||
|
header, not the decrypted payload.
|
||||||
|
|
||||||
|
Claude Code **fully supports proxy configuration** via standard environment
|
||||||
|
variables. From the [official network configuration docs](https://code.claude.com/docs/en/network-config):
|
||||||
|
|
||||||
|
> Claude Code respects standard proxy environment variables:
|
||||||
|
> `https_proxy` > `HTTPS_PROXY` > `http_proxy` > `HTTP_PROXY`
|
||||||
|
|
||||||
|
`NODE_EXTRA_CA_CERTS` is also supported, enabling trust for a custom CA if
|
||||||
|
TLS inspection is desired. Note: there are documented bugs where
|
||||||
|
`NODE_EXTRA_CA_CERTS` set via `settings.json` does not always take effect;
|
||||||
|
setting it as a real environment variable (passed via `docker run -e`) works
|
||||||
|
reliably ([issue #10458](https://github.com/anthropics/claude-code/issues/10458)).
|
||||||
|
|
||||||
|
Claude Code does **not** support SOCKS proxies (confirmed in the docs).
|
||||||
|
|
||||||
|
### Stripe smokescreen
|
||||||
|
|
||||||
|
[`stripe/smokescreen`](https://github.com/stripe/smokescreen) is a
|
||||||
|
purpose-built HTTP CONNECT proxy designed specifically for outbound egress
|
||||||
|
control. Key properties:
|
||||||
|
|
||||||
|
- Enforces a YAML-format hostname ACL with `open`, `report`, and `enforce`
|
||||||
|
policies. Wildcard hostnames (`*.example.com`) are supported.
|
||||||
|
- Resolves all requested hostnames and verifies the resulting IPs are public
|
||||||
|
(not RFC 1918 / link-local / loopback). This blocks lateral movement to
|
||||||
|
`172.17.0.1` (Docker bridge), `169.254.169.254` (metadata service), and
|
||||||
|
private LAN hosts **by default** — without any additional configuration.
|
||||||
|
- Custom `--allow-range` / `--deny-range` CIDR flags for exceptions.
|
||||||
|
- Per-client ACL via mTLS client certificates; not required for the
|
||||||
|
single-agent use case.
|
||||||
|
- Written in Go; available as a Docker image
|
||||||
|
([`pretix/smokescreen`](https://hub.docker.com/r/pretix/smokescreen)).
|
||||||
|
|
||||||
|
Example ACL YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
default:
|
||||||
|
action: enforce
|
||||||
|
allowed_hosts:
|
||||||
|
- api.anthropic.com
|
||||||
|
- statsig.anthropic.com
|
||||||
|
- sentry.io
|
||||||
|
- registry.npmjs.org
|
||||||
|
- raw.githubusercontent.com
|
||||||
|
- "*.github.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
A minimal sidecar setup using Docker Compose (illustrative):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
proxy:
|
||||||
|
image: pretix/smokescreen:latest
|
||||||
|
command: ["--listen-ip", "0.0.0.0", "--listen-port", "4750", "--config-file", "/config/acl.yaml"]
|
||||||
|
volumes:
|
||||||
|
- ./smokescreen-acl.yaml:/config/acl.yaml:ro
|
||||||
|
networks:
|
||||||
|
- agent-net
|
||||||
|
|
||||||
|
claude-agent:
|
||||||
|
image: claude-bottle:latest
|
||||||
|
environment:
|
||||||
|
HTTPS_PROXY: "http://proxy:4750"
|
||||||
|
HTTP_PROXY: "http://proxy:4750"
|
||||||
|
NO_PROXY: "localhost,127.0.0.1"
|
||||||
|
networks:
|
||||||
|
- agent-net
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
```
|
||||||
|
|
||||||
|
In a bash-first project without Docker Compose, the equivalent is:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create isolated network
|
||||||
|
docker network create --internal agent-net-"$slug"
|
||||||
|
|
||||||
|
# launch proxy sidecar
|
||||||
|
docker run -d --name "$proxy_name" \
|
||||||
|
--network agent-net-"$slug" \
|
||||||
|
-v "$acl_file":/config/acl.yaml:ro \
|
||||||
|
pretix/smokescreen --config-file /config/acl.yaml
|
||||||
|
|
||||||
|
# launch agent, no cap-add needed
|
||||||
|
docker run -d --name "$container_name" \
|
||||||
|
--network agent-net-"$slug" \
|
||||||
|
-e HTTPS_PROXY=http://"$proxy_name":4750 \
|
||||||
|
-e HTTP_PROXY=http://"$proxy_name":4750 \
|
||||||
|
claude-bottle:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The `--internal` flag on the network prevents containers from reaching
|
||||||
|
outside the Docker network by default; the proxy container breaks out via
|
||||||
|
a second network attachment to the external bridge.
|
||||||
|
|
||||||
|
### tinyproxy
|
||||||
|
|
||||||
|
[`tinyproxy`](https://tinyproxy.github.io/) is a lighter HTTP proxy
|
||||||
|
(~50 KB) with a simpler allowlist model. It supports
|
||||||
|
[`Filter`](https://tinyproxy.github.io/#filter) directives using POSIX
|
||||||
|
extended regular expressions matched against the CONNECT hostname. It does
|
||||||
|
not resolve DNS or check RFC 1918 ranges — lateral movement to internal IPs
|
||||||
|
must be blocked separately. For a low-threat-model use case tinyproxy is
|
||||||
|
adequate; for this project's threat model, smokescreen's automatic RFC 1918
|
||||||
|
rejection is worth the additional image size.
|
||||||
|
|
||||||
|
### Egress decisions expressible
|
||||||
|
|
||||||
|
Hostname from the CONNECT header (i.e., the domain name the client claims to
|
||||||
|
be connecting to). Because TLS is not terminated, the proxy cannot verify that
|
||||||
|
the SNI in the TLS ClientHello matches the CONNECT header. This enables
|
||||||
|
**domain fronting**: an agent that knows the proxy ACL could send
|
||||||
|
`CONNECT allowed-host.com:443` but embed a different Host header inside the
|
||||||
|
TLS session, reaching a different backend on a CDN that routes by SNI. This is
|
||||||
|
the same limitation acknowledged in Claude Code's own sandboxing docs
|
||||||
|
([security limitations](https://code.claude.com/docs/en/sandboxing#security-limitations)).
|
||||||
|
It is a real bypass for sophisticated actors but unlikely to be the first
|
||||||
|
thing a misbehaving LLM agent attempts.
|
||||||
|
|
||||||
|
### DNS handling
|
||||||
|
|
||||||
|
The proxy resolves hostnames itself before forwarding. The agent container
|
||||||
|
still needs a working DNS resolver to resolve `proxy` by name (or the proxy
|
||||||
|
can be addressed by IP / Docker network alias). To prevent DNS exfiltration,
|
||||||
|
the container's resolver should be restricted (see Approach 4). DNS queries
|
||||||
|
to the real internet do not exfiltrate data through a proxy; they exfiltrate
|
||||||
|
through the resolver.
|
||||||
|
|
||||||
|
### macOS Docker Desktop compatibility
|
||||||
|
|
||||||
|
Fully compatible. Docker bridge networks work identically on both platforms.
|
||||||
|
The proxy sidecar is itself a standard container with no special capabilities.
|
||||||
|
|
||||||
|
### Implementation effort
|
||||||
|
|
||||||
|
Moderate-to-high. New infrastructure (proxy sidecar container, ACL file
|
||||||
|
format, per-agent ACL generation from the manifest). The manifest already has
|
||||||
|
an `ssh` array that lists target SSH hosts; these would seed the agent's ACL
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
### Protocol coverage
|
||||||
|
|
||||||
|
The proxy only covers HTTP and HTTP CONNECT (HTTPS). It does not cover:
|
||||||
|
- Raw TCP (e.g., direct database connections, redis, custom protocols).
|
||||||
|
- UDP.
|
||||||
|
- ICMP.
|
||||||
|
- DNS queries.
|
||||||
|
|
||||||
|
For raw TCP non-HTTP traffic, combine with iptables approach or `--network
|
||||||
|
none` + proxy with SOCKS support. (Note: Claude Code itself doesn't support
|
||||||
|
SOCKS as a client proxy.)
|
||||||
|
|
||||||
|
### Agent bypass
|
||||||
|
|
||||||
|
If the agent can reach the Docker socket or can enumerate Docker networks, it
|
||||||
|
may be able to attach to a network that bypasses the proxy. Mitigation: do not
|
||||||
|
mount the Docker socket into the agent container and use `--internal` Docker
|
||||||
|
networks. With no Docker socket and no host-network access, a `node`-user
|
||||||
|
agent cannot reconfigure network routing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 4: DNS-based egress control
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
Configure the container to use a controlled DNS resolver (e.g., dnsmasq,
|
||||||
|
CoreDNS, or unbound) that only responds with valid answers for allowlisted
|
||||||
|
hostnames. All other queries receive NXDOMAIN. Pair this with an IP-based
|
||||||
|
default-deny firewall so the agent cannot bypass DNS by hardcoding IP addresses.
|
||||||
|
|
||||||
|
dnsmasq example (`/etc/dnsmasq.conf` for the resolver container):
|
||||||
|
|
||||||
|
```conf
|
||||||
|
# deny all by default
|
||||||
|
address=/#/0.0.0.0
|
||||||
|
|
||||||
|
# allowlist
|
||||||
|
server=/api.anthropic.com/8.8.8.8
|
||||||
|
server=/statsig.anthropic.com/8.8.8.8
|
||||||
|
server=/registry.npmjs.org/8.8.8.8
|
||||||
|
```
|
||||||
|
|
||||||
|
Then in the agent container: `--dns <resolver-ip>` and iptables to block
|
||||||
|
outbound UDP port 53 to anything other than the resolver.
|
||||||
|
|
||||||
|
### DNS exfiltration
|
||||||
|
|
||||||
|
This approach does not prevent DNS exfiltration if the agent can send queries
|
||||||
|
to the allowlisted resolver with attacker-crafted subdomains. A resolver that
|
||||||
|
forwards queries for `api.anthropic.com` will also forward queries for
|
||||||
|
`data.api.anthropic.com` if the upstream (`8.8.8.8`) resolves them. Genuine
|
||||||
|
prevention of DNS exfiltration requires either:
|
||||||
|
|
||||||
|
1. A resolver that blocks queries with subdomains deeper than expected for
|
||||||
|
each allowlisted domain (fragile and maintenance-heavy), or
|
||||||
|
2. A custom authoritative resolver for allowlisted names only, with no
|
||||||
|
forwarding for unknown subdomains. dnsmasq with `local=/anthropic.com/`
|
||||||
|
(authoritative, no recursion) can approximate this but breaks legitimate
|
||||||
|
multi-level lookups.
|
||||||
|
|
||||||
|
In practice, DNS exfiltration through an allowlisted domain requires the
|
||||||
|
attacker to control DNS for that domain, which is a significant bar. It is a
|
||||||
|
real threat for high-value targets but is secondary to the baseline of
|
||||||
|
blocking arbitrary HTTP(S) egress.
|
||||||
|
|
||||||
|
### macOS Docker Desktop compatibility
|
||||||
|
|
||||||
|
A dnsmasq or CoreDNS container running on the same Docker network is fully
|
||||||
|
compatible with macOS Docker Desktop. The `--dns` flag on `docker run` works
|
||||||
|
on both platforms.
|
||||||
|
|
||||||
|
### Implementation effort
|
||||||
|
|
||||||
|
Low-to-moderate as a complement to approach 2 or 3, not as a standalone
|
||||||
|
control. The IVIJL/devbox project demonstrates the full stack (iptables +
|
||||||
|
ipset + dnsmasq) in bash.
|
||||||
|
|
||||||
|
### Protocol coverage
|
||||||
|
|
||||||
|
Blocks name resolution for non-allowlisted domains. Does not directly block
|
||||||
|
IP-addressed connections; must be paired with IP-level controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 5: eBPF / Cilium
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
Cilium uses eBPF programs attached to network interfaces inside the kernel to
|
||||||
|
enforce network policy at L3–L7, including DNS-aware hostname policies. It is
|
||||||
|
the most powerful option architecturally.
|
||||||
|
|
||||||
|
### macOS Docker Desktop compatibility
|
||||||
|
|
||||||
|
Not practical. eBPF requires BTF (BPF Type Format) kernel support. Docker
|
||||||
|
Desktop's LinuxKit VM on macOS does not ship kernel headers and has limited
|
||||||
|
BTF support; recent versions (Docker Desktop 4.x with kernel 6.4+) have basic
|
||||||
|
BTF but Cilium requires specific kernel configuration options that LinuxKit
|
||||||
|
does not set ([Docker for Mac issue #6800](https://github.com/docker/for-mac/issues/6800)).
|
||||||
|
Cilium's own documentation requires a Kubernetes cluster; its standalone
|
||||||
|
Docker plugin (`cilium/docker-plugin`) is not maintained to parity.
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
|
||||||
|
Out of scope for this project. Revisit if the project ever targets a
|
||||||
|
Kubernetes-native deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 6: Userspace proxy embedded in the agent image
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
Ship a lightweight proxy binary (e.g., smokescreen or a small Go binary)
|
||||||
|
inside the agent image itself. The entrypoint starts the proxy on localhost,
|
||||||
|
then exports `HTTPS_PROXY=http://127.0.0.1:4750` before launching claude.
|
||||||
|
|
||||||
|
### Pros vs. sidecar
|
||||||
|
|
||||||
|
- Single container; simpler orchestration.
|
||||||
|
- No Docker network management.
|
||||||
|
|
||||||
|
### Cons vs. sidecar
|
||||||
|
|
||||||
|
- The proxy runs as a process inside the same container. The agent (running
|
||||||
|
as `node`) could potentially kill the proxy process or modify its config
|
||||||
|
if it has enough shell access. A sidecar in a separate container cannot
|
||||||
|
be killed by the agent regardless of what the agent does.
|
||||||
|
- Harder to update the ACL independently of the agent image.
|
||||||
|
- The proxy binary adds size to the agent image.
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
|
||||||
|
Acceptable for a first iteration where simplicity matters more than strict
|
||||||
|
isolation of the enforcement point. A sidecar is preferred once the
|
||||||
|
architecture stabilizes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Approach 7: mitmproxy in transparent mode (L7 inspection)
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
mitmproxy in transparent mode intercepts TLS by terminating the connection,
|
||||||
|
re-encrypting with a CA it controls, and forwarding. This enables full URL
|
||||||
|
inspection (not just the CONNECT hostname). The agent must trust the proxy's
|
||||||
|
CA, injected via `NODE_EXTRA_CA_CERTS`.
|
||||||
|
|
||||||
|
For this to work:
|
||||||
|
|
||||||
|
1. The agent image must have the mitmproxy CA cert installed and
|
||||||
|
`NODE_EXTRA_CA_CERTS` set.
|
||||||
|
2. iptables inside the container (or on the Docker network) must redirect all
|
||||||
|
port 443 traffic to the proxy via NAT.
|
||||||
|
3. mitmproxy runs an addon script (`addons.py`) that checks the request URL
|
||||||
|
against the allowlist and returns a 403 for blocked URLs.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mitmproxy addon
|
||||||
|
class AllowlistAddon:
|
||||||
|
ALLOWED = {"api.anthropic.com", "registry.npmjs.org"}
|
||||||
|
def request(self, flow):
|
||||||
|
if flow.request.host not in self.ALLOWED:
|
||||||
|
flow.response = mitmproxy.http.Response.make(
|
||||||
|
403, b"Blocked by egress policy")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Code TLS handling
|
||||||
|
|
||||||
|
Claude Code uses Node.js and respects `NODE_EXTRA_CA_CERTS` when set as an
|
||||||
|
environment variable (not via `settings.json`, which has a documented bug).
|
||||||
|
The `CLAUDE_CODE_CERT_STORE` variable controls whether it uses the bundled
|
||||||
|
Mozilla CA set and/or the OS trust store. Setting `CLAUDE_CODE_CERT_STORE=system`
|
||||||
|
and injecting the mitmproxy CA into the container's system trust store is the
|
||||||
|
most reliable path.
|
||||||
|
|
||||||
|
There is a separate known issue with the Bun-native build of Claude Code
|
||||||
|
(`@anthropic-ai/claude-code` 1.x uses a native Bun binary on some platforms)
|
||||||
|
where Bun does not read macOS system certificates. Inside a Linux container
|
||||||
|
this is not relevant — the binary uses the Linux certificate store.
|
||||||
|
|
||||||
|
### Concerns
|
||||||
|
|
||||||
|
- **CA trust injection is a meaningful security assumption**: if the CA key
|
||||||
|
is ever leaked, all HTTPS from the container is transparent to the attacker.
|
||||||
|
The CA key should be generated fresh per-agent-launch and discarded after.
|
||||||
|
- **Transparent mode requires NAT**, which requires `NET_ADMIN` inside the
|
||||||
|
container (same capability requirement as approach 2).
|
||||||
|
- **Operational complexity** is significantly higher than approaches 2 or 3.
|
||||||
|
- **Domain fronting** is defeated by TLS inspection (the proxy can compare
|
||||||
|
the SNI with the HTTP Host header), but this is overkill for the current
|
||||||
|
threat model.
|
||||||
|
|
||||||
|
### Verdict
|
||||||
|
|
||||||
|
Justified only if the threat model includes sophisticated actors deliberately
|
||||||
|
crafting domain-fronting payloads. The extra complexity and CA-trust-management
|
||||||
|
overhead is not worth it for v1. Keep in view for v2 if the claude-bottle use
|
||||||
|
case expands to high-value agent deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison table
|
||||||
|
|
||||||
|
| Approach | Granularity | DNS-safe | macOS-friendly | Impl. effort | Bypass surface |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1. `--network none` + re-add | On/Off (network-level) | Yes (no resolver) | Yes | Low (standalone); High (functional) | Very low |
|
||||||
|
| 2a. iptables in container | IP / CIDR | No (unless + dnsmasq) | Yes | Low–Medium | Needs `NET_ADMIN`; agent can't undo without sudo |
|
||||||
|
| 2b. iptables on host (DOCKER-USER) | IP / CIDR | No | **No** | Medium | macOS VM boundary |
|
||||||
|
| 3. HTTP proxy sidecar (smokescreen) | Hostname (CONNECT) | Partial (no DNS exfil guard) | Yes | Medium | Domain fronting; agent can't kill sidecar |
|
||||||
|
| 4. DNS resolver (dnsmasq/CoreDNS) | DNS name only | Partial (no IP bypass guard) | Yes | Low (complement) | Hardcoded IPs bypass DNS; DNS sub-exfil |
|
||||||
|
| 5. eBPF / Cilium | L3–L7 | Yes | **No** | Very High | None in practice |
|
||||||
|
| 6. Proxy embedded in image | Hostname (CONNECT) | Partial | Yes | Medium | Agent can kill proxy process |
|
||||||
|
| 7. mitmproxy transparent | Full URL | Yes | Yes (complex NAT) | High | CA key leakage; NAT escape |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anthropic's official guidance
|
||||||
|
|
||||||
|
Anthropic maintains an [`init-firewall.sh`](https://github.com/anthropics/claude-code/blob/main/.devcontainer/init-firewall.sh)
|
||||||
|
in the `claude-code` devcontainer that implements approach 2a. The
|
||||||
|
[official sandboxing documentation](https://code.claude.com/docs/en/sandboxing)
|
||||||
|
describes the `@anthropic-ai/sandbox-runtime` package which uses a
|
||||||
|
host-side HTTP + SOCKS5 proxy with OS-level namespace isolation (bubblewrap
|
||||||
|
on Linux, Seatbelt on macOS) — effectively approach 3 implemented at the
|
||||||
|
OS level rather than the Docker level.
|
||||||
|
|
||||||
|
The required runtime hostnames from the [network config docs](https://code.claude.com/docs/en/network-config)
|
||||||
|
are:
|
||||||
|
|
||||||
|
| Hostname | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `api.anthropic.com` | Claude API requests |
|
||||||
|
| `claude.ai` | claude.ai account auth |
|
||||||
|
| `platform.claude.com` | Anthropic Console auth |
|
||||||
|
| `downloads.claude.ai` | Plugin / native installer downloads |
|
||||||
|
| `raw.githubusercontent.com` | Changelog feed |
|
||||||
|
| `statsig.anthropic.com` | Telemetry (can be disabled) |
|
||||||
|
| `sentry.io` | Error telemetry (can be disabled) |
|
||||||
|
|
||||||
|
Telemetry can be disabled via environment variables; doing so shrinks the
|
||||||
|
allowlist and reduces exfiltration surface at the cost of losing Anthropic's
|
||||||
|
error reporting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claudebox implementation (RchGrav)
|
||||||
|
|
||||||
|
`RchGrav/claudebox` stores per-project firewall configuration in
|
||||||
|
`~/.claudebox/<project-name>/firewall/allowlist` and exposes a
|
||||||
|
`claudebox allowlist` command to view and edit it. The underlying
|
||||||
|
implementation mirrors Anthropic's `init-firewall.sh` (iptables + ipset,
|
||||||
|
approach 2a). The script flushes iptables rules and rebuilds the ipset at
|
||||||
|
container startup by resolving the domains in the allowlist file. GitHub IP
|
||||||
|
ranges are fetched dynamically from `api.github.com/meta`. The README does
|
||||||
|
not expose the precise file format of `allowlist`; from the README content it
|
||||||
|
appears to be a newline-delimited list of hostnames.
|
||||||
|
|
||||||
|
A closer implementation reference is the devbox fork by IVIJL
|
||||||
|
([`IVIJL/devbox`](https://github.com/IVIJL/devbox)) which adds dnsmasq on
|
||||||
|
top of the same iptables + ipset skeleton and provides a live-update CLI
|
||||||
|
(`devbox allow`, `devbox deny`, `devbox blocked`) that reloads dnsmasq and
|
||||||
|
updates ipset without a container restart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
### Tier 1 (v1, implement first): in-container iptables + ipset + dnsmasq
|
||||||
|
|
||||||
|
Adopt approach 2a with the dnsmasq complement from IVIJL/devbox. This is the
|
||||||
|
pattern validated by Anthropic's own devcontainer, is bash-first, adds no new
|
||||||
|
runtime dependencies (iptables and ipset are standard in the base Debian/Ubuntu
|
||||||
|
image used by Claude Code; dnsmasq is a single `apt-get install`), and works
|
||||||
|
on both macOS Docker Desktop and Linux Docker Engine.
|
||||||
|
|
||||||
|
Key PRD scope for this work:
|
||||||
|
|
||||||
|
- Add `--cap-add NET_ADMIN --cap-add NET_RAW` to the `docker run` invocation
|
||||||
|
in `lib/` (container launch).
|
||||||
|
- Add `init-firewall.sh` as a templated script that the launch process
|
||||||
|
`docker cp`s into the container, parameterized with:
|
||||||
|
- The base allowlist (Anthropic API, npm, telemetry).
|
||||||
|
- Per-agent additions from a new optional `allowlist` key in the manifest
|
||||||
|
(newline-separated hostnames, same format as the `ssh` array logic).
|
||||||
|
- The SSH target hostnames from the existing `ssh` array (already in the
|
||||||
|
manifest), automatically seeded into the firewall allowlist.
|
||||||
|
- Run the script via `docker exec --user root` before handing off to claude.
|
||||||
|
- Add a dnsmasq configuration step: install dnsmasq in the Dockerfile, configure
|
||||||
|
it to serve only allowlisted names, and set `--dns 127.0.0.1` in `docker run`.
|
||||||
|
- Block outbound UDP port 53 to non-dnsmasq resolvers in the iptables rules.
|
||||||
|
- Document the `--cap-add` changes in `CLAUDE.md` under security.
|
||||||
|
|
||||||
|
### Tier 2 (v2, higher isolation): smokescreen sidecar
|
||||||
|
|
||||||
|
If the threat model requires that the agent cannot defeat the firewall by
|
||||||
|
exploiting the `NET_ADMIN` capability or by running `sudo iptables -F` (if
|
||||||
|
sudo is ever accidentally granted), replace or complement the in-container
|
||||||
|
approach with a smokescreen sidecar. The sidecar runs with no elevated
|
||||||
|
capabilities, lives in a separate container the agent cannot reach, and enforces
|
||||||
|
RFC 1918 blocking automatically.
|
||||||
|
|
||||||
|
Key PRD scope additions over Tier 1:
|
||||||
|
|
||||||
|
- New `proxy` section in the manifest (or auto-generated per-agent) describing
|
||||||
|
the smokescreen ACL.
|
||||||
|
- Launch smokescreen container on a `--internal` Docker network before the
|
||||||
|
agent container.
|
||||||
|
- Attach the agent container to the internal network only; smokescreen has a
|
||||||
|
second NIC on the external bridge.
|
||||||
|
- Inject `HTTPS_PROXY` and `HTTP_PROXY` into the agent container.
|
||||||
|
- Generate a per-launch smokescreen ACL YAML from the manifest's `allowlist`
|
||||||
|
and `ssh` entries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deferred / out of scope
|
||||||
|
|
||||||
|
- **Layer 7 TLS inspection (approach 7)**: CA management overhead and domain
|
||||||
|
fronting defence are out of scope for v1. Revisit if the project adds
|
||||||
|
multi-user or high-value deployment scenarios.
|
||||||
|
- **eBPF / Cilium (approach 5)**: requires Kubernetes and does not work on
|
||||||
|
macOS Docker Desktop without non-trivial kernel configuration.
|
||||||
|
- **Host-side iptables (approach 2b)**: Linux-only; excluded because the
|
||||||
|
project must work on macOS Docker Desktop.
|
||||||
|
- **SOCKS proxy**: Claude Code does not support SOCKS as a client proxy
|
||||||
|
(confirmed in [official docs](https://code.claude.com/docs/en/network-config)).
|
||||||
|
- **Full DNS exfiltration prevention**: preventing data encoding into DNS
|
||||||
|
sub-labels of an allowlisted domain requires authoritative DNS for those
|
||||||
|
domains and is not achievable in a general-purpose egress guard.
|
||||||
|
- **Container image signing / supply chain**: out of scope; use pinned digests
|
||||||
|
and image scanning separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Anthropic `init-firewall.sh` (devcontainer): <https://github.com/anthropics/claude-code/blob/main/.devcontainer/init-firewall.sh>
|
||||||
|
- Claude Code network configuration docs: <https://code.claude.com/docs/en/network-config>
|
||||||
|
- Claude Code sandboxing docs: <https://code.claude.com/docs/en/sandboxing>
|
||||||
|
- Anthropic blog — Claude Code sandboxing: <https://www.anthropic.com/engineering/claude-code-sandboxing>
|
||||||
|
- `anthropic-experimental/sandbox-runtime`: <https://github.com/anthropic-experimental/sandbox-runtime>
|
||||||
|
- `RchGrav/claudebox`: <https://github.com/RchGrav/claudebox>
|
||||||
|
- `IVIJL/devbox`: <https://github.com/IVIJL/devbox>
|
||||||
|
- `centminmod/claude-code-devcontainers` init-firewall: <https://github.com/centminmod/claude-code-devcontainers/blob/master/.devcontainer/init-firewall.sh>
|
||||||
|
- `stripe/smokescreen`: <https://github.com/stripe/smokescreen>
|
||||||
|
- `pretix/smokescreen` Docker image: <https://hub.docker.com/r/pretix/smokescreen>
|
||||||
|
- Fly.io smokescreen guide: <https://fly.io/docs/app-guides/smokescreen/>
|
||||||
|
- mitmproxy proxy modes: <https://docs.mitmproxy.org/stable/concepts/modes/>
|
||||||
|
- Claude Code issue — `NODE_EXTRA_CA_CERTS` in settings.json not effective: <https://github.com/anthropics/claude-code/issues/10458>
|
||||||
|
- Claude Code issue — init-firewall.sh duplicate IP failure: <https://github.com/anthropics/claude-code/issues/35197>
|
||||||
|
- Docker iptables / DOCKER-USER chain docs: <https://docs.docker.com/engine/network/firewall-iptables/>
|
||||||
|
- Docker for Mac iptables issue #2489: <https://github.com/docker/for-mac/issues/2489>
|
||||||
|
- Docker for Mac BTF / eBPF issue #6800: <https://github.com/docker/for-mac/issues/6800>
|
||||||
|
|
||||||
|
Research conducted 2026-05-07.
|
||||||
Reference in New Issue
Block a user