Files
dotfiles/docker/claude-sandbox/docker-compose.yml
T
branderson 0b7857eb3f Mount ~/specs read-write; add bubblewrap/socat (partial fix)
~/.claude/CLAUDE.md (already carried into the container) tells agents to
write specs to ~/specs/ instead of a project-local path - that instruction
was already in effect inside the sandbox, but nothing mounted the
directory it points at, so an agent following it would write into a path
that vanishes with the container. Bind-mounted read-write at the fixed
host path ~/specs (auto-created if missing), same pattern as the shared
scratch space but not configurable - it's tied to the CLAUDE.md
convention, not a per-run choice.

Also adds bubblewrap/socat, which Claude Code checks for before offering
its own internal per-command sandboxing and /remote-control. This clears
the "Sandbox disabled: dependencies are missing" warning, but doesn't
actually fix /remote-control: bwrap still can't create a namespace on this
host ("kernel does not allow non-privileged user namespaces"), and even
adding CAP_SYS_ADMIN to the container only got further to a mount-
propagation permission error, not further progress. Making it actually
work would need privilege escalation approaching full --privileged mode,
which would undo most of this session's hardening - not pursued. Kept the
packages anyway since they at least make the startup warning accurate
rather than noise, in case a future decision revisits this tradeoff.

All 22 existing tests still pass.
2026-07-19 23:45:01 -07:00

103 lines
5.3 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:-}
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.
- ${HOME}/.claude/settings.json:/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
# ~/.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: