diff --git a/docker/claude-sandbox/Dockerfile b/docker/claude-sandbox/Dockerfile index e49928c..fa9f2c4 100644 --- a/docker/claude-sandbox/Dockerfile +++ b/docker/claude-sandbox/Dockerfile @@ -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 diff --git a/docker/claude-sandbox/entrypoint.sh b/docker/claude-sandbox/entrypoint.sh index 996d17a..b4517ae 100755 --- a/docker/claude-sandbox/entrypoint.sh +++ b/docker/claude-sandbox/entrypoint.sh @@ -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,7 @@ 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 # Test harness hook (docker/claude-sandbox/test/): everything above this @@ -74,10 +74,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 "$@" diff --git a/docker/claude-sandbox/test/static.bats b/docker/claude-sandbox/test/static.bats index 1cafa29..d1bbe54 100644 --- a/docker/claude-sandbox/test/static.bats +++ b/docker/claude-sandbox/test/static.bats @@ -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