From edf79b3880dd529c113bd490e1fab043bc89c502 Mon Sep 17 00:00:00 2001 From: didericis Date: Thu, 7 May 2026 23:27:18 -0400 Subject: [PATCH] docs: add research note on container network egress guards Co-Authored-By: Claude Opus 4.7 --- docs/research/network-egress-guard.md | 799 ++++++++++++++++++++++++++ 1 file changed, 799 insertions(+) create mode 100644 docs/research/network-egress-guard.md diff --git a/docs/research/network-egress-guard.md b/docs/research/network-egress-guard.md new file mode 100644 index 0000000..8563dcf --- /dev/null +++ b/docs/research/network-egress-guard.md @@ -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//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 `, `devbox deny `) 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 ` 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//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): +- Claude Code network configuration docs: +- Claude Code sandboxing docs: +- Anthropic blog — Claude Code sandboxing: +- `anthropic-experimental/sandbox-runtime`: +- `RchGrav/claudebox`: +- `IVIJL/devbox`: +- `centminmod/claude-code-devcontainers` init-firewall: +- `stripe/smokescreen`: +- `pretix/smokescreen` Docker image: +- Fly.io smokescreen guide: +- mitmproxy proxy modes: +- Claude Code issue — `NODE_EXTRA_CA_CERTS` in settings.json not effective: +- Claude Code issue — init-firewall.sh duplicate IP failure: +- Docker iptables / DOCKER-USER chain docs: +- Docker for Mac iptables issue #2489: +- Docker for Mac BTF / eBPF issue #6800: + +Research conducted 2026-05-07.