Carry git identity into the sandbox; warn on an empty ssh-agent

Git commits inside the sandbox need a user.name/user.email. Carries over
a throwaway copy of the host's ~/.gitconfig, generated by bin/claude-sandbox
and mounted as its own directory (not the real file, and not a single-file
bind mount at ~/.gitconfig) with GIT_CONFIG_GLOBAL pointing git at the file
inside it.

That took two iterations to get right, both caught by actually testing gh
auth setup-git (the thing that makes GitHub read-only from in here) rather
than just checking the identity resolved:
- Mounting the real file read-only broke gh auth setup-git outright, which
  needs to *write* a credential-helper entry into the global gitconfig
  ("could not write config file: Device or resource busy").
- Mounting a read-write *copy* of the file directly at ~/.gitconfig hit the
  exact same error - a single-file bind mount can't have its backing inode
  replaced, and git config writes via the usual write-temp-then-rename
  pattern. Only a directory mount, with git pointed at a file inside it,
  actually supports that.
Verified end to end this time: gh auth setup-git completes cleanly, the
credential-helper entries land in the sandbox's copy, user.name/email
resolve correctly, and the real host ~/.gitconfig is untouched throughout.

Also has bin/claude-sandbox warn (non-fatally) at launch if ssh-agent has
zero identities loaded - the forwarded socket still connects fine either
way, but git-over-ssh silently fails until a key's been ssh-add'ed, which
otherwise only surfaces confusingly deep into a session as a failed push.
Confirmed the forwarding mechanism itself works correctly by testing with
a throwaway key - the reported failure was the host agent having no
identities loaded, not a bug in the forwarding.

Test suite grew to 26 with a check for the gitconfig mount shape.
This commit is contained in:
2026-07-20 01:03:20 -07:00
parent 7249da8fc3
commit 7267e1ca77
4 changed files with 75 additions and 2 deletions
+31 -1
View File
@@ -32,6 +32,15 @@ if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
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
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
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
if [ -f "$GITEA_TOKEN_FILE" ]; then
@@ -75,12 +84,33 @@ mkdir -p "$HOME/specs"
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-settings.XXXXXX.json")"
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
cleanup() { rm -f "$SANDBOX_SETTINGS_FILE"; }
cleanup() { rm -f "$SANDBOX_SETTINGS_FILE"; rm -rf "$SANDBOX_GITCONFIG_DIR"; }
trap cleanup EXIT
# Git commits inside the sandbox need a user.name/user.email - carry over a
# copy of the host's global gitconfig, read-write: entrypoint.sh's
# `gh auth setup-git` (what makes GitHub read-only from in here - see
# README) needs to *write* a credential-helper entry into it.
#
# A whole directory, not a single file bind-mounted at ~/.gitconfig: `git
# config` writes via the usual write-temp-then-rename-over-original pattern,
# which fails with "Device or resource busy" on a single-file bind mount -
# Linux won't let that mount point's backing inode be replaced, only a file
# *within* a mounted directory can be freely renamed over. Point git at the
# copy via GIT_CONFIG_GLOBAL instead of relying on it being at ~/.gitconfig.
# Nothing written in here ever reaches the user's real ~/.gitconfig either
# way, since it's a copy in its own directory, not the real file/parent.
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-gitconfig.XXXXXX")"
if [ -f "$HOME/.gitconfig" ]; then
cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
else
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
fi
export WORKSPACE_DIR
export SANDBOX_SHARE_DIR
export SANDBOX_SETTINGS_FILE
export SANDBOX_GITCONFIG_DIR
export SSH_AUTH_SOCK
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
export GITEA_TOKEN="${GITEA_TOKEN:-}"
+17 -1
View File
@@ -18,7 +18,12 @@ into the container at runtime from wherever it actually sits on disk.
- Docker with the `docker compose` v2 plugin.
- `ssh-agent` running with the key for your Gitea remote(s) added
(`ssh-add -l` should list it). The container never sees your private key,
only a forwarded agent socket.
only a forwarded agent socket. `bin/claude-sandbox` warns (non-fatally) at
launch if the agent has zero identities loaded - the socket still forwards
fine either way, but git-over-ssh will fail until you `ssh-add` a key. That
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.
- `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
@@ -73,6 +78,17 @@ 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
`Dockerfile`.
Your global `~/.gitconfig` (`user.name`/`user.email` and the like) is also
carried over, so commits made inside the sandbox have the right identity
without any extra setup - falls back to an empty config if you don't have
one, it's not a hard requirement. It's a throwaway copy rather than the
real file, in its own directory rather than mounted directly at
`~/.gitconfig` (`GIT_CONFIG_GLOBAL` points git at it instead) - `gh auth
setup-git` needs to write a credential-helper entry into it, and both a
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`.
## Shared scratch space
`~/shared` inside the container is a read-write bind mount of a real host
+14
View File
@@ -22,6 +22,10 @@ services:
# itself for github.com over HTTPS - see the firewall/Dockerfile for
# why that's the only path GitHub is reachable through at all.
GH_TOKEN: ${GH_TOKEN:-}
# Points git at the gitconfig copy mounted below instead of the
# default ~/.gitconfig - see that mount's comment for why it can't
# just live at ~/.gitconfig directly.
GIT_CONFIG_GLOBAL: /home/node/.gitconfig-sandbox/.gitconfig
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
# 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
@@ -76,6 +80,16 @@ services:
# here too, or an agent following it just writes into an ephemeral
# path inside the container that vanishes with the session.
- ${HOME}/specs:/home/node/specs
# Global git identity (user.name/user.email) - a throwaway copy of it,
# in its own directory rather than a single file bind-mounted at
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
# credential-helper entry into it via git's usual write-temp-then-
# rename pattern, which fails on a single-file mount ("Device or
# resource busy" - Linux won't let that mount point's backing inode be
# replaced). GIT_CONFIG_GLOBAL below points git at the file inside
# this directory instead. See bin/claude-sandbox - nothing written
# here ever reaches the user's real ~/.gitconfig.
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:/home/node/.gitconfig-sandbox
# ~/.claude/hooks is a symlink into ~/dotfiles/config/claude/hooks (an
# absolute host path); mount dotfiles at the same path so it resolves
# in the container too. Read-only: hooks are only ever read/executed
+13
View File
@@ -56,6 +56,19 @@ setup() {
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
}
@test "gitconfig is a throwaway copy in its own directory, not a single-file mount" {
# entrypoint.sh's `gh auth setup-git` writes a credential-helper entry
# via git's write-temp-then-rename pattern, which fails with "Device or
# resource busy" on a single-file bind mount at ~/.gitconfig directly -
# a real regression this guards against (Linux won't let a bind mount's
# backing inode be replaced, only a file *within* a mounted directory
# can be freely renamed over). Must be a directory mount, with
# 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*GIT_CONFIG_GLOBAL:\s*/home/node/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox"
}
@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
# of this container and can't actually function inside it (bwrap can't