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
Owner

Summary generated by LLM agent

Summary

Two related fixes to docker/claude-sandbox/, from the same session:

  1. Container user now mirrors the real host user (username/uid/gid/home),
    via new Dockerfile build args (defaulted to today's exact node/1000/1000/
    /home/node, so nothing changes unless bin/claude-sandbox actually overrides
    them). This fixes a real bug: Claude Code plugin/skill resolution was
    silently broken inside the sandbox because ~/.claude/plugins/
    installed_plugins.json records install paths as literal host-absolute
    strings (e.g. /home/brad/.claude/plugins/...), which never existed inside
    a container whose $HOME was hardcoded to /home/node - confirmed live
    before and after the fix. It also lets the existing dotfiles dual-mount
    (a workaround for the same underlying $HOME mismatch) collapse to one
    line, and requires tagging the built image per-user
    (claude-sandbox:) so two accounts on a shared devbox don't
    clobber each other's build.

  2. Dedicated in-container SSH agent for headless git push. Motivation:
    running the sandbox on a devbox over SSH+tmux means the forwarded agent
    belongs to whichever client connected - when that client disconnects, the
    forwarded socket dies, breaking git push for anything still running
    headless. An optional SANDBOX_SSH_KEY now points at a purpose-built,
    passphrase-less keypair (a Gitea deploy key, not your personal identity)
    that entrypoint.sh loads into a fresh ssh-agent running inside the
    container itself, independent of the forwarded socket - the plaintext key
    is deleted immediately after being loaded into the agent, so nothing
    readable is left on disk. Fully opt-in; forwarding-only behavior is
    unchanged if SANDBOX_SSH_KEY isn't set.

Testing

  • Full bats suite (docker/claude-sandbox/test/{static,runtime}.bats): 40/40
    passing, including 7 new tests covering both changes (host-identity
    build-arg override end-to-end, dedicated-key agent load with no forwarded
    socket at all, plaintext-key deletion regression guard, optional-mount
    patterns, etc.)
  • Verified live: a container built with overridden SANDBOX_USER/UID/HOME
    actually comes up as that user; the plugin/skill path that was previously
    unreachable now resolves; a throwaway ed25519 key loads into the
    in-container agent and its plaintext copy is gone afterward;
    bin/claude-sandbox's validation gate correctly fails fast with neither a
    forwarded agent nor a dedicated key, and passes through when only the
    dedicated key is set.
  • Confirmed no regressions: every pre-existing static/runtime test still
    passes unchanged against the new defaults.

Design doc: ~/specs/2026-07-21-claude-sandbox-ssh-and-plugin-mounts-design.md
Implementation plan: ~/specs/2026-07-21-claude-sandbox-ssh-and-plugin-mounts-plan.md

**Summary generated by LLM agent** ## Summary Two related fixes to docker/claude-sandbox/, from the same session: 1. **Container user now mirrors the real host user** (username/uid/gid/home), via new Dockerfile build args (defaulted to today's exact node/1000/1000/ /home/node, so nothing changes unless bin/claude-sandbox actually overrides them). This fixes a real bug: Claude Code plugin/skill resolution was silently broken inside the sandbox because ~/.claude/plugins/ installed_plugins.json records install paths as literal host-absolute strings (e.g. /home/brad/.claude/plugins/...), which never existed inside a container whose $HOME was hardcoded to /home/node - confirmed live before and after the fix. It also lets the existing dotfiles dual-mount (a workaround for the same underlying $HOME mismatch) collapse to one line, and requires tagging the built image per-user (claude-sandbox:<username>) so two accounts on a shared devbox don't clobber each other's build. 2. **Dedicated in-container SSH agent for headless git push.** Motivation: running the sandbox on a devbox over SSH+tmux means the forwarded agent belongs to whichever client connected - when that client disconnects, the forwarded socket dies, breaking git push for anything still running headless. An optional SANDBOX_SSH_KEY now points at a purpose-built, passphrase-less keypair (a Gitea deploy key, not your personal identity) that entrypoint.sh loads into a fresh ssh-agent running inside the container itself, independent of the forwarded socket - the plaintext key is deleted immediately after being loaded into the agent, so nothing readable is left on disk. Fully opt-in; forwarding-only behavior is unchanged if SANDBOX_SSH_KEY isn't set. ## Testing - Full bats suite (docker/claude-sandbox/test/{static,runtime}.bats): 40/40 passing, including 7 new tests covering both changes (host-identity build-arg override end-to-end, dedicated-key agent load with no forwarded socket at all, plaintext-key deletion regression guard, optional-mount patterns, etc.) - Verified live: a container built with overridden SANDBOX_USER/UID/HOME actually comes up as that user; the plugin/skill path that was previously unreachable now resolves; a throwaway ed25519 key loads into the in-container agent and its plaintext copy is gone afterward; bin/claude-sandbox's validation gate correctly fails fast with neither a forwarded agent nor a dedicated key, and passes through when only the dedicated key is set. - Confirmed no regressions: every pre-existing static/runtime test still passes unchanged against the new defaults. Design doc: ~/specs/2026-07-21-claude-sandbox-ssh-and-plugin-mounts-design.md Implementation plan: ~/specs/2026-07-21-claude-sandbox-ssh-and-plugin-mounts-plan.md
branderson added 3 commits 2026-07-22 00:06:48 -07:00
branderson added 1 commit 2026-07-22 12:35:53 -07:00
The headless-SSH-key feature claimed the plaintext key was "deleted
immediately after being loaded into the agent, so nothing readable is
left on disk" - but entrypoint.sh only deleted the copy it made; the
original bind-mounted source at $SANDBOX_HOME/.ssh-sandbox-key-src stayed
mounted, readable, for the container's whole life. Combined with the
sandbox user's uid now matching the host user's, that file was directly
readable by the very process the sandbox exists to constrain, letting a
compromised session exfiltrate the raw key instead of being limited to
signing through the local agent socket.

Move the mount to /root/.ssh-sandbox-key-src (root:root, 700 by default)
so only entrypoint.sh's root-run startup code can ever reach it; the
sandbox user has no path to that directory at all. Update the matching
static/runtime tests (the runtime one now asserts the sandbox user
actually gets permission denied reading the source) and correct the
README's description of the mechanism.
branderson added 1 commit 2026-07-22 13:02:39 -07:00
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).
branderson merged commit b360904421 into master 2026-07-22 13:05:38 -07:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: branderson/dotfiles#3