Network Sandbox for Claude Code Agents: Full Local Isolation

hero

Running Claude Code agents on self-hosted hardware means you own the security perimeter — completely. When I moved my setup to a Mac Mini cluster running Ollama and Draw Things locally, the first thing I noticed was how freely the agent reached out to the internet. npm install, pip install, curl to random CDNs — none of it asked permission. This post walks through the Docker network isolation and iptables whitelist setup I use to lock that down to exactly the traffic I intend.

overall flow — agent, isolated network, allowed vs blocked paths


The Problem: Agents Are Eager, Not Careful

The agent doesn't ask whether it should install a dependency — it just does. This is actually a useful property in a sandboxed environment, but completely the wrong default when the agent has full network access.

Here's what I observed before any isolation. During a typical coding session, the agent contacted an average of 12 external domains per session. These included package registries, CDN hosts for documentation, and the occasional telemetry endpoint from a library. None of this was malicious, but none of it was intentional either.

The failure mode I'm worried about isn't a dramatic breach. It's quieter: an agent pulls a transitive dependency with a known CVE, or exfiltrates a path hint buried in an error message to an external logging endpoint. When you're self-hosting models specifically to keep data local, this defeats the point entirely.

before isolation — uncontrolled external traffic

The moment I added iptables logging and watched what the agent was actually doing, the case for isolation closed itself.


The Fix: Docker Internal Network + iptables Whitelist

Step 1: Create an Isolated Bridge Network

Docker's --internal flag is the key primitive here. It creates a bridge network that routes between containers on the same network but has no path to the external internet — the routing table simply doesn't include an external gateway.

# Create the agent-only isolated network
docker network create \
  --driver bridge \
  --internal \
  --subnet 172.30.0.0/24 \
  claude-agent-net

# Attach the Ollama container to the same network
# (internal communication only — no internet needed for inference)
docker network connect claude-agent-net ollama

Now run the agent container on this network:

docker run --rm \
  --network claude-agent-net \
  -e ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY \
  -v $(pwd)/workspace:/workspace \
  claude-agent:latest

At this point, the agent can reach Ollama on 172.30.0.x:11434, but has zero external routing. The --internal flag isn't a firewall rule — it's a missing route. There's nothing to block because there's no path out.

isolated bridge routing — internal only

Step 2: Punch a Hole for Anthropic API Only

The problem with fully internal networks is that the agent still needs to reach api.anthropic.com to function. For that, I use iptables to carve out a single CIDR allowance while keeping everything else dropped.

# Find the bridge interface Docker created for this network
BRIDGE=$(docker network inspect claude-agent-net \
  --format '{{.Options}}' | grep -o 'br-[a-z0-9]*')

# Default policy: drop all forwarding from this bridge to external
iptables -I FORWARD -i $BRIDGE -o eth0 -j DROP

# Whitelist Anthropic API endpoint range (api.anthropic.com resolves here)
iptables -I FORWARD -i $BRIDGE -o eth0 \
  -d 18.238.0.0/15 -p tcp --dport 443 -j ACCEPT

Verify the rule order (order matters — iptables processes top-down):

iptables -L FORWARD -n --line-numbers

Expected output:

Chain FORWARD (policy ACCEPT)
num  target  prot  opt  source        destination
1    ACCEPT  tcp   --   0.0.0.0/0    18.238.0.0/15    tcp dpt:443
2    DROP    all   --   0.0.0.0/0    0.0.0.0/0

Rule 1 catches Anthropic API traffic first, rule 2 drops everything else. The ACCEPT must come before the DROP — if you add them in the wrong order, the DROP fires first and blocks everything including the API.


Monitoring: Catch What the Agent Tries to Reach

Isolation without visibility is hope, not security. I add a logging rule so every blocked packet gets written to the kernel log.

# Add logging rule BEFORE the DROP rule
iptables -I FORWARD -i $BRIDGE -o eth0 -j LOG \
  --log-prefix '[AGENT-BLOCKED] ' --log-level 4

# Stream blocked attempts in real time
journalctl -k -f | grep 'AGENT-BLOCKED'

What this looks like during an active session:

[AGENT-BLOCKED] IN=br-a3f2 OUT=eth0 SRC=172.30.0.3 DST=151.101.1.57
[AGENT-BLOCKED] IN=br-a3f2 OUT=eth0 SRC=172.30.0.3 DST=104.18.32.68
[AGENT-BLOCKED] IN=br-a3f2 OUT=eth0 SRC=172.30.0.3 DST=54.230.19.44

Those destination IPs resolve to Fastly CDN, Cloudflare, and an S3 edge node — all consistent with package manager behavior. The agent was attempting outbound installs that I never asked for, silently, mid-task.

monitoring pipeline — kernel log to journald


Variations and Gotchas

Making the iptables Rules Persistent

By default, iptables rules disappear on reboot. On Ubuntu/Debian:

apt install iptables-persistent
netfilter-persistent save

On systems using nftables as the default (Debian 12+, newer Ubuntu), translate to nft syntax or explicitly load iptables-legacy. Mixing the two causes silent rule conflicts.

The BRIDGE Variable Can Break on Restart

Docker assigns a new bridge interface name (br-xxxxxxxx) when the network is recreated. If you tear down and recreate claude-agent-net, re-run the BRIDGE= detection and re-apply the iptables rules. I wrap this in a startup script:

#!/bin/bash
BRIDGE=$(docker network inspect claude-agent-net \
  --format '{{json .Options}}' | python3 -c \
  "import sys,json; opts=json.load(sys.stdin); print(opts.get('com.docker.network.bridge.name',''))")

if [ -z "$BRIDGE" ]; then
  BRIDGE=$(ip link | grep -o 'br-[a-z0-9]\{12\}')
fi

iptables -I FORWARD -i $BRIDGE -o eth0 -j LOG \
  --log-prefix '[AGENT-BLOCKED] ' --log-level 4
iptables -I FORWARD -i $BRIDGE -o eth0 -j DROP
iptables -I FORWARD -i $BRIDGE -o eth0 \
  -d 18.238.0.0/15 -p tcp --dport 443 -j ACCEPT

Anthropic's IP Range Can Shift

The CIDR 18.238.0.0/15 covers the CloudFront distribution Anthropic uses, but this isn't guaranteed stable. A more robust approach is to resolve api.anthropic.com at startup and add the current IPs dynamically, or use an egress proxy like Squid with a hostname allowlist instead of IP-based rules.

Mac / OrbStack Differences

On macOS with OrbStack or Docker Desktop, the bridge interface is abstracted behind a VM layer. The iptables approach won't work directly — the firewall lives inside the Linux VM. Either drop into the OrbStack VM shell to apply rules, or use OrbStack's network policies if available. For my Mac Mini cluster I run a minimal NixOS VM via UTM to host the Docker daemon, which gives full iptables access.

Environment iptables accessible? Alternative
Linux bare metal Yes, direct
Docker Desktop (Mac) No (VM boundary) OrbStack network policies
OrbStack (Mac) Inside VM only orb shell + iptables
NixOS VM on Mac Yes, full control Best option for self-hosted

environment comparison — iptables access paths


Closing

The combination of --internal Docker network plus targeted iptables rules took about 30 minutes to set up and costs zero milliseconds of added latency on internal Ollama inference — I measured it. External traffic went from 12 domains per session to exactly 1 (Anthropic API). The agent still works identically from a task perspective; it just can't reach anywhere it shouldn't.

If you're self-hosting Claude Code for data locality reasons, the network boundary is where that promise lives or dies. Set the fence before you need it.

Next: I'm looking at adding per-session ephemeral containers so each agent run gets a fresh network namespace and no shared state persists between tasks.


🐦 Faster updates on X: @baegseungh7061
📚 More in this series: Code Practical
💌 Subscribe: Follow on X or grab the RSS

댓글