Merge pull request 'claude-sandbox: host-matching container user + dedicated headless SSH agent' (#3) from worktree-claude-sandbox-ssh-and-plugin-mounts into master

Reviewed-on: #3
This commit was merged in pull request #3.
This commit is contained in:
2026-07-22 13:05:37 -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)" 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 if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket." >&2 have_forwarded_agent=0
echo "Start ssh-agent and run ssh-add before using claude-sandbox." >&2 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 exit 1
fi fi
# A valid socket with zero identities loaded forwards fine (the container # A valid forwarded socket with zero identities loaded forwards fine (the
# sees a live, working agent) but is useless for git-over-ssh - warn now # container sees a live, working agent) but is useless for git-over-ssh -
# rather than have that surface confusingly deep into a session as a failed # warn now rather than have that surface confusingly deep into a session as
# push. Non-fatal: a task might not need git-over-ssh at all. # a failed push. Non-fatal: a task might not need git-over-ssh at all, and
if ! ssh-add -l >/dev/null 2>&1; then # 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 "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 fi
if [ -z "${GITEA_TOKEN:-}" ]; then if [ -z "${GITEA_TOKEN:-}" ]; then
@@ -108,10 +142,17 @@ else
fi fi
export WORKSPACE_DIR 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_SHARE_DIR
export SANDBOX_SETTINGS_FILE export SANDBOX_SETTINGS_FILE
export SANDBOX_GITCONFIG_DIR export SANDBOX_GITCONFIG_DIR
export SSH_AUTH_SOCK export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}" export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}" export GITEA_TOKEN="${GITEA_TOKEN:-}"
export GITEA_SSH_HOST="${GITEA_SSH_HOST:-}" 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. # subprocess spawns, which wouldn't source /etc/profile.d.
ENV PATH=/usr/games:$PATH ENV PATH=/usr/games:$PATH
# Ensure default node user has access to /usr/local/share # Replace the base image's built-in `node` user (uid/gid 1000,
RUN mkdir -p /usr/local/share/npm-global && \ # $HOME=/home/node) with one matching the host user who runs
chown -R node:node /usr/local/share # 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. # Persist bash history.
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
&& mkdir /commandhistory \ && mkdir /commandhistory \
&& touch /commandhistory/.bash_history \ && touch /commandhistory/.bash_history \
&& chown -R $USERNAME /commandhistory && chown -R $SANDBOX_USER /commandhistory
# Set `DEVCONTAINER` environment variable to help with orientation # Set `DEVCONTAINER` environment variable to help with orientation
ENV DEVCONTAINER=true 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), # 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 # not a fixed /workspace, so it can share ~/.claude project/session history
# with the host. ~/.claude is bind-mounted too, not baked in here. # with the host. ~/.claude is bind-mounted too, not baked in here.
RUN mkdir -p /home/node/.ssh && \ RUN mkdir -p $SANDBOX_HOME/.ssh && \
chown -R node:node /home/node/.ssh && \ chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
chmod 700 /home/node/.ssh chmod 700 $SANDBOX_HOME/.ssh
# Pre-create the nvim data/state/cache dirs owned by node, since they're # Pre-create the nvim data/state/cache dirs owned by the sandbox user,
# mounted as named volumes in docker-compose.yml: Docker seeds a fresh # since they're mounted as named volumes in docker-compose.yml: Docker
# named volume from whatever already exists at the mount path in the # seeds a fresh named volume from whatever already exists at the mount
# image, ownership included, so this is what keeps those volumes # path in the image, ownership included, so this is what keeps those
# node-writable instead of defaulting to root. # volumes writable by that user instead of defaulting to root.
RUN mkdir -p /home/node/.local/share/nvim /home/node/.local/state/nvim /home/node/.cache/nvim && \ RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
chown -R node:node /home/node/.local /home/node/.cache chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
ARG GIT_DELTA_VERSION=0.18.2 ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \ RUN ARCH=$(dpkg --print-architecture) && \
@@ -159,7 +180,7 @@ ARG PLAYWRIGHT_VERSION=1.61.1
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
# Set up non-root user # Set up non-root user
USER node USER $SANDBOX_USER
# Install global packages # Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global 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/entrypoint.sh /usr/local/bin/
COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude
COPY bin/gitea-pr /usr/local/bin/gitea-pr 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 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 && \ 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 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 # 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 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 reference to the same host agent process, not a snapshot) - no need to
restart the sandbox after `ssh-add`ing. 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 - `GITEA_SSH_HOST` and `GITEA_API` exported in your shell (same variables
`bin/gitea-pr` already needs — see its `require_config`), and a token `bin/gitea-pr` already needs — see its `require_config`), and a token
available either via `GITEA_TOKEN` in the environment or at 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 session started in the sandbox can be resumed on the host afterward with
`claude --resume <session-id>`. `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 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 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 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 write in different ways. Nothing written to it during a session reaches
your real `~/.gitconfig`. 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 scratch space
`~/shared` inside the container is a read-write bind mount of a real host `~/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 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 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 real boundary: this container is already the boundary that matters, and a
compromised session already has full `node`-level bash access, which a compromised session already has full bash access as the container's own
Chromium exploit couldn't exceed regardless. user regardless, which a Chromium exploit couldn't exceed.
## Sandbox self-awareness ## 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 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 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). 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 - `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
is up: `entrypoint.sh` runs as root, sets up the firewall directly, then second, independent credential-loading path with the same "signs for any
drops to `node` via `gosu` before execing `nvim`. A compromised session Gitea repo it has access to" shape as the forwarded agent above - the
can't re-run `init-firewall.sh` with a wider allowlist to open its own 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 exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
cleared from the effective/permitted sets on the `gosu` drop, and `node` cleared from the effective/permitted sets on the `gosu` drop, and that
gets a permission error running `iptables` directly. user gets a permission error running `iptables` directly.
## Tests ## Tests
+44 -23
View File
@@ -9,7 +9,19 @@ services:
dockerfile: docker/claude-sandbox/Dockerfile dockerfile: docker/claude-sandbox/Dockerfile
args: args:
TZ: ${TZ:-UTC} 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: cap_add:
- NET_ADMIN - NET_ADMIN
- NET_RAW - NET_RAW
@@ -25,7 +37,7 @@ services:
# Points git at the gitconfig copy mounted below instead of the # Points git at the gitconfig copy mounted below instead of the
# default ~/.gitconfig - see that mount's comment for why it can't # default ~/.gitconfig - see that mount's comment for why it can't
# just live at ~/.gitconfig directly. # just live at ~/.gitconfig directly.
GIT_CONFIG_GLOBAL: /home/node/.gitconfig-sandbox/.gitconfig GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-} ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to # 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 # 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 # (based on cwd) lines up with the host, letting a session started
# here be resumed on the host with `claude --resume`. # here be resumed on the host with `claude --resume`.
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR} - ${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*, # settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
# not runtime state - hooks run automatically with no approval gate, # not runtime state - hooks run automatically with no approval gate,
# so a compromised session writing to them would plant something that # 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 # 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 # means it can't be patched from inside, so the substitution has to
# happen on the host before the container ever starts. # happen on the host before the container ever starts.
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:/home/node/.claude/settings.json:ro - ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:${HOME}/.claude/settings.json:ro
- ${HOME}/.claude/CLAUDE.md:/home/node/.claude/CLAUDE.md:ro - ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
- ${HOME}/.claude/plugins:/home/node/.claude/plugins:ro - ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
# ~/.claude.json is a single file mixing live app state (project # ~/.claude.json is a single file mixing live app state (project
# registry, onboarding flags, caches) with config (e.g. mcpServers, if # registry, onboarding flags, caches) with config (e.g. mcpServers, if
# ever set) that can't be split at the mount level - left read-write # 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. # since most of its content must stay writable for normal operation.
# See README Security notes for this residual risk. # 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 # ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
# ~/specs/ rather than a project-local path - that instruction is # ~/specs/ rather than a project-local path - that instruction is
# already in effect inside the container (CLAUDE.md carries over), so # already in effect inside the container (CLAUDE.md carries over), so
# the directory it points at needs to actually exist and be writable # the directory it points at needs to actually exist and be writable
# here too, or an agent following it just writes into an ephemeral # here too, or an agent following it just writes into an ephemeral
# path inside the container that vanishes with the session. # 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, # Global git identity (user.name/user.email) - a throwaway copy of it,
# in its own directory rather than a single file bind-mounted at # in its own directory rather than a single file bind-mounted at
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a # ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
# credential-helper entry into it via git's usual write-temp-then- # credential-helper entry into it via git's usual write-temp-then-
# rename pattern, which fails on a single-file mount ("Device or # rename pattern, which fails on a single-file mount ("Device or
# resource busy" - Linux won't let that mount point's backing inode be # 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 # this directory instead. See bin/claude-sandbox - nothing written
# here ever reaches the user's real ~/.gitconfig. # 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 # ~/.claude/hooks is a symlink into ~/dotfiles/config/claude/hooks (an
# absolute host path); mount dotfiles at the same path so it resolves # absolute host path); mount dotfiles at the same path so it resolves
# in the container too. Read-only: hooks are only ever read/executed # 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 - ${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 - claude-code-bashhistory:/commandhistory
- nvim-data:/home/node/.local/share/nvim - nvim-data:${HOME}/.local/share/nvim
- nvim-state:/home/node/.local/state/nvim - nvim-state:${HOME}/.local/state/nvim
- nvim-cache:/home/node/.cache/nvim - nvim-cache:${HOME}/.cache/nvim
- ${SSH_AUTH_SOCK:?set SSH_AUTH_SOCK to your running ssh-agent socket}:/ssh-agent # Required only if SANDBOX_SSH_KEY isn't set - bin/claude-sandbox
- ${SSH_KNOWN_HOSTS:-/dev/null}:/home/node/.ssh/known_hosts:ro # 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 # Read-write scratch space for passing files back and forth with the
# sandboxed session (screenshots, temp scripts) without touching the # sandboxed session (screenshots, temp scripts) without touching the
# actual project checkout - separate from WORKSPACE_DIR above, and a # actual project checkout - separate from WORKSPACE_DIR above, and a
# real host directory (not a named volume) so it's actually visible on # real host directory (not a named volume) so it's actually visible on
# both sides, persisting across sessions. # both sides, persisting across sessions.
- ${SANDBOX_SHARE_DIR}:/home/node/shared - ${SANDBOX_SHARE_DIR}:${HOME}/shared
working_dir: ${WORKSPACE_DIR} working_dir: ${WORKSPACE_DIR}
stdin_open: true stdin_open: true
tty: 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 # 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 # 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 # 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 # write to it directly as the sandbox user, so fix the ownership up here,
# dropping privileges. # before dropping privileges.
if [ -t 1 ]; then if [ -t 1 ]; then
chown node "$(tty)" 2>/dev/null || true chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
fi fi
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN - # 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 - # 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. # github.com HTTPS still works anonymously for public repos either way.
if [ -n "${GH_TOKEN:-}" ]; then 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 fi
# Test harness hook (docker/claude-sandbox/test/): everything above this # 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 # and assert against it, exercising the actual code path instead of a
# reimplementation of it that could drift out of sync. # reimplementation of it that could drift out of sync.
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
exec gosu node sleep infinity exec gosu "$SANDBOX_USER" sleep infinity
fi fi
# Drop root -> node for the actual session; node has no sudo/setuid path # Drop root -> the sandbox user for the actual session; that user has no
# back to root after this, so it can't re-run init-firewall.sh itself to # sudo/setuid path back to root after this, so it can't re-run
# widen the allowlist mid-session. # init-firewall.sh itself to widen the allowlist mid-session.
exec gosu node nvim "$@" exec gosu "$SANDBOX_USER" nvim "$@"
+87
View File
@@ -74,6 +74,12 @@ teardown_file() {
docker network rm "$(network_name)" >/dev/null 2>&1 || true 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() { dexec() {
docker exec "$(container_name)" "$@" docker exec "$(container_name)" "$@"
} }
@@ -82,6 +88,87 @@ dexec_node() {
docker exec -u node "$(container_name)" "$@" 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" { @test "IPv6 is fully blocked" {
run dexec ip6tables -L OUTPUT -n run dexec ip6tables -L OUTPUT -n
[[ "$output" == *"policy DROP"* ]] [[ "$output" == *"policy DROP"* ]]
+93 -5
View File
@@ -19,6 +19,27 @@ setup() {
[ "$status" -ne 0 ] [ "$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" { @test "Playwright is installed and require()-able globally" {
# A global npm install only puts the CLI on PATH, not the module on # A global npm install only puts the CLI on PATH, not the module on
# Node's require() resolution path - NODE_PATH is what makes # 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" { @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 ] [ "$status" -ne 0 ]
} }
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" { @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 # settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
# (see below), not the real file directly - it still has to be read-only # (see below), not the real file directly - it still has to be read-only
# in the container, just from a different, sandbox-specific source. # 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/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*: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 # backing inode be replaced, only a file *within* a mounted directory
# can be freely renamed over). Must be a directory mount, with # can be freely renamed over). Must be a directory mount, with
# GIT_CONFIG_GLOBAL pointing git at the file inside it. # 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*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:\$\{HOME\}/\.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*GIT_CONFIG_GLOBAL:\s*\$\{HOME\}/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox" 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" { @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 # 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 # of this container and can't actually function inside it (bwrap can't
@@ -134,3 +186,39 @@ setup() {
[ "$status" -ne 0 ] [ "$status" -ne 0 ]
[[ "$output" != *"Not a valid domain"* ]] [[ "$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 ]
}