Run Claude Code in a container that inherits your setup but not your filesystem. Workspace access is scoped to the directories you pass in; your statusline, skills, agents, and slash commands ride along as read-only bind-mounts. CLI tools are preinstalled (gh, glab, aws, openspec, uv, pnpm, tfenv, git-lfs) — language runtimes are not: tfenv and uv fetch your project-pinned Terraform / Python on demand. Host credentials (gh, glab, aws, tfe) are opt-in per flag; nothing leaks in by default.
The VCS and cloud CLIs (gh, glab, aws) need a flag to see host credentials — see Credential opt-in. The rest work out of the box.
# Build the image from your checkout (one-time; rerun after Dockerfile or tool pins change)
docker build -t claude-code:local .
# Put on your PATH (create ~/bin if it doesn't exist)
mkdir -p ~/bin
ln -s "$(pwd)/run.sh" ~/bin/claude-dockerThe build needs BuildKit (the Dockerfile uses COPY --chmod). Docker Desktop ships it by default. A Homebrew docker CLI with Colima does not: the buildx plugin is a separate formula, and without it docker build silently falls back to the legacy builder and dies at the COPY --chmod step with the --chmod option requires BuildKit. One-time fix:
brew install docker-buildx
mkdir -p ~/.docker/cli-plugins
ln -sfn "$(brew --prefix)/opt/docker-buildx/bin/docker-buildx" ~/.docker/cli-plugins/docker-buildxVerify with docker buildx version, then rerun the same docker build — with the plugin present, plain docker build uses BuildKit automatically.
claude-docker runs on docker or podman. With no configuration it auto-detects the engine, preferring docker and falling back to podman — so a podman-only host (including Windows via podman machine + WSL backend, and podman-as-docker Linux setups) works with zero setup and never hits docker: command not found.
To force an engine, set CLAUDE_DOCKER_RUNTIME:
CLAUDE_DOCKER_RUNTIME=podman claude-docker ~/repoThe env var is the canonical mechanism: it works everywhere — scripts, CI, editor "run" integrations, and non-interactive shells all inherit it. If you want a shorter interactive spelling, an alias is optional sugar (not a substitute — an alias only resolves at an interactive prompt, so scripts and editors still need the env var):
alias claude-podman='CLAUDE_DOCKER_RUNTIME=podman claude-docker'Only docker and podman are accepted; any other value is rejected before anything runs. The image build is the engine's own command — podman build -t claude-code:local . on a podman host, mirroring the docker build line above.
Windows / Git Bash: run claude-docker from Git Bash (MSYS/MINGW). The wrapper disables MSYS's automatic POSIX→Windows path rewriting for the engine's argv and translates host mount paths itself, so container-side paths reach podman.exe/docker.exe intact — no more invalid option type "\Program Files\Git\workspaces\...".
claude-docker # current dir as workspace
claude-docker ~/repo-a ~/repo-b # multi-workspace
claude-docker --yolo ~/repo # alias for --dangerously-skip-permissions
claude-docker ~/repo -- --resume # any claude flag after --claude-docker --help (or -h) prints every wrapper flag with a one-line explanation — the canonical reference.
Credentials are off by default. No AWS / GitHub / GitLab / Terraform Cloud / package-registry config, tokens, or env vars reach the container unless you explicitly opt in:
| Flag | Effect |
|---|---|
--aws |
Mount ~/.aws/config and ~/.aws/sso/ read-only and forward AWS_PROFILE / AWS_REGION / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN. ~/.aws/credentials (long-lived keys) and ~/.aws/cli/cache/ are not mounted. |
--gh |
Starts a per-session auth proxy sidecar that holds the GitHub token — the agent container never sees it. Token discovery is unchanged (GH_TOKEN / GITHUB_TOKEN, else host gh auth token, else a silent skip with no sidecar and legacy no-token behavior). When a token is found, the agent container gets a placeholder GH_TOKEN=claude-docker-proxy, GitHub traffic is redirected to the sidecar, and the real Authorization header is injected in transit. /root/.config/gh stays masked while the sidecar is active. See GitHub auth proxy. |
--gh-direct |
Legacy escape hatch: same token discovery as --gh, but forwards the real token straight into the agent container as GH_TOKEN — no sidecar. For custom-hostname GitHub (Enterprise Server, *.ghe.com) and hosts that can't run the sidecar. Mutually exclusive with --gh (combining both is a startup error) and shown as its own gh-direct statusline tag. Unmasks in-container gh auth login state, same as pre-proxy --gh. |
--glab |
Mount the platform-appropriate glab-cli config dir read-only (macOS: ~/Library/Application Support/glab-cli, Linux: ~/.config/glab-cli) and forward GITLAB_TOKEN. Unmasks in-container glab auth login state — without the flag, /root/.config/glab-cli/ is hidden by a tmpfs overlay. |
--tfe |
Mount ~/.terraform.d/credentials.tfrc.json read-only when present and forward TF_TOKEN_app_terraform_io. Targets app.terraform.io (HCP Terraform) only — self-hosted Terraform Enterprise hostnames and other TF_TOKEN_<host> variables are not forwarded. Unmasks in-container terraform login state — without the flag, /root/.terraform.d/ is hidden by a tmpfs overlay. See Terraform Cloud workflow. |
--registry |
Surface host-native private package-registry config so in-container uv / pnpm / pip installs resolve against your private feed (CodeArtifact, Artifactory, Nexus, …) instead of public npm/PyPI. Read-only mounts of ~/.npmrc / uv.toml / pip.conf plus UV_INDEX_* / npm_config_registry / PIP_* env when set. ~/.netrc is intentionally not mounted (too broad). Runtime-only; the build is unaffected. Whole-file mounts — see Private package registries for the full channel list and the scoping caution. |
Combine as needed: claude-docker --aws --gh ~/repo. --gh and --gh-direct cannot be combined with each other.
| Flag | Effect |
|---|---|
--ephemeral |
Skip the persistent named volumes. No in-container auth state, shell history, or conversation history persists across runs. |
--ro |
Mount every workspace read-only. Prevents the agent from modifying your code. |
--ro does not block credential flags or restrict network egress — for an isolated review session, combine --ephemeral and --ro and pass no credential flags:
claude-docker --ephemeral --ro ~/untrusted-repoFor --iterm / --tmux (teammate split panes), see Split-pane agent teams. In-container YOLO narrows the blast radius compared to running on the host, but see Threat model for what it does and doesn't protect.
Conversation history persists in the shared claude-code-home volume (skipped under --ephemeral), so claude --resume followed by Ctrl+A lists sessions from every workspace you've ever used — not just the one you're currently in.
On every run, these items are dereferenced (symlinks resolved) and bind-mounted read-only into the container at the equivalent /root/.claude/ path:
| Item | Purpose |
|---|---|
~/.claude/agents/ |
custom agent definitions |
~/.claude/skills/ |
custom skills |
~/.claude/commands/ |
slash commands |
~/.claude/CLAUDE.md |
global preferences (gprefs) |
~/.claude/statusline-command.sh |
statusline renderer |
For settings.json, maintain a dedicated ~/.claude/settings.docker.json (any valid Claude settings.json schema) — when present it's copied to /root/.claude/settings.json at container start. A copy rather than a bind mount, because Claude Code saves settings by renaming a tmp file over settings.json and rename() over a mountpoint fails with EBUSY — so in-session settings changes (effort, model, theme) actually save; they last for that container run, are re-seeded from the host file on the next start, and are never written back to the host. Keeping it separate from your host settings.json avoids dragging macOS-only keys (sandbox, env.SSL_CERT_FILE, enabledPlugins) or host-filesystem hooks into the container. See examples/settings.docker.json for a starting point.
If you keep more than one host Claude config (e.g. a personal ~/.claude/ and a work-only ~/.claude-work/), point the wrapper at the one you want with --claude-dir=PATH or the CLAUDE_DOCKER_CONFIG_DIR env var:
claude-docker --claude-dir=~/.claude-work ~/repo
CLAUDE_DOCKER_CONFIG_DIR=~/.claude-work claude-docker ~/repoThe chosen dir takes the place of ~/.claude for every item in the parity table above (agents, skills, commands, CLAUDE.md, statusline, settings.docker.json).
user.name and user.email from your global git config (~/.gitconfig) are forwarded automatically as GIT_AUTHOR_NAME/GIT_AUTHOR_EMAIL/GIT_COMMITTER_NAME/GIT_COMMITTER_EMAIL so in-container git commit works out of the box with your real identity — no git -c user.email=... dance, no wrong-author commits. Not gated by a flag: identity is already public on every commit you've made. Signing keys, credential helpers, aliases, and hooks are NOT forwarded — those are host-specific (keychains, absolute paths) and would misfire inside the container.
run.sh exports CLAUDE_DOCKER_FLAGS into the container with the comma-separated list of active opt-ins (gh, gh-direct, aws, glab, tfe, ephemeral, ro) and wraps the host statusline script so a yellow docker:<flags> tag is prepended to whatever your personal statusline renders. The variable is set by the wrapper for the statusline to read — not a user-tunable knob. --yolo / --dangerously-skip-permissions is not surfaced here — Claude Code's own mode indicator already makes it obvious. The wrapper is a no-op passthrough when no opt-ins are active, so your statusline looks unchanged on a plain claude-docker ~/repo.
The image sets IS_SANDBOX=1 — historically required to let --yolo / --dangerously-skip-permissions work when claude ran as root. The entrypoint now drops to the host UID before exec'ing claude, so the root-user check no longer triggers in steady state; IS_SANDBOX=1 remains as a safety net for the legacy HOST_UID=0 fall-through path. OS-level hardening comes from --cap-drop ALL (with CHOWN, SETUID, SETGID, DAC_READ_SEARCH re-added for transient entrypoint use only), --security-opt no-new-privileges, the Docker default seccomp profile, --init (tini reaps subprocess zombies), and the bind-mount layout. See File ownership and Threat model below.
Credentials are opt-in per run — see Credential opt-in above for the per-flag effect, mounts, and env-var forwarding. The subsections below cover the two workflows that need more than a one-line table cell.
Standard SSO usage works unchanged: aws sso login --profile X && export AWS_PROFILE=X on the host, then claude-docker --aws. The container reads the short-lived SSO bearer token from ~/.aws/sso/cache via the read-only mount.
If you'd rather not mount sso/cache either, flatten to env vars after login:
aws sso login --profile X
eval "$(aws configure export-credentials --profile X --format env)"
claude-docker --aws ...Container then uses AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN and the SSO cache is not needed inside. Temp creds freeze at container start (~1h TTL).
--gh starts a per-session auth proxy sidecar (pinned Caddy image caddy:2.11.4@sha256:844f60b64e4724a5aa8245e019dace0d3f199f7433ce6c57676cb30a920dbad9, override via CLAUDE_DOCKER_PROXY_IMAGE) that holds the real GitHub token so the agent container never sees it. Token discovery is unchanged: GH_TOKEN / GITHUB_TOKEN, else host gh auth token, else a silent skip — no token found means no sidecar, and the session behaves like the legacy no-token fallback (empty GH_TOKEN, in-container gh auth login still works and persists as before).
When a token is found:
- The agent container gets a placeholder
GH_TOKEN=claude-docker-proxy— enough forghto consider itself authenticated.gh auth tokeninside the container returns this placeholder, not your real token, and it doesn't appear anywhere in the container's environment or filesystem. github.com,api.github.com, anduploads.github.comresolve to the sidecar via--add-host.objects.githubusercontent.comandcodeload.github.com(pre-signed release/archive URLs) are not intercepted — they resolve normally and never see the token.- The sidecar terminates TLS for those three hostnames with a CA generated fresh for the session; the private key never leaves the sidecar and is destroyed with it at teardown. The public root is installed into the agent container's trust store by the entrypoint (
update-ca-certificates, before privilege drop), andNODE_EXTRA_CA_CERTSpoints at it, sogit,gh, and node-based tooling all trust it natively. - The sidecar injects the real
Authorizationheader in transit —Basic base64(x-access-token:<token>)forgithub.com(git smart-HTTP),Bearer <token>forapi.github.com/uploads.github.com— replacing anything the client sent./root/.config/ghstays tmpfs-masked while the sidecar is active: the placeholder token already satisfiesgh, so persisted in-container login state would just be a second, unneeded secret.
Isolation and lifecycle. Each invocation gets its own network (claude-gh-<id>) and sidecar (claude-gh-proxy-<id>), so concurrent sessions never share a token copy, a CA, or traffic. Teardown happens in run.sh's existing EXIT trap, extended and installed before any sidecar or network is created, so a failure mid-startup can't leak either resource. Startup is fail-closed: if the sidecar won't start or its CA can't be retrieved in time, run.sh tears everything down and exits with an error — it never falls back to forwarding the real token. run.sh prints the sidecar's container name at startup.
Re-login and rotation are host-managed. Under the proxy, GitHub auth is not something you manage from inside the container. gh auth status reports authentication via the GH_TOKEN env var (the placeholder), so gh auth login inside the container is a no-op: gh won't override an env-var token, /root/.config/gh is masked and ephemeral, and the sidecar rewrites the Authorization header on every request regardless of what's stored inside. To switch account or change scopes, do it on the host (gh auth login / gh auth refresh, or export a different GH_TOKEN) and relaunch — the sidecar reads the host token fresh at each container start, so a new session is how a changed credential flows in. A running container keeps working on the token it captured at launch until that container exits; host-side changes never propagate into it live. (In-container gh auth login only works in the two no-sidecar modes: --gh-direct, and --gh when no host token was found.)
Revoking vs. re-login — they are not the same.
gh auth logout/login/refreshonly change your host's local credential store; none of them revokes a previously-issued token at GitHub. GitHub CLI's OAuth token is long-lived, so a token captured earlier (by a running sidecar, or exfiltrated) stays valid until you explicitly revoke it: for OAuth login, GitHub → Settings → Applications → Authorized OAuth Apps → GitHub CLI → Revoke; for a PAT, delete it under Settings → Developer settings. Relaunching only stops a new container from using the old token — it does not invalidate the old one.
Filtering and policy. The generated Caddyfile blocks the one broadly destructive call by default: DELETE on /repos/{owner}/{repo} gets a 403 naming the claude-docker gh-proxy policy and never reaches GitHub. Extend it with CLAUDE_DOCKER_GH_POLICY=<path> pointing at a Caddyfile snippet — run.sh stages it and imports it into the api.github.com site block only (a snippet written for github.com or uploads.github.com traffic has no effect). Policy config lives solely in the sidecar; the agent container can neither read nor write it.
Audit log. Every proxied request (method, path, status — no headers, no token) is written as structured JSON to the sidecar's stdout. View it live with docker logs <sidecar-name> (the name run.sh prints at startup). The log is deliberately not persisted past the session — it's meant for live debugging, not a compliance trail. It's still a net improvement: host-side gh usage has no audit log at all today.
--gh-direct restores the pre-proxy behavior: the real token is forwarded straight into the agent container as GH_TOKEN, no sidecar involved. Use it for custom-hostname GitHub — Enterprise Server (github.mycompany.com) or GHEC data residency (*.ghe.com) — where the sidecar can't intercept the right hostnames, or on hosts that can't pull the Caddy image. github.com organizations under a GitHub Enterprise Cloud account use the standard github.com / api.github.com hostnames and are fully covered by the proxy — --gh-direct is only needed for organizations on a genuinely custom hostname. Passing --gh and --gh-direct together is a startup error, and the statusline tags them distinctly (gh vs gh-direct) so a riskier direct-forwarding session is visible at a glance.
Limitations:
- TLS clients that don't read the OS trust store — notably Python's
certifi-bundled CA set — get certificate errors against the three intercepted hostnames, since they never see the entrypoint-installed session CA. Workarounds: point the tool at the system store explicitly (anSSL_CERT_FILE-style override) or use--gh-direct. - SSH remotes (
git@github.com) remain unsupported, as before —--ghnever mounted a key or agent. The failure mode changes from an auth prompt to connection-refused, sincegit@github.comnow resolves to the sidecar on a port it doesn't serve. - git-LFS is expected to work unchanged: the batch endpoint on
github.comgets the sameAuthorizationinjection as any other git smart-HTTP request, and the actual object transfer happens against pre-signed, non-intercepted hosts. It's covered by the manual checklist rather than called out as a limitation.
Standard usage targets app.terraform.io (HCP Terraform):
# One-time on the host: writes ~/.terraform.d/credentials.tfrc.json
terraform login app.terraform.io
# Per session
claude-docker --tfe ~/repo
# Inside the container, fetch the project-pinned terraform version
tfenv install # reads .terraform-version, downloads from releases.hashicorp.com
terraform planThe image ships tfenv (a pure-bash terraform version manager) and does not ship a pre-installed terraform binary version — versions are project-pinned (required_version / .terraform-version) and a single bundled version would drift against real workspaces. tfenv install writes terraform binaries under /opt/tfenv/versions/, which is not in the claude-code-root named volume; downloads do not persist across docker run --rm exits. Power users can build a child image (FROM claude-code:local) that runs tfenv install <version> at build time to bake a specific version into a derived image.
Token alternative: instead of (or in addition to) the credentials file, export TF_TOKEN_app_terraform_io=<token> on the host and --tfe will forward it. The terraform CLI honours both.
⚠️ Use with care — the npmrc/pip.conf mounts are whole-file, not just the registry line. Everything in a mounted file becomes readable inside the container, including credentials and settings unrelated to your package feed. Inspect these files before using:
~/.npmrcoften carries tokens for several registries (npmjs.org, GitHub Packages, other scoped feeds) plus unrelated npm settings — all of it spills over, not just your private feed's entry.pip.conflikewise carries any global pip settings you've set, not onlyindex-url.~/.netrcis deliberately NOT mounted — as a machine-keyed store of logins for arbitrary unrelated hosts it's the broadest offender, so--registrynever forwards it. Put registry auth in~/.npmrc/pip.conf/ the index URL /UV_INDEX_*_PASSWORDinstead.The forwarded env vars are tightly scoped (named individually), so the over-share is specific to the npmrc/pip.conf file mounts. To minimise exposure, prefer the env-var channel or keep registry-only config files, and remember the container has full network egress (see Threat model).
--registry makes the in-container package managers resolve against a private feed (AWS CodeArtifact, Artifactory, Nexus, GitLab/Azure, …) the same way your pipelines do — without inventing any claude-docker-specific config. It surfaces the package managers' own native config from the host, read-only:
| Channel | npm / pnpm | uv | pip / pipenv |
|---|---|---|---|
Config file (:ro mount) |
~/.npmrc (or npm_config_userconfig) |
~/.config/uv/uv.toml |
platform pip.conf (macOS: ~/Library/Application Support/pip/pip.conf, Linux: ~/.config/pip/pip.conf) |
| Env vars (forwarded when set) | npm_config_registry, NPM_CONFIG_REGISTRY, NODE_AUTH_TOKEN, NPM_TOKEN |
UV_INDEX_URL, UV_DEFAULT_INDEX, UV_EXTRA_INDEX_URL, UV_INDEX, UV_KEYRING_PROVIDER, and any UV_INDEX_<NAME>_USERNAME / _PASSWORD |
PIP_INDEX_URL, PIP_EXTRA_INDEX_URL, PIP_TRUSTED_HOST, PIPENV_PYPI_MIRROR |
(~/.netrc is intentionally absent from this table — see the caution above.)
Mounts are read-only and composable with other flags; --registry does not require --aws. Resolution policy is whatever your host config already expresses: setting a default registry/index natively replaces the public default (confining resolution to your feed), and re-adding public registries is done in your own native config — the wrapper imposes no policy of its own.
If you've relocated your npm config via npm_config_userconfig / NPM_CONFIG_USERCONFIG, that path is sourced instead of ~/.npmrc (and still mounted at the container's default /root/.npmrc), so a relocated config doesn't silently fall through to public npm.
Standard AWS CodeArtifact flow (the per-tool login commands write the token into the native config files the flag then mounts):
# One-time on the host, per ecosystem you use:
aws codeartifact login --tool npm --domain D --domain-owner ACCT --repository R # → ~/.npmrc
aws codeartifact login --tool pip --domain D --domain-owner ACCT --repository R # → pip.conf
# uv: export the index + token (uv has no `codeartifact login`):
export UV_INDEX_URL="https://aws:$(aws codeartifact get-authorization-token --domain D --domain-owner ACCT --query authorizationToken --output text)@D-ACCT.d.codeartifact.REGION.amazonaws.com/pypi/R/simple/"
# Per session
claude-docker --registry ~/repoThe captured token freezes for the life of the container (a CodeArtifact token is ≤12h) — when it expires, re-run the host login/export and relaunch. Same posture as the --aws SSO credentials.
No Python is bundled. pip/pipenv themselves are not in the image (uv fetches its own Python; project runtimes live in child images). Run a pip-based tool via uvx pipenv … — pipenv shells out to pip, which reads the forwarded pip.conf / PIP_*. Caveat: if your feed is fully locked down with no public upstream, pipenv itself must be mirrored there for uvx to fetch it.
Build vs. runtime. --registry is runtime-only. The image build always resolves its own tooling (claude-code, openspec, pnpm) against the public npm registry / PyPI regardless of any private registry configured on your host — your ~/.npmrc and npm_config_* env are neither in the build context nor inherited by Dockerfile RUN steps. That isolation is what keeps the build reproducible from the committed pins. Routing the build itself through a private registry is intentionally out of scope.
Files created inside the container appear on the host owned by the user who launched claude-docker, not by root. The wrapper forwards HOST_UID / HOST_GID and the in-container entrypoint creates a matching passwd entry and drops to it via runuser before exec'ing claude. Persistent state in the claude-code-root and claude-code-home named volumes is chowned on first start, so an existing volume from before this change is fixed up the next time you run claude-docker.
The container narrows blast radius vs. running claude --yolo on the host, but it is not a full sandbox:
- Protected: host filesystem outside your passed workspaces, host
~/.aws/credentials(long-lived keys), host AWS/glab config dirs are read-only from inside (container can't persist changes back). - Exposed (per session): your passed workspaces are read-write (unless
--ro); host credentials when opted in — short-lived AWS SSO bearer tokens (~/.aws/sso/cache), the glab config token,~/.terraform.d/credentials.tfrc.json, andGITLAB_TOKEN/TF_TOKEN_app_terraform_io/AWS_*env vars are all readable inside the container; under--gh-direct(but not plain--gh, see GitHub auth proxy), the realGH_TOKENis readable inside the container too; under--registry, the full contents of mounted~/.npmrc/pip.conf(and forwarded*_TOKEN/UV_INDEX_*_PASSWORDenv) are readable — and those files can hold tokens for registries beyond the one you intended (~/.netrcis deliberately not mounted, see Private package registries); full outbound network with no egress filtering. - GitHub via the proxy (
--gh): the real token itself no longer reaches the agent container — the biggest prior exposure for this flag is gone. What remains is live capability: a compromised session can still act on GitHub through the sidecar for the life of the session, bounded by its policy (default: no repo deletion, extensible viaCLAUDE_DOCKER_GH_POLICY) and recorded in its audit log if you capture it (docker logs <sidecar-name>) before teardown. The sidecar is a policy point and a record, not a guarantee that a compromised session can't act on GitHub at all. - Exposed (cross-session): the persistent
claude-code-rootandclaude-code-homenamed volumes hold the Claude OAuth token, in-containergh/glab/terraform loginstate, shell history, and conversation history.claude --resumecan replay sessions from any past workspace — see Resuming sessions across workspaces. Skipped under--ephemeral. - Runtime code-fetch:
npx,pnpm dlx,uvx, andtfenv installfetch and execute arbitrary code from public sources on first use — npm and PyPI for the package managers,releases.hashicorp.comfortfenv install. Under--yolo, a prompt-injected workspace can trigger these.pnpm dlxadds zero marginal blast radius vs the already-reachablenpx;uvxis a new PyPI execution primitive (no Python runtime existed in the image before);tfenv installis a new HashiCorp release-channel execution primitive whose downloadedterraformbinary is intentionally not sha256-pinned in the image (versions are project-pinned via.terraform-version, so the image stays neutral on version policy). Build-time installs of the CLIs themselves are pinned by version + sha256 where the ecosystem supports it (uv binary, glab .deb, AWS CLI, tfenv source archive), and by version only for npm-backed packages (claude-code, openspec, pnpm) —--ignore-scriptsblocks lifecycle scripts at install time but does not protect against a compromised registry serving a malicious tarball at the pinned version. - Private registries (
--registry): this narrows where the package managers resolve packages — pointinguv/pnpm/ pip at a curated private feed instead of public npm/PyPI — which can reduce dependency-confusion exposure, but only as much as your host config and the feed's upstream setup dictate. It is registry-resolution config, not network egress filtering:npx,git+httpsinstalls,curl, and every other egress path are unaffected, and--yoloruntime code-fetch (above) still reaches whatever the resolved feed serves. Treat it as supply-chain hygiene, not a network boundary. - If a session is compromised: assume exfiltration already happened (full network egress). Then: rotate the host sessions for every flag that was passed —
glab auth login,aws sso login,terraform login; under--registry, re-runaws codeartifact login/ rotate the npm·PyPI registry tokens exposed via the mounted~/.npmrc/pip.conf. GitHub is different under--gh: the real token never entered the container, so there's no token to rotate on that basis alone — but review what the session did through the proxy during its lifetime (the sidecar's audit log helps, if you captured it viadocker logsbefore teardown), and revert any resulting GitHub-side actions. If the token itself may be exposed (a--gh-directsession, a version predating this proxy, or any doubt), revoke it — not merelygh auth logout/login, which only clear local state and leave the issued token valid at GitHub: revoke the GitHub CLI authorization under Settings → Applications → Authorized OAuth Apps, or delete the PAT under Settings → Developer settings if you used one. In all cases: revoke the Claude OAuth credential, and clear the named volumes (docker volume rm claude-code-root claude-code-home) to flush in-container auth state and cross-workspace conversation history thatclaude --resumecould otherwise replay.
Hardening applied at runtime: --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_READ_SEARCH — the four added caps are held only during entrypoint setup and cleared from the effective / permitted / ambient sets by the kernel when the entrypoint drops UID 0 → host UID (the bounding set retains them but is inert under no-new-privileges), so claude itself runs with no usable capabilities; --security-opt no-new-privileges; --init (tini reaps subprocess zombies — runuser would otherwise be PID 1); container starts as root and drops to the host user before exec'ing claude (see File ownership); the Docker default seccomp profile; scoped workspace bind-mounts; tmpfs masks over non-opted-in credential paths. Build-time: pinned base image digest, sha256-verified downloads where the ecosystem supports it (uv, glab, AWS CLI, tfenv source); npm packages (claude-code, openspec, pnpm) are version-pinned with --ignore-scripts but not sha256-verified — a compromised npm registry serving a malicious tarball at the pinned version would not be caught at build time. Not applied: read-only root filesystem, user-namespace remapping, custom seccomp profile (Docker's default is in use), network egress filtering, resource limits.
uv, glab, aws-cli, and tfenv are downloaded directly from GitHub/GitLab/vendor sites rather than from a language package registry, so nothing else verifies the bytes. Each is pinned to a version and a per-architecture sha256 that the Dockerfile checks (sha256sum -c) before installing. The npm-installed tools (claude-code, openspec, pnpm) are pinned by version only — npm install already verifies the tarball against the registry's dist.integrity, and CI additionally runs npm audit signatures.
The pins live in version-controlled fragments under pins/, one pins/<tool>.env per tool, which the Dockerfile COPYs and sources at build time — so docker build is reproducible from the committed files.
Refresh them with update_pins.py (a single stdlib-only Python file, run via uv):
uv run update_pins.py # refresh all tools (7-day soak)
uv run update_pins.py --soak 14 # wider soak window
uv run update_pins.py --block-major-bumps # stay within each tool's current major
uv run update_pins.py --pin uv=0.12.3 # pin one tool to a specific versionFor each tool it selects the newest stable version at least 7 days old, downloads the amd64 and arm64 artifacts, computes their sha256s, and rewrites pins/<tool>.env — the soak window gives a release time to be vetted (and a bad one pulled) before it enters the image. The script prints a report — each old → new bump with its age, a ⬆ MAJOR marker on major-version jumps, held lines for versions still inside the soak window, and ⚠ reminders for the manual pins — then review the diff, build to test, and commit. By default a major-version bump is taken once it has soaked; --block-major-bumps keeps a run within each tool's current major. Set GITHUB_TOKEN (or GH_TOKEN) to avoid GitHub's unauthenticated rate limit.
nodejs (from NodeSource's signed apt repo) and the ubuntu base-image digest are pinned manually: the script reports base-digest drift but does not rewrite it, since moving the base OS is a deliberate, separately-reviewed change.
The GitHub auth proxy sidecar's Caddy image is pinned manually too, but lives outside this whole mechanism: the digest is a default in run.sh (CLAUDE_DOCKER_PROXY_IMAGE), not a file under pins/, and update_pins.py never touches it. That's deliberate — a Caddy upgrade can change Caddyfile directive semantics, i.e. the security-critical config this feature generates, so bumping it means reading the changelog and validating the generated Caddyfile against the new version by hand, not taking an automated version bump on faith.
Git worktrees embed the path between the worktree and its repo's .git/ in two link files. By default those paths are absolute, so a worktree created on the host breaks inside the container (and vice versa) because the same files sit at different absolute paths in each environment.
No host config change needed. For every workspace whose .git/config is a regular file (i.e. the main repo, not a worktree pointer), claude-docker overlays a container-only copy of .git/config that declares extensions.relativeWorktrees = true and worktree.useRelativePaths = true. The host's on-disk .git/config is never touched. Worktrees created inside the container therefore get relative paths, and those link files are then portable to the host without any opt-in.
This asymmetry is deliberate: the extension flag — when written into the host's .git/config — blinds tools that bundle an older libgit2 (notably gitstatusd, which powers the Powerlevel10k git prompt), because they refuse to open a v1 repo declaring an extension they don't know. Keeping the flag container-only sidesteps that.
To convert pre-existing absolute-path worktrees: from inside the container, run git worktree repair --relative-paths <worktree-path>. New worktrees added in the container get relative paths automatically.
Trade-off: container-side git config writes (e.g. git remote add ... writing to local config) land in the ephemeral overlay and are discarded when the container exits. Persistent git config edits should happen on the host.
Fallback — git worktree repair (no flag), inside the container:
git worktree repairUse this when you passed a repo and a sibling worktree as separate workspace args (claude-docker ~/repo ~/repo-feature). Sibling-flattened mounts collapse the parent directory, so the relative offset between worktree and repo is not preserved by the bind mount and relative paths can't help.
Caveats:
- The overlay only applies to workspaces whose
.gitis a real directory (the main repo). If you mount only a worktree without its main repo, no overlay is created for it. Mount the main repo alongside if you need bidirectional worktree work. - Relative paths assume the worktree's location relative to the repo's
.git/is the same in both environments. Nested layouts (e.g.<repo>/.claude/worktrees/<name>) always satisfy this; moving a worktree to a totally different parent dir breaks both relative and absolute setups.
Cmd-V to paste a clipboard image doesn't work inside the container — Claude Code reads the macOS clipboard via OS APIs that a Linux container can't reach. Workaround: save the image into any workspace you mounted (e.g. Cmd-Shift-4 to Desktop, then move it into ~/repo) and reference it from Claude with @screenshot.png.
Claude's teammate feature needs tmux. Two modes:
| Flag | Env var equivalent | Effect |
|---|---|---|
| (none — default) | (unset) | No tmux. Teammates fall back to Claude's in-process mode; cycle with Shift+Down. |
--tmux |
CLAUDE_DOCKER_TMUX=1 |
Plain tmux. Teammates = tmux splits in one terminal tab; switch with C-b + arrow keys. Any terminal. |
--iterm |
CLAUDE_DOCKER_TMUX=cc |
tmux -CC (iTerm2 control mode). Teammates = native iTerm2 panes/tabs. macOS + iTerm2 only. |
The env vars are handy for export in your shell rc; the flags are handy for one-offs. Both modes need teammateMode set in settings.docker.json — see examples/settings.docker.json. The image already bakes in CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1, so you don't need to add that env var yourself.
- Launch from a tab that is not already inside a host
tmux -CCsession — nesting degrades the inner server to plain splits. - iTerm2 → Settings → General → tmux → Attaching → "When attaching, restore windows as:" →
Tabs in the attaching windowkeeps the gateway and Claude's content inside one iTerm2 window (default isNative windows, which spawns a separate window). - iTerm2 → Settings → General → tmux → "Automatically bury the tmux client session after connecting" → hides the
** tmux mode started **gateway tab on attach so only the Claude tab is visible. Retrieve the gateway later via Session → Buried Sessions if needed. - The UTF-8 warning from earlier builds is resolved — the image sets
LANG=C.UTF-8andrun.shpassestmux -u.
When a project needs extra tooling (language runtimes, package managers, project-scoped CLIs) that doesn't belong in the base image, build a child image and reuse this wrapper via the CLAUDE_DOCKER_IMAGE env var — no need to fork run.sh.
In the child repo:
# .claude-docker/Dockerfile
FROM claude-code:local
RUN ... # add your extras here#!/usr/bin/env bash
# claude-docker (project-root entrypoint)
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
IMAGE="claude-code-myproject:local"
docker build -t "$IMAGE" "$here/.claude-docker"
CLAUDE_DOCKER_IMAGE="$IMAGE" exec claude-docker "$@"The child Dockerfile uses FROM claude-code:local (locally-built tag) — assumes the base has been built once on the host. Every wrapper flag (--aws, --gh, --ephemeral, --ro, --iterm, …) keeps working because the child script just exec's into this one with a different image tag.
Any extra package managers a child image installs (rustup, go, ruby, etc.) add to the runtime code-fetch surface noted under Threat model — they don't replace the existing npx/pnpm dlx/uvx primitives.
The container's runtime behaviour — privilege-drop, capability set, credential
isolation, file ownership — is exercised by a smoke harness
(smoke/smoke.sh + smoke/assert-in-container.sh).
It runs in CI on Linux on every change (in the
docker-build job, reusing the built image), across a matrix of cells: host UID
1000 / 501 / 0, cold and warm volumes, the --aws / --glab / --tfe opt-ins
(singly and combined), --ephemeral, and --ro. Most of the container's
behaviour lives inside Docker's Linux VM and is identical regardless of host OS,
so Linux CI covers the bulk of it.
Run a cell locally against a built image:
IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --optins=aws,glab,tfeThe GitHub auth proxy sidecar (see GitHub auth proxy) has its own harness, tests/gh-proxy-integration.sh: it drives run.sh end-to-end against a mock GitHub upstream, credential-free and CI-runnable, since smoke.sh never invokes run.sh and CI has no real GitHub credentials to test against.
There is no automated macOS CI job: GitHub-hosted macos-latest runners
can't reliably provision a Docker daemon (the vz VM driver fails to boot under
the runner's nested-virtualization limits, and the qemu driver hits an upstream
Lima crash), so a hosted job can't even reach the assertions — and Colima's
file-sharing may not match Docker Desktop's anyway. The one behaviour unique to
macOS is virtiofs collapsing st_dev across bind mounts, which changes how
entrypoint.sh's -xdev chown-prune treats the :ro mounts under /root
(see entrypoint.sh:30-45). Verify it by hand on a real Mac with Docker Desktop
before shipping changes to entrypoint.sh / run.sh / Dockerfile:
- Run the smoke cells on macOS:
IMAGE=claude-code:local bash smoke/smoke.sh --uid="$(id -u)" --volstate=warmand… --ro=1and… --optins=aws,glab,tfe— the entrypoint must reach the dropped process with no spuriousentrypoint: WARNdespite the:romounts under/root. - File ownership round-trips to the host user and is editable without
sudoon a real~/repobind mount. - macOS Keychain
ghflow:--ghwith noGH_TOKEN/GITHUB_TOKENexported falls back togh auth token; in-containerghis authenticated. - Real AWS SSO (
--aws) and Terraform Cloud (--tfe) reach their endpoints from inside the container via the mounted config. --iterm(tmux -CC) renders native panes (control mode can't be asserted headlessly).- GitHub auth proxy, real credentials:
gh api /userthrough the sidecar (--gh) returns your real identity whileecho $GH_TOKENin the container still shows the placeholder; clone and push a private repo over HTTPS with no credential prompt;git lfs pullsucceeds through the proxy (batch call ongithub.comgets the injected header, object transfer hits pre-signed hosts unmodified); the statusline tag readsghfor a proxied session andgh-directfor--gh-direct. - GitHub auth proxy, platform parity (not macOS-specific — grouped here because it's likewise outside the CI harness): under podman, sidecar
network create/inspect(reading the sidecar IP) /cp(CA extraction) /--add-hostbehave like their docker equivalents; from Git Bash on Windows, the staged Caddyfile and CA-certificate mounts reachpodman.exe/docker.exewith intact paths (samehostpath()translation as the rest of the wrapper).
Behavioural requirements live in openspec/specs/; change history in openspec/changes/archive/.
Licensed under the Apache License, Version 2.0 — see LICENSE and NOTICE.
The software is provided on an "AS IS" basis, without warranties or conditions of any kind, express or implied, including any warranty as to its security, fitness, or suitability for a particular purpose. The container narrows blast radius but is not a full sandbox (see Threat model) — you are responsible for assessing whether it meets your own security requirements before use. claude-docker installs and runs third-party software under its own license and is not affiliated with or endorsed by Anthropic.
