claude-sandbox: support a dedicated in-container SSH agent for headless git push
This commit is contained in:
+30
-8
@@ -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:-}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"* ]]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user