init-firewall.sh's ipset population was a one-time snapshot at container start: an already-allowed domain could still start failing to connect later if its CDN edge rotated to an IP that was never added (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do this), and allowing a domain like crates.io didn't help traffic that actually goes to a different hostname under it (index.crates.io/static.crates.io). dns-proxy.sh's dnsmasq now also uses --ipset= so the resolved IP gets added on every fresh answer it serves for the life of the session, not just once at boot - verified this actually repopulates after a real (non-cached) re-resolution, and that dnsmasq tolerates the target ipset not existing yet when its config is generated (it runs before init-firewall.sh creates it). Also batches log-shipper.sh's Loki pushes (up to 20 lines or 2s, whichever first) instead of one HTTP request per line - a burst of activity (e.g. npm install resolving/connecting to many hosts quickly) would otherwise spam Loki with one request per line. Verified via the sandbox's own connection logging: 15 rapid lines produced 2 requests instead of 15.
claude-sandbox
Runs Claude Code inside a Docker container with --dangerously-skip-permissions
(no permission prompts, all tool use auto-accepted) against a project mounted
from the host. Based on
Anthropic's reference dev container:
same base image, non-root user, and egress-firewall approach, adapted to run
via docker compose directly instead of through a VS Code dev container, and
extended with this user's own nvim config and a gitea-pr helper for
opening/iterating on PRs.
This directory (docker/claude-sandbox/) only holds the tooling. It is
not where any project lives — the project you point it at is bind-mounted
into the container at runtime from wherever it actually sits on disk.
Prerequisites
- Docker with the
docker composev2 plugin. ssh-agentrunning with the key for your Gitea remote(s) added (ssh-add -lshould list it). The container never sees your private key, only a forwarded agent socket.GITEA_SSH_HOSTandGITEA_APIexported in your shell (same variablesbin/gitea-pralready needs — see itsrequire_config), and a token available either viaGITEA_TOKENin the environment or at~/.config/gitea/token.- Optional: a read-only fine-grained GitHub PAT (GitHub → Settings →
Developer settings → Fine-grained tokens → generate, with Contents and
Metadata set to read-only, no write scopes) for
gh/private-repo HTTPS access, available viaGH_TOKENin the environment or at~/.config/gh/sandbox-token. Don't use a normalgh auth loginsession here — that's write-capable, which defeats the point (see "GitHub is read-only" below). Without it, public GitHub repos still clone/fetch fine anonymously over HTTPS; only private repos andghAPI calls need it. - Optional:
LOKI_URL(a base URL, e.g.http://10.0.30.11:3100) to ship DNS-query and firewall allow/reject logs to a Loki push endpoint - see "Network request logging" below. Logging is skipped entirely if unset.
Usage
bin/claude-sandbox # sandbox the current directory
bin/claude-sandbox ~/repos/some-project
bin/claude-sandbox ~/repos/some-project src/foo.py # open a specific file
The entry point is nvim, opened on the bind-mounted project directory, using
this user's real nvim config (config/nvim, including
claudecode.nvim — <leader>ac
toggles a Claude Code terminal). claude is also on PATH directly for use
from any shell/terminal inside the container. Either way it always runs with
--dangerously-skip-permissions, regardless of how it's invoked.
Typical loop: open the project, ask Claude to implement something and open a
PR (it has gitea-pr on PATH); from the host, gitea-pr comments <PR#> to
pull review feedback, feed it back in, repeat.
Config parity with your host Claude Code setup
~/.claude and ~/.claude.json are bind-mounted read-write from the host
into the container, so global CLAUDE.md, settings, hooks, plugins, MCP
server config, and your existing login/credentials all carry over
automatically — the container is a faithful copy of this setup, not a
separate identity. There's no separate first-run login.
The project directory is mounted at the same absolute path inside the
container as on the host (not a fixed /workspace), so Claude Code's
project/session-history keying lines up between host and container. A
session started in the sandbox can be resumed on the host afterward with
claude --resume <session-id>.
Caveat: MCP servers or hooks that shell out to host-specific binaries (e.g. a
browser for Playwright, a language runtime not installed in this image) may
not work inside the container unless that tooling is also added to the
Dockerfile.
Sandbox self-awareness
A SessionStart hook (~/.claude/hooks/sandbox-context.sh, registered in
~/.claude/settings.json) checks for the CLAUDE_SANDBOX environment
variable this container sets, and when present, injects a reminder that the
session is running in a disposable, firewalled container and should stay
within the task at hand — and that if a task genuinely needs to break out of
the sandbox (broader network access, host-level changes, GUI/hardware), it
should stop and tell the user rather than trying to work around the
firewall, since the same session can be resumed unrestricted on the host via
claude --resume. It's a no-op outside the container. This is on top of the
existing deny-dangerous-bash.sh PreToolUse hook, which also applies here
since ~/.claude is shared.
git-push-guard.sh (also PreToolUse/Bash, also CLAUDE_SANDBOX-gated)
blocks any git push whose resolved remote isn't the Gitea host, and any
push to main/master even there — the sandbox workflow is feature branch
gitea-pr create, never a direct push. Like the other hooks, this is a fast-fail guard against an overeager agent, not a hard security boundary: a sandbox session has full shell access and could in principle edit.git/configto route around it. For "never pushes to GitHub" specifically, the firewall backs this up with real enforcement (see below); for "never pushes to main/master", the authoritative version of that is branch protection on the Gitea repo itself, which this doesn't set up for you.
sandbox-context.sh also tells the agent the actual workflow to follow
proactively, not just what's blocked: do new work in a git worktree
rather than switching branches in the mounted checkout in place (that's the
user's real working copy), then push the feature branch and run
gitea-pr create from the worktree.
Network egress firewall
On start, the container runs an allowlist-only iptables/ipset firewall
(init-firewall.sh), matching the reference dev container. Allowed by
default: DNS resolution via a local DNS proxy (see "DNS is allowlisted too"
below), the Docker host itself (not its whole subnet), GitHub's published IP
ranges (port 443 only - see "GitHub is read-only" below),
release-assets.githubusercontent.com (GitHub release assets - e.g. mason's
own registry index - redirect here, a separate CDN from github.com's own
IPs), registry.npmjs.org, pypi.org and files.pythonhosted.org (mason's
Python-based LSP tools - ruff, basedpyright, debugpy - install via
pip), api.anthropic.com, and whatever hosts GITEA_SSH_HOST/GITEA_API
resolve to — that last group isn't port-restricted, so git-over-ssh to Gitea
works without a separate blanket "port 22 to anywhere" rule. Set
ALLOWED_DOMAINS (space-separated) in the environment to allow more (e.g.
crates.io for a Rust project needing more than what mason/npm already
cover). Everything else is rejected. Set ENABLE_FIREWALL=0 to disable it
entirely (e.g. for debugging a blocked domain).
IPv6 is blocked outright (ip6tables policy DROP on INPUT/OUTPUT/FORWARD,
and dnsmasq strips AAAA answers so nothing wastes a connection attempt on an
address that could never be reached) - there's no IPv6 equivalent of the
allowlist above, so leaving it open would bypass the entire firewall on any
host with IPv6 egress enabled.
If your Gitea host is only resolvable via a LAN-local DNS server, make sure the Docker host itself can resolve it — containers inherit the host's resolver by default.
DNS is allowlisted too
Resolution itself is restricted, not just the IP connections made
afterward: dns-proxy.sh runs a local dnsmasq bound to 127.0.0.1 with no
fallback resolver, and /etc/resolv.conf is repointed at it. Only names
matching the same allowlist as above (plus github.com/
githubusercontent.com/githubassets.com, needed since GitHub is
allowlisted by IP range rather than by resolving those names - see
fixed-domains.sh) get forwarded to Docker's real resolver; anything else
gets no upstream configured at all and is refused outright. Without this, a
compromised session could still exfiltrate data by encoding it into query
names to an attacker-controlled domain even though it could never open a
direct connection there - IP-based filtering alone doesn't stop that, since
the DNS query itself was never restricted.
Adding a domain to a running session
ALLOWED_DOMAINS only takes effect at container start. For a domain a
running session needs right now, run from the host (not inside the
sandbox — it has no privilege to do this itself):
bin/claude-sandbox-allow crates.io # this session only
bin/claude-sandbox-allow crates.io --global # + persist in config/profile
It first adds the domain to the running dnsmasq's allowlist (appends a
server= line to its servers-file and sends SIGHUP to reload just that
file - no restart), then resolves it through the now-unblocked container
resolver and adds the IP(s) to the running firewall's ipset, all via
docker exec -u root. --global also appends the domain to
ALLOWED_DOMAINS in config/profile for future sessions; source your shell
(or open a new one) for that part to take effect. If more than one
claude-sandbox container is running, pass --container <name> to
disambiguate (docker ps to find it).
The sandbox-context.sh SessionStart hook tells the agent to ask for this
by name when a task is blocked on a single missing domain, rather than
stopping the whole session over it.
Network request logging
If LOKI_URL is set (see Prerequisites), every DNS query and every new
outbound connection attempt - allowed or rejected - is shipped to that Loki
endpoint. Two sources, both tailed and pushed by log-shipper.sh:
dns-proxy.sh's dnsmasq logs every query and whether it was forwarded (allowed) or refused (not on the allowlist) - labeledjob=claude-sandbox-dns.init-firewall.shtags its terminal ACCEPT/REJECT rules withNFLOG(group 1 for allowed, group 2 for rejected), whichulogd2(ulogd.conf) turns into JSON lines with source/dest IP, port, and anactionfield - labeledjob=claude-sandbox-net. Only new connection attempts are logged, not every packet: the firewall's existing ESTABLISHED,RELATED rule short-circuits the rest of a connection before it ever reaches the tagged rules.
This is visibility on top of the firewall/DNS-proxy enforcement above, not a substitute for it - nothing about logging changes what's actually allowed to resolve or connect.
GitHub is read-only
The sandbox can read from GitHub (clone, fetch, gh API calls) but can
never push there, and this is enforced at the network layer, not just by the
git-push-guard.sh hook above: GitHub's IP ranges are only allowlisted on
port 443. Port 22 is unreachable, so SSH-based push (which could otherwise
use the forwarded agent's real key - agent forwarding doesn't distinguish
"push" from "pull") has no transport to use at all, regardless of what any
hook does or doesn't catch. HTTPS access itself has no write-capable
credential either: gh auth setup-git (run at container start if GH_TOKEN
is set - see Prerequisites) points git's github.com credential helper at
gh, which only ever holds whatever token GH_TOKEN provides. Use a
read-only PAT there and even a push attempt over HTTPS gets rejected by
GitHub itself (403), not just blocked locally.
Security notes
--dangerously-skip-permissionsremoves your chance to review tool calls before they run. Only point this at trusted repositories.- Because
~/.claude(including.credentials.json) is bind-mounted in, anything reachable from inside the container (the allowed domains above) is a potential exfiltration path for those credentials if a malicious repo manages to manipulate a session. The firewall limits this to Anthropic/npm/GitHub/your Gitea host, not the open internet. settings.json,CLAUDE.md, andplugins/under~/.claudeare layered back read-only on top of the (otherwise read-write)~/.claudemount. Hooks execute automatically with no separate approval step, so without this a compromised session could plant a backdoored hook, rewriteCLAUDE.mdwith injected instructions, or tamper with plugin code - something that would then run unsandboxed on the host the next time Claude Code starts there, turning a single compromised sandbox session into persistent host compromise. Everything else under~/.claude(session/project history, credentials refresh, caches) stays read-write, since it's runtime state Claude Code needs to update.~/.claude.jsonis a single file mixing that same kind of live state with config (e.g.mcpServers, if ever set) and can't be split at the mount level, so it's left fully read-write - accepted as a residual risk rather than solved.- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
not just the IP connections made afterward, closing off exfiltration via
encoding data into query names to an attacker-controlled domain - verified
a non-allowlisted domain gets refused outright (
status: REFUSED) rather than resolving and then only failing to connect. - Don't mount other host secrets (
~/.sshprivate keys, cloud credential files) into the container. Git push auth goes through the forwarded SSH agent instead, which never exposes the key material itself - though note the agent will sign for any Gitea repo your key has access to, not just the mounted project, and the firewall's Gitea allowlist is host-based rather than repo-scoped, so a compromised session can still reach (and push to) other repos on that host you have access to. This doesn't extend to GitHub: port 22 there is blocked outright, so the forwarded agent has no transport to use for it at all (see "GitHub is read-only" above). - The
nodeuser has nosudo/setuid path back to root once the container is up:entrypoint.shruns as root, sets up the firewall directly, then drops tonodeviagosubefore execingnvim. A compromised session can't re-runinit-firewall.shwith a wider allowlist to open its own exfiltration path. Verified: capabilities (NET_ADMIN/NET_RAW) are cleared from the effective/permitted sets on thegosudrop, andnodegets a permission error runningiptablesdirectly.
Files
| File | Purpose |
|---|---|
Dockerfile |
Base image, dev tools, nvim, Claude Code + wrapper, gitea-pr |
fixed-domains.sh |
Shared domain list sourced by both init-firewall.sh and dns-proxy.sh |
dns-proxy.sh |
Starts the DNS-allowlisting dnsmasq, repoints resolv.conf at it |
init-firewall.sh |
IPv4/IPv6 egress allowlist firewall, run at container start |
ulogd.conf |
ulogd2 config: turns init-firewall.sh's NFLOG-tagged packets into JSON |
log-shipper.sh |
Tails the dnsmasq/ulogd2 logs and ships them to $LOKI_URL |
entrypoint.sh |
Runs as root: DNS proxy, firewall, logging, drops to node via gosu, execs nvim |
claude-wrapper.sh |
Shadows claude on PATH to always add --dangerously-skip-permissions |
docker-compose.yml |
Wires up mounts/env; invoked via bin/claude-sandbox, not directly |
bin/claude-sandbox (repo root bin/) is the entry point — it resolves the
project path, checks for a live ssh-agent, loads GITEA_TOKEN/GH_TOKEN
if not already set, and runs docker compose run. bin/claude-sandbox-allow
adds a domain to a running session's firewall from the host - see "Adding a
domain to a running session" above.