Files
bot-bottle/docs/research/network-egress-guard.md
didericis-codex 18e3b62b72
test / unit (pull_request) Successful in 28s
test / integration (pull_request) Successful in 40s
test / unit (push) Successful in 31s
test / integration (push) Successful in 44s
docs: rename CLAUDE.md to AGENTS.md and rebrand provider-agnostic
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>
2026-05-28 20:36:47 -04:00

802 lines
34 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 L3L7, 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 | LowMedium | 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 | L3L7 | 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.