Two switches, controlled independently:
-
Filesystem — always locked to exactly one mounted folder, in both modes.
run.shbind-mounts only the workspace path you pass it (-v "$WORKSPACE:/workspace"). Nothing else on your machine is mounted, so the container simply cannot see any other project, even if Claude triescd ../other-projectorcd ~— those paths don't exist inside the container's filesystem at all. This holds true whether the network is locked or open; it's a property of the Docker mount namespace, not the firewall. -
Network — toggled by
MODE:locked(default):firewall.shruns at container start (via a scoped, passwordlesssudoentry that can run only that one script) and drops all outbound traffic except DNS, loopback, the Docker host bridge, and Claude's own domains (api.anthropic.com,claude.ai,platform.claude.com). Everything else — npm, pip, GitHub, curl to random URLs — is rejected.open: firewall.sh never runs; the container gets whatever network access the Docker host normally provides.
Per your requirement, locked mode allows only Claude's own domains — no
npm/pip registries. That means npm install / pip install will fail once
the container is running in locked mode. Install everything you need in the
Dockerfile instead (it has full network access during docker build,
before the firewall ever applies):
RUN npm install -g @anthropic-ai/claude-code@latest
# COPY package*.json /tmp/deps/ && RUN cd /tmp/deps && npm ci
# RUN pip install --break-system-packages -r requirements.txtIf a task genuinely needs a fresh package mid-session, run that one session
in MODE=open instead, or rebuild the image with the new dependency added
and go back to locked.
Every run picks a mode (locked/open) and, optionally, what runs inside:
claude (default), shell (plain bash, no Claude at all), or any literal
command. The folder-only mount and the network restriction apply identically
no matter which one you pick — shell mode is the exact same sandbox,
just with you typing the commands instead of Claude.
Browser login (no API key needed), running Claude Code:
./run.sh locked /absolute/path/to/project-aOn first start, Claude Code prints a login URL/code — open it in your own
browser, approve it, and you're done. Credentials are saved to a named
Docker volume (claude-sandbox-config), so this only happens once; later
runs (even against a different workspace path) reuse it. To wipe it and
force a fresh login: docker volume rm claude-sandbox-config.
API key instead (skips the login prompt):
export ANTHROPIC_API_KEY=sk-ant-...
./run.sh locked /absolute/path/to/project-aFull internet, still only this one folder mounted:
./run.sh open /absolute/path/to/project-aPlain shell instead of Claude — same sandbox, you're driving:
./run.sh locked /absolute/path/to/project-a shell
./run.sh open /absolute/path/to/project-a shellYou land in bash at /workspace with the same folder-only mount and the
same network restriction Claude would have had. No login needed for this
one. Exit the shell (exit or Ctrl-D) and the container is removed
(--rm), same as after a Claude session.
Swap in a different path and neither mode can reach project-b sitting
next to it on your disk — it was never mounted, so it isn't there. And
remember you don't need the container at all just to edit files: the
mount is live in both directions, so anything you change in Finder or your
normal editor on the Mac shows up inside the container immediately, and
vice versa, whether or not anything is currently running inside it.
- Auth:
firewall.shallowlistsclaude.aiandplatform.claude.comby default (needed for browser-basedclaude login), on top ofapi.anthropic.com. If you only ever useANTHROPIC_API_KEY, you can remove those two lines to shrink the allowlist further. - Login persistence:
run.shmounts a named volume,claude-sandbox-config, at/home/sandbox/.claudein every container, so a browser login survives container restarts. It's shared across whatever workspace path you pointrun.shat — that's intentional (same login, different projects) but means anyone who can run this script also reuses that login. Remove the volume mount inrun.shif you want fully separate credentials per workspace instead. - Docker Desktop on macOS: containers run inside Docker's own Linux VM,
so
HOST_NETWORKinfirewall.shrefers to Docker's internal bridge, not your Mac's LAN. That's expected and fine. - Capabilities:
run.shdeliberately does not--cap-drop=ALL.sudo(used byfirewall.sh) needs several of Docker's normal default capabilities (SETUID/SETGID/AUDIT_WRITE) just to switch to root and log it -- stripping them produces a confusingsudo: unable to change to root gidfailure with no obvious cause. So this setup keeps Docker's ordinary default capability set (already far short of real root) and only addsNET_ADMIN/NET_RAWon top, and only inlockedmode, for iptables/ipset.openmode andshellmode add nothing extra. - Verify it actually holds:
firewall.shself-tests on start (fails the container ifhttps://example.comis still reachable in locked mode). Worth also manually tryingcurl https://google.comandls /vs a path outside/workspacefrom inside a locked container once, to see the rejections yourself.sandbox-test-project/verify.sh(sibling folder) automates exactly this. - This is a reference implementation you should treat as a starting
point, not a hardened security boundary — Docker's default bridge network,
a misconfigured
HOST_NETWORKmatch, or DNS-based bypass tricks (e.g. DNS rebinding against the allowed IP) are the usual soft spots in this pattern. For anything higher-stakes than "keep a coding assistant from wandering into the wrong folder or the open internet," pair this with a proper outbound proxy allowlist rather than iptables+ipset alone.