Files
dotfiles/docker/claude-sandbox/entrypoint.sh
T
branderson 832aaef3d8 Add a bats test suite for the sandbox's security invariants
Addresses the "no regression protection" gap called out in Security notes:
everything there was hand-verified at a point in time, with nothing to
catch a future change silently breaking one of those properties again -
which had already happened once (the ipset -exist bug hit twice, on two
different ipsets, before being caught).

entrypoint.sh gets a SANDBOX_TEST_MODE hook: run the real DNS-proxy/
firewall/logging setup exactly as normal, then exec `sleep infinity` as
node instead of nvim (which needs a real tty). Lets the suite attach to a
live, fully-initialized container and assert against it, rather than
reimplementing the setup sequence in the test file where it could drift
out of sync with the real thing.

test/static.bats: fast, no Docker required - sudo/sudoers absence, every
ipset add is duplicate-safe, no docker.sock/host-credential mounts, the
~/.claude read-only overlay lines are present, claude-sandbox-allow's
input validation.

test/runtime.bats: builds the image, boots a container in test mode,
asserts IPv6 is blocked, DNS/IP allowlist enforcement in both directions,
GitHub read-only, no working sudo/iptables/init-firewall.sh as node,
capabilities are actually stripped (not just absent because docker exec
doesn't have them to begin with), ulogd2 runs unprivileged, and the
dynamic-ipset CDN-rotation fix actually re-syncs on a fresh DNS answer.

Container/network names are derived from bats' own BATS_FILE_TMPDIR rather
than $$ (caught via a real failure: bats reruns each file's top-level code
in a fresh subshell per test, so $$ differs across setup_file/tests/
teardown_file, silently pointing them at different, nonexistent
containers - several tests "passed" only because asserting failure against
a missing container also happens to return nonzero). BATS_FILE_TMPDIR is
guaranteed stable across the whole file's run, verified directly before
relying on it.

All 22 tests pass cleanly against the current sandbox.
2026-07-19 22:08:15 -07:00

84 lines
3.9 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
# Derive the Gitea API hostname from GITEA_API (a full URL) so it can be
# firewall-allowlisted alongside GITEA_SSH_HOST.
gitea_api_host=""
if [ -n "${GITEA_API:-}" ]; then
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
fi
# Same for LOKI_URL (also a full URL) - Loki's host needs to be reachable
# for log-shipper.sh to actually deliver anything.
loki_host=""
if [ -n "${LOKI_URL:-}" ]; then
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
fi
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${loki_host} ${ALLOWED_DOMAINS:-}"
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
# dig calls (and everything afterward) go through the same restriction.
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
# NFLOG consumer) and the Loki shipper start after it. Both are
# best-effort visibility on top of the enforcement above, not required
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
# files first, then drops), not root - it's parsing headers from
# attacker-influenceable packets (whatever a compromised session sends),
# so a bug in it shouldn't be a path back to root in the container.
#
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
# point of view, regardless of whether the daemon itself goes on to
# start successfully - so `set -e` above won't catch a failure here (nor
# should it: logging is best-effort, not something worth failing the
# whole container over). Check separately so a failure is at least
# visible instead of silently producing no logs.
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
sleep 0.5
if ! pgrep -x ulogd >/dev/null; then
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
fi
/usr/local/bin/log-shipper.sh &
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.
if [ -t 1 ]; then
chown node "$(tty)" 2>/dev/null || true
fi
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN -
# only possible now, not at image build time, since that's a runtime
# secret. Combined with the firewall restricting github.com to port 443
# only, this is what makes GitHub read-only from inside the sandbox: no
# write-capable credential for it, and SSH (which could use the forwarded
# 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
fi
# Test harness hook (docker/claude-sandbox/test/): everything above this
# point is the real startup sequence - DNS proxy, firewall, IPv6 block,
# logging, privilege-drop prep. Skip execing nvim (which needs a real tty)
# so the suite can `docker exec` into a live, fully-initialized container
# 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
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 "$@"