Files
dotfiles/docker/claude-sandbox/test/static.bats
T
branderson 621bc28f7a claude-sandbox: document SANDBOX_SSH_KEY in Security notes; warn on default-looking personal keys
Second-round review follow-up. Security notes was written as the
canonical list of residual risks but never mentioned the new
SANDBOX_SSH_KEY mechanism, which has the same "signs for any repo it can
reach" shape as the forwarded agent (already documented) - add a bullet
covering it and the /root-only mount.

Also nothing enforced that SANDBOX_SSH_KEY actually be a dedicated
low-privilege key rather than a real personal identity, which is the
whole basis for accepting it as passphrase-less and left loaded for an
entire unattended session. bin/claude-sandbox now warns (non-fatal) if
it looks like a default personal keyfile (id_rsa, id_ed25519, etc).
2026-07-22 13:03:30 -07:00

225 lines
12 KiB
Bash

#!/usr/bin/env bats
# Fast checks that don't need Docker: catch regressions in the security
# invariants that are visible directly in the repo's files, without paying
# for an image build + container boot. See runtime.bats for everything that
# actually needs a live container.
setup() {
SANDBOX_DIR="$BATS_TEST_DIRNAME/.."
REPO_ROOT="$BATS_TEST_DIRNAME/../../.."
}
@test "Dockerfile never installs sudo" {
run grep -E '^\s*sudo\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
}
@test "Dockerfile never creates a sudoers entry" {
run grep -i "sudoers" "$SANDBOX_DIR/Dockerfile"
[ "$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
# require("playwright") work from any script, not just one with its own
# local node_modules (see runtime.bats for the actual launch test).
grep -q 'npm install -g playwright' "$SANDBOX_DIR/Dockerfile"
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
}
@test "every ipset add in init-firewall.sh is duplicate-safe" {
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
# ipset add without -exist errors on a duplicate entry, and set -e turns
# that into the whole firewall - and thus the whole container - failing
# to start with no clear error surfaced to the user. Every "ipset add"
# line must either pass -exist or already be wrapped so a failure can't
# propagate (e.g. `|| true`).
while IFS= read -r line; do
case "$line" in
*-exist*) continue ;;
*"|| true"*) continue ;;
esac
echo "unsafe ipset add: $line"
return 1
done < <(grep "ipset add" "$SANDBOX_DIR/init-firewall.sh")
}
@test "docker-compose.yml never mounts the Docker socket" {
run grep -i "docker.sock" "$SANDBOX_DIR/docker-compose.yml"
[ "$status" -ne 0 ]
}
@test "docker-compose.yml never mounts host SSH/cloud credential directories" {
# 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\}/\.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\}/\.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"
}
@test "gitconfig is a throwaway copy in its own directory, not a single-file mount" {
# entrypoint.sh's `gh auth setup-git` writes a credential-helper entry
# via git's write-temp-then-rename pattern, which fails with "Device or
# resource busy" on a single-file bind mount at ~/.gitconfig directly -
# a real regression this guards against (Linux won't let a bind mount's
# 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\}/\.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
# create a namespace here) - settings.json is read-only precisely so it
# can't be patched from inside, so this has to happen in the generation
# step on the host, before the container starts.
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/claude-sandbox"
}
@test "Dockerfile never installs bubblewrap or socat" {
# Nothing needs them now that sandbox.enabled is forced off - if they
# come back, it's worth asking why before just re-adding them.
run grep -E '^\s*(bubblewrap|socat)\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
[ "$status" -ne 0 ]
}
@test "sandbox-context.sh never tells the agent to worktree outside the mounted project" {
# ../<branch> (a sibling of the mounted project) or anything under $HOME
# lands in container-local ephemeral storage, not real host storage -
# the worktree, and whatever work went into it, silently vanishes when
# the session ends. Regression check for exactly this bug.
run grep -E '(worktree add|git worktree)[^\\n]*\.\./' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
[ "$status" -ne 0 ]
grep -q '.worktrees/' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
}
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
# The whole privilege-drop model depends on entrypoint.sh itself starting
# as root (to set up the firewall) - a stray `USER node` before
# ENTRYPOINT would silently break that.
run tail -n 5 "$SANDBOX_DIR/Dockerfile"
[[ "$output" == *"ENTRYPOINT"* ]]
! grep -qE '^\s*USER\s+node\s*$' <(tail -n 8 "$SANDBOX_DIR/Dockerfile")
}
@test "bin/claude-sandbox-allow rejects a domain with shell metacharacters" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com;touch /tmp/pwned'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow rejects a domain with a slash (dnsmasq server= delimiter)" {
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com/../etc'
[ "$status" -ne 0 ]
[[ "$output" == *"Not a valid domain"* ]]
}
@test "bin/claude-sandbox-allow accepts a well-formed domain (passes validation)" {
# --container points at a name that can't exist, so this can *never*
# reach a real running claude-sandbox session (and mutate its live
# firewall) even if the machine running this test happens to have one -
# it must fail at "container not found", not at validation.
run "$REPO_ROOT/bin/claude-sandbox-allow" 'crates.io' --container claude-sandbox-test-suite-nonexistent-target
[ "$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 ]
}