Git commits inside the sandbox need a user.name/user.email. Carries over
a throwaway copy of the host's ~/.gitconfig, generated by bin/claude-sandbox
and mounted as its own directory (not the real file, and not a single-file
bind mount at ~/.gitconfig) with GIT_CONFIG_GLOBAL pointing git at the file
inside it.
That took two iterations to get right, both caught by actually testing gh
auth setup-git (the thing that makes GitHub read-only from in here) rather
than just checking the identity resolved:
- Mounting the real file read-only broke gh auth setup-git outright, which
needs to *write* a credential-helper entry into the global gitconfig
("could not write config file: Device or resource busy").
- Mounting a read-write *copy* of the file directly at ~/.gitconfig hit the
exact same error - a single-file bind mount can't have its backing inode
replaced, and git config writes via the usual write-temp-then-rename
pattern. Only a directory mount, with git pointed at a file inside it,
actually supports that.
Verified end to end this time: gh auth setup-git completes cleanly, the
credential-helper entries land in the sandbox's copy, user.name/email
resolve correctly, and the real host ~/.gitconfig is untouched throughout.
Also has bin/claude-sandbox warn (non-fatally) at launch if ssh-agent has
zero identities loaded - the forwarded socket still connects fine either
way, but git-over-ssh silently fails until a key's been ssh-add'ed, which
otherwise only surfaces confusingly deep into a session as a failed push.
Confirmed the forwarding mechanism itself works correctly by testing with
a throwaway key - the reported failure was the host agent having no
identities loaded, not a bug in the forwarding.
Test suite grew to 26 with a check for the gitconfig mount shape.
125 lines
6.8 KiB
YAML
125 lines
6.8 KiB
YAML
# Not meant to be run directly with plain `docker compose up`: WORKSPACE_DIR,
|
|
# SSH_AUTH_SOCK, etc. must point at real host paths for a specific project.
|
|
# Use `bin/claude-sandbox <path-to-project>` instead, which sets these and
|
|
# invokes this file.
|
|
services:
|
|
claude:
|
|
build:
|
|
context: ../..
|
|
dockerfile: docker/claude-sandbox/Dockerfile
|
|
args:
|
|
TZ: ${TZ:-UTC}
|
|
image: claude-sandbox:latest
|
|
cap_add:
|
|
- NET_ADMIN
|
|
- NET_RAW
|
|
environment:
|
|
GITEA_SSH_HOST: ${GITEA_SSH_HOST:-}
|
|
GITEA_API: ${GITEA_API:-}
|
|
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
|
# Read-only fine-grained GitHub PAT (Contents + Metadata: read-only,
|
|
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
|
|
# itself for github.com over HTTPS - see the firewall/Dockerfile for
|
|
# why that's the only path GitHub is reachable through at all.
|
|
GH_TOKEN: ${GH_TOKEN:-}
|
|
# Points git at the gitconfig copy mounted below instead of the
|
|
# default ~/.gitconfig - see that mount's comment for why it can't
|
|
# just live at ~/.gitconfig directly.
|
|
GIT_CONFIG_GLOBAL: /home/node/.gitconfig-sandbox/.gitconfig
|
|
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
|
|
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
|
|
# ship DNS-query and firewall allow/reject logs to. Its host is
|
|
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
|
|
# logging is skipped entirely if unset.
|
|
LOKI_URL: ${LOKI_URL:-}
|
|
SSH_AUTH_SOCK: /ssh-agent
|
|
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
|
|
# Docker's own tty allocation defaults to a generic TERM (e.g. plain
|
|
# "xterm", dropping 256-color and any extended-keys terminfo bits),
|
|
# not whatever the host's actual terminal/tmux session is using.
|
|
# Forward the real value so nvim negotiates key sequences (e.g. the
|
|
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
|
|
# the terminfo entry the rest of the chain actually expects.
|
|
TERM: ${TERM:-xterm-256color}
|
|
volumes:
|
|
# Mounted at the same absolute path as on the host (rather than a
|
|
# fixed /workspace) so Claude Code's project/session-history keying
|
|
# (based on cwd) lines up with the host, letting a session started
|
|
# here be resumed on the host with `claude --resume`.
|
|
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
|
- ${HOME}/.claude:/home/node/.claude
|
|
# settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
|
|
# not runtime state - hooks run automatically with no approval gate,
|
|
# so a compromised session writing to them would plant something that
|
|
# then executes unsandboxed on the host next time Claude Code starts
|
|
# there. Layer them back read-only on top of the read-write parent
|
|
# mount above (Docker applies more specific mounts on top of broader
|
|
# ones); everything else under ~/.claude (session/project history,
|
|
# credentials refresh, caches, etc.) still needs to stay read-write.
|
|
#
|
|
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
|
|
# bin/claude-sandbox-generated copy with sandbox.enabled forced to
|
|
# false), not the real file directly - Claude Code's own internal
|
|
# per-command sandboxing is redundant on top of this container (and
|
|
# can't actually function inside it anyway), and mounting it read-only
|
|
# means it can't be patched from inside, so the substitution has to
|
|
# happen on the host before the container ever starts.
|
|
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:/home/node/.claude/settings.json:ro
|
|
- ${HOME}/.claude/CLAUDE.md:/home/node/.claude/CLAUDE.md:ro
|
|
- ${HOME}/.claude/plugins:/home/node/.claude/plugins:ro
|
|
# ~/.claude.json is a single file mixing live app state (project
|
|
# registry, onboarding flags, caches) with config (e.g. mcpServers, if
|
|
# ever set) that can't be split at the mount level - left read-write
|
|
# since most of its content must stay writable for normal operation.
|
|
# See README Security notes for this residual risk.
|
|
- ${HOME}/.claude.json:/home/node/.claude.json
|
|
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
|
|
# ~/specs/ rather than a project-local path - that instruction is
|
|
# already in effect inside the container (CLAUDE.md carries over), so
|
|
# the directory it points at needs to actually exist and be writable
|
|
# here too, or an agent following it just writes into an ephemeral
|
|
# path inside the container that vanishes with the session.
|
|
- ${HOME}/specs:/home/node/specs
|
|
# Global git identity (user.name/user.email) - a throwaway copy of it,
|
|
# in its own directory rather than a single file bind-mounted at
|
|
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
|
|
# credential-helper entry into it via git's usual write-temp-then-
|
|
# rename pattern, which fails on a single-file mount ("Device or
|
|
# resource busy" - Linux won't let that mount point's backing inode be
|
|
# replaced). GIT_CONFIG_GLOBAL below points git at the file inside
|
|
# this directory instead. See bin/claude-sandbox - nothing written
|
|
# here ever reaches the user's real ~/.gitconfig.
|
|
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:/home/node/.gitconfig-sandbox
|
|
# ~/.claude/hooks is a symlink into ~/dotfiles/config/claude/hooks (an
|
|
# absolute host path); mount dotfiles at the same path so it resolves
|
|
# in the container too. Read-only: hooks are only ever read/executed
|
|
# from here, never written.
|
|
- ${HOME}/dotfiles:${HOME}/dotfiles:ro
|
|
# Also mount at node's own $HOME/dotfiles: node's actual $HOME is
|
|
# /home/node (not the host user's $HOME above), and plenty of this
|
|
# repo's own config (nvim's clipboard-copy path, vimrc sourcing, etc.)
|
|
# assumes dotfiles lives at $HOME/dotfiles for whichever user it's
|
|
# running as.
|
|
- ${HOME}/dotfiles:/home/node/dotfiles:ro
|
|
- claude-code-bashhistory:/commandhistory
|
|
- nvim-data:/home/node/.local/share/nvim
|
|
- nvim-state:/home/node/.local/state/nvim
|
|
- nvim-cache:/home/node/.cache/nvim
|
|
- ${SSH_AUTH_SOCK:?set SSH_AUTH_SOCK to your running ssh-agent socket}:/ssh-agent
|
|
- ${SSH_KNOWN_HOSTS:-/dev/null}:/home/node/.ssh/known_hosts:ro
|
|
# Read-write scratch space for passing files back and forth with the
|
|
# sandboxed session (screenshots, temp scripts) without touching the
|
|
# actual project checkout - separate from WORKSPACE_DIR above, and a
|
|
# real host directory (not a named volume) so it's actually visible on
|
|
# both sides, persisting across sessions.
|
|
- ${SANDBOX_SHARE_DIR}:/home/node/shared
|
|
working_dir: ${WORKSPACE_DIR}
|
|
stdin_open: true
|
|
tty: true
|
|
|
|
volumes:
|
|
claude-code-bashhistory:
|
|
nvim-data:
|
|
nvim-state:
|
|
nvim-cache:
|