# 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.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: 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.
