claude-sandbox: host-matching container user + dedicated headless SSH agent #3

Merged
branderson merged 5 commits from worktree-claude-sandbox-ssh-and-plugin-mounts into master 2026-07-22 13:05:38 -07:00
7 changed files with 418 additions and 71 deletions
+49 -8
View File
@@ -26,19 +26,53 @@ fi
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
echo "SANDBOX_SSH_KEY is set but not a file: $SANDBOX_SSH_KEY" >&2
exit 1
fi
if [ -n "$SANDBOX_SSH_KEY" ]; then
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
if [ "${key_perms: -2}" != "00" ]; then
echo "Warning: $SANDBOX_SSH_KEY is readable by group/other (mode $key_perms)." >&2
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
fi
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
# (see README "Headless git push"), not your real identity - it stays
# loaded in an in-container agent for the whole session, unattended.
# Nothing enforces that convention, so at least catch the common case
# of pointing it at a default personal keyfile by mistake.
case "$(basename -- "$SANDBOX_SSH_KEY")" in
id_rsa|id_ecdsa|id_ed25519|id_dsa|id_ecdsa_sk|id_ed25519_sk)
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
"not a dedicated one." >&2
echo "It will be loaded into the sandbox for the whole unattended session -" \
"see README 'Headless git push'." >&2
;;
esac
fi
have_forwarded_agent=1
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket." >&2
echo "Start ssh-agent and run ssh-add before using claude-sandbox." >&2
have_forwarded_agent=0
fi
if [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket, and SANDBOX_SSH_KEY is not set." >&2
echo "Either start ssh-agent and run ssh-add, or set SANDBOX_SSH_KEY to a dedicated" >&2
echo "key for headless use - see README 'Headless git push' section." >&2
exit 1
fi
# A valid socket with zero identities loaded forwards fine (the container
# sees a live, working agent) but is useless for git-over-ssh - warn now
# rather than have that surface confusingly deep into a session as a failed
# push. Non-fatal: a task might not need git-over-ssh at all.
if ! ssh-add -l >/dev/null 2>&1; then
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
echo "git-over-ssh (e.g. pushing to Gitea) will fail until you ssh-add a key." >&2
echo "git-over-ssh (e.g. pushing to Gitea) will fail until you ssh-add a key," >&2
echo "unless SANDBOX_SSH_KEY is also set." >&2
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
@@ -108,10 +142,17 @@ else
fi
export WORKSPACE_DIR
# The image's user is built to match this uid/gid/username exactly (see
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
export SANDBOX_USER="$(id -un)"
export SANDBOX_UID="$(id -u)"
export SANDBOX_GID="$(id -g)"
export SANDBOX_SHARE_DIR
export SANDBOX_SETTINGS_FILE
export SANDBOX_GITCONFIG_DIR
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}"
export GITEA_SSH_HOST="${GITEA_SSH_HOST:-}"
+39 -18
View File
@@ -61,17 +61,38 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
# Replace the base image's built-in `node` user (uid/gid 1000,
# $HOME=/home/node) with one matching the host user who runs
# bin/claude-sandbox - so absolute paths under $HOME (dotfiles, and
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
# which Claude Code resolves as a literal host-absolute string rather than
# relative to whatever $HOME happens to be) resolve identically inside and
# outside the container, instead of needing a one-off dual-mount for every
# individual thing that assumes the real host path. Defaults below
# reproduce today's node/1000/1000//home/node exactly, so a build that
# never overrides them (bin/claude-sandbox always does; the test suite
# never does) exercises this same rename path rather than a separate,
# untested one.
ARG SANDBOX_USER=node
ARG SANDBOX_UID=1000
ARG SANDBOX_GID=1000
ARG SANDBOX_HOME=/home/node
RUN userdel -r node 2>/dev/null; \
groupdel node 2>/dev/null; \
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
ENV SANDBOX_USER=$SANDBOX_USER
ENV SANDBOX_HOME=$SANDBOX_HOME
ARG USERNAME=node
# Ensure the sandbox user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
# Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \
&& touch /commandhistory/.bash_history \
&& chown -R $USERNAME /commandhistory
&& chown -R $SANDBOX_USER /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true
@@ -94,17 +115,17 @@ RUN useradd -r -s /usr/sbin/nologin ulogd
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
# not a fixed /workspace, so it can share ~/.claude project/session history
# with the host. ~/.claude is bind-mounted too, not baked in here.
RUN mkdir -p /home/node/.ssh && \
chown -R node:node /home/node/.ssh && \
chmod 700 /home/node/.ssh
RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 $SANDBOX_HOME/.ssh
# Pre-create the nvim data/state/cache dirs owned by node, since they're
# mounted as named volumes in docker-compose.yml: Docker seeds a fresh
# named volume from whatever already exists at the mount path in the
# image, ownership included, so this is what keeps those volumes
# node-writable instead of defaulting to root.
RUN mkdir -p /home/node/.local/share/nvim /home/node/.local/state/nvim /home/node/.cache/nvim && \
chown -R node:node /home/node/.local /home/node/.cache
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# since they're mounted as named volumes in docker-compose.yml: Docker
# seeds a fresh named volume from whatever already exists at the mount
# path in the image, ownership included, so this is what keeps those
# volumes writable by that user instead of defaulting to root.
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
@@ -159,7 +180,7 @@ ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Set up non-root user
USER node
USER $SANDBOX_USER
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
@@ -227,11 +248,11 @@ COPY docker/claude-sandbox/ulogd.conf /etc/ulogd-sandbox.conf
COPY docker/claude-sandbox/entrypoint.sh /usr/local/bin/
COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude
COPY bin/gitea-pr /usr/local/bin/gitea-pr
COPY config/nvim /home/node/.config/nvim
COPY config/nvim $SANDBOX_HOME/.config/nvim
USER root
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
chown -R node:node /home/node/.config/nvim && \
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.config/nvim && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
# Entrypoint runs as root (no USER node here) so it can set up the firewall
+77 -8
View File
@@ -24,6 +24,10 @@ into the container at runtime from wherever it actually sits on disk.
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 after `ssh-add`ing.
For a sandbox that needs to keep working unattended after your client
disconnects, see "Headless git push without agent forwarding" below —
`SANDBOX_SSH_KEY` is an alternative to this forwarded-agent requirement,
not just an addition to it.
- `GITEA_SSH_HOST` and `GITEA_API` exported in your shell (same variables
`bin/gitea-pr` already needs — see its `require_config`), and a token
available either via `GITEA_TOKEN` in the environment or at
@@ -73,6 +77,19 @@ 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
@@ -89,6 +106,43 @@ 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:
```sh
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:
```sh
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
@@ -137,8 +191,8 @@ 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 `node`-level bash access, which a
Chromium exploit couldn't exceed regardless.
compromised session already has full bash access as the container's own
user regardless, which a Chromium exploit couldn't exceed.
## Sandbox self-awareness
@@ -386,13 +440,28 @@ one can't happen.
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 `node` user has no `sudo`/setuid path back to root once the container
is up: `entrypoint.sh` runs as root, sets up the firewall directly, then
drops to `node` via `gosu` before execing `nvim`. A compromised session
can't re-run `init-firewall.sh` with a wider allowlist to open its own
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
second, independent credential-loading path with the same "signs for any
Gitea repo it has access to" shape as the forwarded agent above - the
mitigation is that it's meant to be a dedicated deploy key scoped to only
the repos that actually need push access, not your personal identity.
That scoping is a convention, not something enforced here: nothing checks
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
full personal identity into the container for the whole session (`bin/
claude-sandbox` warns if this looks like it might be happening - see
below - but doesn't block it). The key source itself is mounted read-only
at `/root/...`, not under the sandbox user's own home, specifically so
that user (which the session actually runs as) has no path to read the
raw key material - only the agent socket it's loaded into.
- 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.sh` runs as root, sets up the firewall directly, then drops to
that user via `gosu` before execing `nvim`. A compromised session can't
re-run `init-firewall.sh` with a wider allowlist to open its own
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and `node`
gets a permission error running `iptables` directly.
cleared from the effective/permitted sets on the `gosu` drop, and that
user gets a permission error running `iptables` directly.
## Tests
+44 -23
View File
@@ -9,7 +9,19 @@ services:
dockerfile: docker/claude-sandbox/Dockerfile
args:
TZ: ${TZ:-UTC}
image: claude-sandbox:latest
# Matches the container's own user to the real host user - see
# Dockerfile. Defaults reproduce today's node/1000/1000//home/node
# exactly if these aren't set (e.g. building directly, bypassing
# bin/claude-sandbox).
SANDBOX_USER: ${SANDBOX_USER:-node}
SANDBOX_UID: ${SANDBOX_UID:-1000}
SANDBOX_GID: ${SANDBOX_GID:-1000}
SANDBOX_HOME: ${HOME:-/home/node}
# Tagged per-user, not a fixed :latest - bin/claude-sandbox always
# passes --build, and two different accounts on a shared devbox
# building from their own checkouts would otherwise silently overwrite
# each other's image under the same tag.
image: claude-sandbox:${SANDBOX_USER:-node}
cap_add:
- NET_ADMIN
- NET_RAW
@@ -25,7 +37,7 @@ services:
# 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
GIT_CONFIG_GLOBAL: ${HOME}/.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
@@ -47,7 +59,7 @@ services:
# (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
- ${HOME}/.claude:${HOME}/.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
@@ -64,55 +76,64 @@ services:
# 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
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:${HOME}/.claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
- ${HOME}/.claude/plugins:${HOME}/.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
- ${HOME}/.claude.json:${HOME}/.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
- ${HOME}/specs:${HOME}/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
# replaced). GIT_CONFIG_GLOBAL above 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
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:${HOME}/.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.
# from here, never written. Only one mount is needed now (unlike
# before) - the container's own $HOME equals the host's $HOME, so
# there's no separate node-specific path to mirror it at.
- ${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
- nvim-data:${HOME}/.local/share/nvim
- nvim-state:${HOME}/.local/state/nvim
- nvim-cache:${HOME}/.cache/nvim
# Required only if SANDBOX_SSH_KEY isn't set - bin/claude-sandbox
# itself enforces that at least one of the two is present.
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
# Dedicated, purpose-built key for headless git push - see README
# "Headless git push without agent forwarding". Read-only single-file
# mount is fine here (unlike the gitconfig mount above): nothing in
# the container ever writes to this file. Mounted under /root (700,
# root:root by default), not under $SANDBOX_HOME: the sandbox user's
# UID now matches the host user's (see Dockerfile), so a mount here
# under their own home would leave the raw key readable by that user
# for the container's whole life, even after entrypoint.sh's copy is
# deleted - only root (which loads it into the agent, then never
# touches it again) can reach this path.
- ${SANDBOX_SSH_KEY_FILE:-/dev/null}:/root/.ssh-sandbox-key-src: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
- ${SANDBOX_SHARE_DIR}:${HOME}/shared
working_dir: ${WORKSPACE_DIR}
stdin_open: true
tty: true
+29 -9
View File
@@ -50,10 +50,10 @@ fi
# A normal login/ssh session always chowns the allocated pty to the logging
# in user; this container's entrypoint skips that (there's no login/sshd
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
# write to it directly as node, so fix the ownership up here, before
# dropping privileges.
# write to it directly as the sandbox user, so fix the ownership up here,
# before dropping privileges.
if [ -t 1 ]; then
chown node "$(tty)" 2>/dev/null || true
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN -
@@ -64,7 +64,27 @@ fi
# agent's real key) can't reach it at all. Skipped if GH_TOKEN isn't set -
# github.com HTTPS still works anonymously for public repos either way.
if [ -n "${GH_TOKEN:-}" ]; then
gosu node gh auth setup-git
gosu "$SANDBOX_USER" gh auth setup-git
fi
# Dedicated headless SSH identity (optional - see bin/claude-sandbox's
# SANDBOX_SSH_KEY and README "Headless git push"): a purpose-built key
# mounted read-only, independent of whatever forwarded agent socket the
# SSH_AUTH_SOCK mount above provides. Loaded into a fresh agent owned by
# the sandbox user rather than left forwarded, so it keeps working for the
# rest of this container's life even after whatever client connection
# forwarded the *other* agent goes away.
#
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
# can ever read it - copied out, loaded into the agent, and the copy
# deleted, all before dropping to the sandbox user below.
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
eval "$(gosu "$SANDBOX_USER" ssh-agent -s)" >/dev/null
gosu "$SANDBOX_USER" ssh-add "$SANDBOX_HOME/.ssh/sandbox-key"
rm -f "$SANDBOX_HOME/.ssh/sandbox-key"
export SSH_AUTH_SOCK
fi
# Test harness hook (docker/claude-sandbox/test/): everything above this
@@ -74,10 +94,10 @@ fi
# and assert against it, exercising the actual code path instead of a
# reimplementation of it that could drift out of sync.
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
exec gosu node sleep infinity
exec gosu "$SANDBOX_USER" sleep infinity
fi
# Drop root -> node for the actual session; node has no sudo/setuid path
# back to root after this, so it can't re-run init-firewall.sh itself to
# widen the allowlist mid-session.
exec gosu node nvim "$@"
# Drop root -> the sandbox user for the actual session; that user has no
# sudo/setuid path back to root after this, so it can't re-run
# init-firewall.sh itself to widen the allowlist mid-session.
exec gosu "$SANDBOX_USER" nvim "$@"
+87
View File
@@ -74,6 +74,12 @@ teardown_file() {
docker network rm "$(network_name)" >/dev/null 2>&1 || true
}
teardown() {
docker rm -f "claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rmi "claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
docker rm -f "claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
}
dexec() {
docker exec "$(container_name)" "$@"
}
@@ -82,6 +88,87 @@ dexec_node() {
docker exec -u node "$(container_name)" "$@"
}
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
local test_image="claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")"
local test_container="claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")"
local sandbox_dir="$BATS_TEST_DIRNAME/.."
local repo_root="$BATS_TEST_DIRNAME/../../.."
docker build -q -t "$test_image" \
--build-arg SANDBOX_USER=testuser \
--build-arg SANDBOX_UID=1234 \
--build-arg SANDBOX_GID=1234 \
--build-arg SANDBOX_HOME=/home/testuser \
-f "$sandbox_dir/Dockerfile" "$repo_root" >&2
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
-e SANDBOX_TEST_MODE=1 \
--entrypoint /usr/local/bin/entrypoint.sh \
"$test_image" >/dev/null
for _ in $(seq 1 60); do
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
sleep 1
done
run docker exec -u testuser "$test_container" whoami
[ "$status" -eq 0 ]
[ "$output" = "testuser" ]
run docker exec -u testuser "$test_container" printenv HOME
[ "$status" -eq 0 ]
[ "$output" = "/home/testuser" ]
run docker exec -u testuser "$test_container" id -u
[ "$output" = "1234" ]
}
@test "a dedicated SANDBOX_SSH_KEY loads into its own in-container agent with no forwarded socket at all" {
local key_dir test_container
key_dir="$BATS_TEST_TMPDIR"
ssh-keygen -t ed25519 -N '' -f "$key_dir/testkey" >/dev/null
test_container="claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
docker run -d --name "$test_container" \
--cap-add=NET_ADMIN --cap-add=NET_RAW \
--network "$(network_name)" \
-e SANDBOX_TEST_MODE=1 \
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
--entrypoint /usr/local/bin/entrypoint.sh \
"$IMAGE" >/dev/null
for _ in $(seq 1 60); do
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
sleep 1
done
# A fresh `docker exec` is a sibling of PID 1, not a descendant of the
# exec chain entrypoint.sh set SSH_AUTH_SOCK in, so it doesn't inherit
# that export the way a real session's nvim/terminal children would
# (nvim itself *is* that same process, continued via exec, not a
# separate one) - read the value back out of PID 1's own environment
# instead of assuming docker exec sees it.
run docker exec -u node "$test_container" sh -c '
export SSH_AUTH_SOCK=$(cat /proc/1/environ | tr "\0" "\n" | grep ^SSH_AUTH_SOCK= | cut -d= -f2-)
ssh-add -l
'
[ "$status" -eq 0 ]
[ -n "$output" ]
run docker exec -u node "$test_container" test -e /home/node/.ssh/sandbox-key
[ "$status" -ne 0 ]
# The regression this guards against: /root/.ssh-sandbox-key-src is the
# bind-mounted original, still present for the container's whole life
# (unlike the deleted copy above, this can't be rm'd from inside). If
# it were reachable by the sandbox user, that user's uid matching the
# host's (see Dockerfile) would make the raw key directly readable,
# defeating the point of only ever exposing it via the agent socket.
run docker exec -u node "$test_container" cat /root/.ssh-sandbox-key-src
[ "$status" -ne 0 ]
}
@test "IPv6 is fully blocked" {
run dexec ip6tables -L OUTPUT -n
[[ "$output" == *"policy DROP"* ]]
+93 -5
View File
@@ -19,6 +19,27 @@ setup() {
[ "$status" -ne 0 ]
}
@test "Dockerfile creates a SANDBOX_USER matching the host user, not a hardcoded node" {
grep -qE '^ARG SANDBOX_USER=node\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_UID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_GID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ARG SANDBOX_HOME=/home/node\s*$' "$SANDBOX_DIR/Dockerfile"
grep -q 'useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME"' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ENV SANDBOX_USER=\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
grep -qE '^ENV SANDBOX_HOME=\$SANDBOX_HOME\s*$' "$SANDBOX_DIR/Dockerfile"
# No literal "USER node" anywhere - the mid-build USER switch must use
# the parametrized variable, not the old hardcoded name.
run grep -E '^USER\s+node\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
grep -qE '^USER\s+\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
}
@test "entrypoint.sh drops privilege via SANDBOX_USER, not a literal node" {
run grep -E 'gosu node\b' "$SANDBOX_DIR/entrypoint.sh"
[ "$status" -ne 0 ]
grep -q 'gosu "$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "Playwright is installed and require()-able globally" {
# A global npm install only puts the CLI on PATH, not the module on
# Node's require() resolution path - NODE_PATH is what makes
@@ -51,16 +72,20 @@ setup() {
}
@test "docker-compose.yml never mounts host SSH/cloud credential directories" {
run grep -E '\$\{HOME\}/\.(ssh|aws|gcloud|azure)(/|:)' "$SANDBOX_DIR/docker-compose.yml"
# Anchored to the *source* side of a mount (right after the leading
# "- ") - the container's own $HOME legitimately contains .ssh/known_hosts
# as a *destination* path now that it mirrors the host's $HOME, which
# isn't the same thing as mounting the host's real ~/.ssh directory in.
run grep -E '^\s*-\s*\$\{HOME\}/\.(ssh|aws|gcloud|azure)(/|:)' "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" {
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:/home/node/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
# settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
# (see below), not the real file directly - it still has to be read-only
# in the container, just from a different, sandbox-specific source.
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:/home/node/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@@ -73,11 +98,38 @@ setup() {
# backing inode be replaced, only a file *within* a mounted directory
# can be freely renamed over). Must be a directory mount, with
# GIT_CONFIG_GLOBAL pointing git at the file inside it.
grep -qE '^\s*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:/home/node/\.gitconfig-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*GIT_CONFIG_GLOBAL:\s*/home/node/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:\$\{HOME\}/\.gitconfig-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*GIT_CONFIG_GLOBAL:\s*\$\{HOME\}/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox"
}
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
# mirroring the Dockerfile's default) is the one legitimate exception -
# it's a fallback expression, not a hardcoded container path.
run bash -c "grep -v '^\s*#' '$SANDBOX_DIR/docker-compose.yml' | grep -v '\${HOME:-/home/node}' | grep '/home/node'"
[ "$status" -ne 0 ]
}
@test "docker-compose.yml builds with a per-user image tag and passes the host identity as build args" {
grep -qE '^\s*image:\s*claude-sandbox:\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_USER:\s*\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_UID:\s*\$\{SANDBOX_UID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_GID:\s*\$\{SANDBOX_GID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*SANDBOX_HOME:\s*\$\{HOME:-/home/node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "bin/claude-sandbox exports the host user identity for the build" {
grep -q 'SANDBOX_USER="$(id -un)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_UID="$(id -u)"' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'SANDBOX_GID="$(id -g)"' "$REPO_ROOT/bin/claude-sandbox"
}
@test "dotfiles is mounted once now that the container home matches the host home" {
count=$(grep -c '\${HOME}/dotfiles:' "$SANDBOX_DIR/docker-compose.yml")
[ "$count" -eq 1 ]
}
@test "bin/claude-sandbox forces sandbox.enabled to false in the generated settings.json" {
# Claude Code's own internal per-command sandboxing is redundant on top
# of this container and can't actually function inside it (bwrap can't
@@ -134,3 +186,39 @@ setup() {
[ "$status" -ne 0 ]
[[ "$output" != *"Not a valid domain"* ]]
}
@test "the dedicated SSH key mount is optional, not a hard requirement" {
grep -qE '^\s*-\s*\$\{SANDBOX_SSH_KEY_FILE:-/dev/null\}:/root/\.ssh-sandbox-key-src:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{SSH_AUTH_SOCK:-/dev/null\}:/ssh-agent\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "the dedicated SSH key source is mounted under /root, not the sandbox user's home" {
# A mount under \$SANDBOX_HOME would be readable by the sandbox user
# directly, since its uid now matches the host user's (see Dockerfile) -
# /root is only reachable by root, which is all that should ever read
# this file (see entrypoint.sh).
run grep -E '\.ssh-sandbox-key-src' "$SANDBOX_DIR/docker-compose.yml"
[[ "$output" != *'${HOME}'* ]]
run grep -F 'SANDBOX_SSH_KEY_SRC=' "$SANDBOX_DIR/entrypoint.sh"
[[ "$output" != *'SANDBOX_HOME'* ]]
grep -qF 'SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"' "$SANDBOX_DIR/entrypoint.sh"
}
@test "entrypoint.sh deletes the plaintext dedicated-key copy after loading it into the agent" {
grep -q 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh"
grep -q 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh"
ssh_add_line=$(grep -n 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
rm_line=$(grep -n 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
[ "$rm_line" -gt "$ssh_add_line" ]
}
@test "bin/claude-sandbox allows a dedicated SANDBOX_SSH_KEY in place of a forwarded agent" {
grep -q 'SANDBOX_SSH_KEY' "$REPO_ROOT/bin/claude-sandbox"
grep -q 'export SANDBOX_SSH_KEY_FILE' "$REPO_ROOT/bin/claude-sandbox"
}
@test "bin/claude-sandbox warns when SANDBOX_SSH_KEY looks like a default personal key" {
grep -q 'looks like a default personal SSH key' "$REPO_ROOT/bin/claude-sandbox"
run grep -E '^\s*id_rsa\|id_ecdsa\|id_ed25519\|id_dsa\|id_ecdsa_sk\|id_ed25519_sk\)\s*$' "$REPO_ROOT/bin/claude-sandbox"
[ "$status" -eq 0 ]
}