18e3b62b72
Delete CLAUDE.md in favor of AGENTS.md as the orientation doc, rebrand the project from Codex-bottle to provider-agnostic bot-bottle, and repoint every CLAUDE.md reference across PRDs, research notes, the implementer agent example, and the yaml_subset comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
802 lines
34 KiB
Markdown
802 lines
34 KiB
Markdown
# Network egress guard for bot-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.py` 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: bot-bottle-claude: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 project without Docker Compose, the equivalent (shell or Python orchestrator
|
||
shelling out to `docker`) 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 \
|
||
bot-bottle-claude: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 bot-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, configures cleanly from
|
||
plain shell + standard system packages, 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 `AGENTS.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.
|