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.
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.bin/claude-sandboxwarns (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 youssh-adda 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 afterssh-adding. For a sandbox that needs to keep working unattended after your client disconnects, see "Headless git push without agent forwarding" below —SANDBOX_SSH_KEYis an alternative to this forwarded-agent requirement, not just an addition to it.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://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/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. 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) - 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.
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-permissionsremoves 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.shandgit-push-guard.share 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/configdirectly 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
ipsetdedup 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, 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 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.shruns as root, sets up the firewall directly, then drops to that user viagosubefore 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, and that user gets a permission error runningiptablesdirectly.
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.