Files
dotfiles/docker/claude-sandbox
branderson 5fef8dcf90 claude-sandbox: mount the dedicated SSH key source under /root, not $HOME
The headless-SSH-key feature claimed the plaintext key was "deleted
immediately after being loaded into the agent, so nothing readable is
left on disk" - but entrypoint.sh only deleted the copy it made; the
original bind-mounted source at $SANDBOX_HOME/.ssh-sandbox-key-src stayed
mounted, readable, for the container's whole life. Combined with the
sandbox user's uid now matching the host user's, that file was directly
readable by the very process the sandbox exists to constrain, letting a
compromised session exfiltrate the raw key instead of being limited to
signing through the local agent socket.

Move the mount to /root/.ssh-sandbox-key-src (root:root, 700 by default)
so only entrypoint.sh's root-run startup code can ever reach it; the
sandbox user has no path to that directory at all. Update the matching
static/runtime tests (the runtime one now asserts the sandbox user
actually gets permission denied reading the source) and correct the
README's description of the mechanism.
2026-07-22 12:36:40 -07:00
..

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 compose v2 plugin.
  • ssh-agent running with the key for your Gitea remote(s) added (ssh-add -l should list it). The container never sees your private key, only a forwarded agent socket. bin/claude-sandbox warns (non-fatally) at launch if the agent has zero identities loaded - the socket still forwards fine either way, but git-over-ssh will fail until you ssh-add a key. That takes effect immediately in an already-running session too (it's a live reference to the same host agent process, not a snapshot) - no need to restart the sandbox after ssh-adding. For a sandbox that needs to keep working unattended after your client disconnects, see "Headless git push without agent forwarding" below — SANDBOX_SSH_KEY is an alternative to this forwarded-agent requirement, not just an addition to it.
  • GITEA_SSH_HOST and GITEA_API exported in your shell (same variables bin/gitea-pr already needs — see its require_config), and a token available either via GITEA_TOKEN in 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 via GH_TOKEN in the environment or at ~/.config/gh/sandbox-token. Don't use a normal gh auth login session 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 and gh API calls need it.
  • Optional: LOKI_URL (a base URL, e.g. http://loki.internal: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>.

The container's own user is built to match yours exactly — same username, uid, gid, and home directory (bin/claude-sandbox passes these as Docker build args; see Dockerfile). This is what makes $HOME-relative assumptions actually work inside the container instead of just superficially resembling your setup — your dotfiles, and Claude Code's own plugin bookkeeping (which records install locations as literal host-absolute paths, not relative to whatever $HOME happens to be), resolve to the same paths on both sides. The build defaults to node/1000/1000//home/node if these aren't supplied, so building the image directly (bypassing bin/claude-sandbox) still works, just without this parity. On a shared machine, the built image is tagged per-user (claude-sandbox:<username>) so two accounts building concurrently don't clobber each other's image.

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.

Your global ~/.gitconfig (user.name/user.email and the like) is also carried over, so commits made inside the sandbox have the right identity without any extra setup - falls back to an empty config if you don't have one, it's not a hard requirement. It's a throwaway copy rather than the real file, in its own directory rather than mounted directly at ~/.gitconfig (GIT_CONFIG_GLOBAL points git at it instead) - gh auth setup-git needs to write a credential-helper entry into it, and both a read-only mount and a single-file bind mount at ~/.gitconfig break that write in different ways. Nothing written to it during a session reaches your real ~/.gitconfig.

Headless git push without agent forwarding

Agent forwarding (the default — see Prerequisites) ties the sandbox's SSH access to whichever client's agent is currently forwarded. On a devbox reached over SSH+tmux, that agent belongs to whichever client connected — when that client disconnects, the forwarded socket dies with it, breaking git-over-ssh for anything still running headless in the sandbox.

For sandboxes meant to keep running unattended, generate a dedicated, purpose-built keypair instead — not your personal key:

ssh-keygen -t ed25519 -N '' -f ~/.ssh/claude-sandbox_ed25519

Passphrase-less is deliberate: headless operation has no human to answer a prompt, and this is meant to be a low-privilege credential that only ever lives inside the container, not your personal identity. Add the public half as a Gitea deploy key scoped to only the repos that actually need push access, then:

SANDBOX_SSH_KEY=~/.ssh/claude-sandbox_ed25519 bin/claude-sandbox ~/repos/some-project

The key is bind-mounted read-only at /root/.ssh-sandbox-key-src — under /root, not $SANDBOX_HOME — so only entrypoint.sh's root-run startup code can ever read it; the sandbox user (which the session actually runs as) has no path to that directory at all. At container start, entrypoint.sh loads it into a fresh ssh-agent running inside the container itself — independent of the forwarded socket — copies it out just long enough to do that, and deletes the copy immediately afterward. A compromised session can use the agent's socket to sign git-over-ssh operations (bounded by whatever the deploy key has push access to), but can't read the raw key material itself. When SANDBOX_SSH_KEY is set, a live forwarded agent is no longer required to start the sandbox at all.

Shared scratch space

~/shared inside the container is a read-write bind mount of a real host directory ($SANDBOX_SHARE_DIR, default ~/.local/share/claude-sandbox/shared, created automatically) - separate from the project mount, for passing files (screenshots, temp scripts) back and forth with a sandboxed session without touching the actual project checkout. It persists across sessions since it's a real directory, not a container volume. Set SANDBOX_SHARE_DIR to point it somewhere else.

~/specs is also bind-mounted read-write, at the fixed host path ~/specs (created automatically if it doesn't exist yet) - not configurable like SANDBOX_SHARE_DIR, since it exists to make the global CLAUDE.md convention of writing specs to ~/specs/ (also carried into the container) actually work there instead of writing into a path that vanishes with the session.

Testing frontend changes

Playwright (Chromium) is pre-installed, browser included - require ("playwright") works from any script, anywhere in the container, not just a project with its own npm install playwright. Both the npm package and the matching Chromium build are baked in at image build time, so a session never needs network access for either.

Testing against a project's own local dev server needs no firewall changes at all - localhost/loopback traffic never goes through the egress firewall in the first place. Testing against a real external site works the same as any other network use: the domain needs to be on the allowlist (ALLOWED_DOMAINS, or bin/claude-sandbox-allow <domain> for a running session) - verified the firewall applies to the browser's own traffic exactly like everything else (a non-allowlisted domain gets blocked from inside the browser too, not just from curl/git).

If a project has its own pinned playwright/@playwright/test devDependency at a different version than the one baked in here, its npm install may still try to download a matching Chromium build from Playwright's own CDN, which isn't on the default allowlist - allow it the same way as any other one-off domain need if that comes up.

Chromium's own internal process sandbox is best-effort here, not something to rely on: it launches without erroring, but its setuid sandbox helper isn't actually setuid in this image, and this host has the same unprivileged-userns restriction that keeps Claude Code's own internal sandboxing (see Security notes) from working either - so its namespace- based isolation almost certainly isn't active, Chromium just doesn't hard-fail the way bubblewrap does when it's missing. Not treated as a real boundary: this container is already the boundary that matters, and a compromised session already has full bash access as the container's own user regardless, which a Chromium exploit couldn't exceed.

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/config to 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. The worktree has to live under the mounted project directory (e.g. .worktrees/<branch>, not ../<branch>) - only the exact bind-mounted path is backed by real host storage, so a worktree created as a sibling of the project (or anywhere under $HOME) looks fine but silently disappears with the container, worktree and all.

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), proxy.golang.org and sum.golang.org (go build/go mod download's module proxy and checksum database), 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) - labeled job=claude-sandbox-dns.
  • init-firewall.sh tags its terminal ACCEPT/REJECT rules with NFLOG (group 1 for allowed, group 2 for rejected), which ulogd2 (ulogd.conf) turns into JSON lines with source/dest IP, port, and an action field - labeled job=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.

Viewing logs in Grafana

Nothing sandbox-specific needed on the Grafana side beyond a Loki data source pointed at wherever LOKI_URL resolves to (Connections → Data sources → Loki, if one isn't already configured against that instance) - log-shipper.sh pushes directly to Loki's HTTP API, there's no Prometheus scrape target or exporter involved.

In Grafana's Explore view (or a dashboard panel with a Loki query), both streams are selectable by their job label:

{job="claude-sandbox-dns"}                          # every DNS query + verdict
{job="claude-sandbox-net"}                          # every connection attempt, as JSON

Useful filters on top of those:

# DNS lookups that got refused (not on the allowlist)
{job="claude-sandbox-dns"} |= "REFUSED"

# Rejected connections only (parses ulogd2's JSON, filters on its action field)
{job="claude-sandbox-net"} | json | action="blocked"

# Both streams together, e.g. to watch a live session across sources
{source="claude-sandbox"}

{job="claude-sandbox-net"} lines are one full ulogd2 JSON object per connection attempt (| json in a query unpacks fields like dest_ip, dest_port, action) - Loki's Explore view shows these expandable/ pretty-printed by default, no extra parsing setup needed.

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

Threat model, honestly stated: this reduces the blast radius of a misbehaving or prompt-injected coding agent - it does not make it safe to point at arbitrary untrusted or adversarial input, and it does not turn --dangerously-skip-permissions into something without real risk. Point it at repositories you already trust. Everything below is about how bad a compromised session inside that trust boundary can get, not a claim that one can't happen.

  • --dangerously-skip-permissions removes your chance to review tool calls before they run. Only point this at trusted repositories.
  • The mounted project itself has no write protection. deny-dangerous- bash.sh and git-push-guard.sh are regex pattern-matching on the literal Bash command string - a fast-fail guard against an overeager agent doing something obviously destructive, not a boundary against one that's actively trying to get around them (a different scripting language, an encoded command, editing .git/config directly all sail right through). Nothing here stops a compromised or badly-instructed session from modifying or deleting the real working copy it's mounted against.
  • Allowing egress to a domain at all creates a channel for exfiltrating data through that service's own request surface (query strings, lookups, etc.), not just to a literal attacker-controlled destination. Domain-level allowlisting closes off reaching somewhere new; it can't inspect or restrict what gets sent to somewhere already trusted for normal operation (installing packages, calling the model API). That's a structural limit of allowlist-only firewalls in general, not something specific to this setup, and nothing here addresses it.
  • This is container-level isolation (Linux namespaces plus a small capability set), not VM-level isolation - no gVisor/Firecracker or similar. It shares the host kernel, so a container-escape vulnerability would defeat every other control described here at once.
  • Everything in this document has been verified by hand, at a point in time - there's no automated test suite re-checking these properties on every change, so a future edit could silently reintroduce a gap. This has already happened once in this file's own history (a missing ipset dedup flag broke firewall startup entirely, twice, on two different ipsets, before being caught).
  • 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, and plugins/ under ~/.claude are layered back read-only on top of the (otherwise read-write) ~/.claude mount. Hooks execute automatically with no separate approval step, so without this a compromised session could plant a backdoored hook, rewrite CLAUDE.md with 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.json is 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 (~/.ssh private 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 sandbox's own user (matching your host user — see "Config parity" above) has no sudo/setuid path back to root once the container is up: entrypoint.sh runs as root, sets up the firewall directly, then drops to that user via gosu before execing nvim. A compromised session can't re-run init-firewall.sh with a wider allowlist to open its own exfiltration path. Verified: capabilities (NET_ADMIN/NET_RAW) are cleared from the effective/permitted sets on the gosu drop, and that user gets a permission error running iptables directly.

Tests

test/ has a bats suite covering the security invariants above - not a substitute for reading this document, but a way to actually catch a future change silently breaking one of them instead of finding out the hard way:

bats docker/claude-sandbox/test/               # everything
bats docker/claude-sandbox/test/static.bats     # fast, no Docker/build needed
SKIP_BUILD=1 bats docker/claude-sandbox/test/runtime.bats   # reuse the current image

static.bats greps the repo's own files - no sudo in the Dockerfile, every ipset add is duplicate-safe, no docker.sock/host-credential mounts, the ~/.claude read-only overlay lines are present, bin/claude-sandbox-allow rejects a malicious domain argument. runtime.bats builds the image, boots a real container running the actual entrypoint.sh (a SANDBOX_TEST_MODE env var makes it stop just short of execing nvim, so the suite can attach to a fully-initialized container instead of reimplementing its own copy of the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist enforcement in both directions, GitHub read-only, no working sudo/ iptables/init-firewall.sh as node, capabilities actually stripped (not just absent from a docker exec that doesn't have them to begin with), ulogd2 unprivileged, and the dynamic-ipset CDN-rotation fix.

Every mutation in runtime.bats (firewall rules, ipset entries) happens inside the test container's own network namespace, same as any other container - none of it touches the host. Container/network names are derived from bats' own per-run temp directory, not anything guessable or likely to collide with a real container you have running.

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.