Files
dotfiles/docker/claude-sandbox/docker-compose.yml
T
branderson 7267e1ca77 Carry git identity into the sandbox; warn on an empty ssh-agent
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.
2026-07-20 01:03:20 -07:00

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: