claude-sandbox: support a dedicated in-container SSH agent for headless git push

This commit is contained in:
2026-07-21 23:59:52 -07:00
parent 075f4b2ba4
commit 6b833bd222
6 changed files with 146 additions and 9 deletions
+30 -8
View File
@@ -26,19 +26,40 @@ fi
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
fi
have_forwarded_agent=1
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
echo "SSH_AUTH_SOCK is not set or not a valid socket." >&2
echo "Start ssh-agent and run ssh-add before using claude-sandbox." >&2
have_forwarded_agent=0
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
fi
# A valid socket with zero identities loaded forwards fine (the container
# sees a live, working agent) but is useless for git-over-ssh - warn now
# rather than have that surface confusingly deep into a session as a failed
# push. Non-fatal: a task might not need git-over-ssh at all.
if ! ssh-add -l >/dev/null 2>&1; then
# A valid forwarded socket with zero identities loaded forwards fine (the
# container sees a live, working agent) but is useless for git-over-ssh -
# warn now rather than have that surface confusingly deep into a session as
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
# 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 "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
if [ -z "${GITEA_TOKEN:-}" ]; then
@@ -118,6 +139,7 @@ export SANDBOX_SHARE_DIR
export SANDBOX_SETTINGS_FILE
export SANDBOX_GITCONFIG_DIR
export SSH_AUTH_SOCK
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}"
export GITEA_SSH_HOST="${GITEA_SSH_HOST:-}"
+37
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
reference to the same host agent process, not a snapshot) - no need to
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
`bin/gitea-pr` already needs — see its `require_config`), and a token
available either via `GITEA_TOKEN` in the environment or at
@@ -102,6 +106,39 @@ 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
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 — a compromised
session already has full access as the container's own user regardless (see
Security notes), so this isn't a new category of exposure. 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
```
At container start, `entrypoint.sh` loads it into a fresh `ssh-agent`
running inside the container itself — independent of the forwarded socket —
and deletes the plaintext copy immediately afterward, so nothing readable is
left on disk once it's in the agent. When `SANDBOX_SSH_KEY` is set, a live
forwarded agent is no longer required to start the sandbox at all.
## Shared scratch space
`~/shared` inside the container is a read-write bind mount of a real host
+8 -1
View File
@@ -113,8 +113,15 @@ services:
- nvim-data:${HOME}/.local/share/nvim
- nvim-state:${HOME}/.local/state/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
# 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.
- ${SANDBOX_SSH_KEY_FILE:-/dev/null}:${HOME}/.ssh-sandbox-key-src:ro
# Read-write scratch space for passing files back and forth with the
# sandboxed session (screenshots, temp scripts) without touching the
# actual project checkout - separate from WORKSPACE_DIR above, and a
+16
View File
@@ -67,6 +67,22 @@ if [ -n "${GH_TOKEN:-}" ]; then
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.
SANDBOX_SSH_KEY_SRC="$SANDBOX_HOME/.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
# 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)
+37
View File
@@ -77,6 +77,7 @@ teardown_file() {
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() {
@@ -123,6 +124,42 @@ dexec_node() {
[ "$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:/home/node/.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 ]
}
@test "IPv6 is fully blocked" {
run dexec ip6tables -L OUTPUT -n
[[ "$output" == *"policy DROP"* ]]
+18
View File
@@ -186,3 +186,21 @@ setup() {
[ "$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\}:\$\{HOME\}/\.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 "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"
}