Sign commits made in the sandbox

The sandbox held no signing material, so its commits arrived unverified and a
branch rule requiring signatures rejected them outright. AI_SBX_SIGNING_KEY
copies an SSH signing key into the sandbox and points both git and jj at it.

The private half genuinely lands in the sandbox, which is why this is opt-in
and documented as signing-only: an agent that can read the key can sign as
you. A signing key grants no repository access and is revocable on its own,
so the exposure is forged attestation rather than reach. Forwarding an agent
socket would avoid the copy, but a socket passed over virtiofs is visible and
unconnectable from the guest, and the TCP workaround is a worse trade.

Setup refuses a passphrase-protected key rather than letting the failure
surface on the agent's first commit, and writes an allowed_signers entry so
the sandbox can verify what it just signed. jj is configured through conf.d,
which is read after config.toml and so overrides the host key path a copied
dotfile carries.
This commit is contained in:
2026-08-05 13:30:09 -05:00
parent e0f6113320
commit 16ba06cc6d
3 changed files with 255 additions and 0 deletions
+28
View File
@@ -303,6 +303,7 @@ provider "aws" {
| `AI_SBX_NETWORK` | unset | hosts to allow through the sandbox network policy, comma or space separated |
| `AI_SBX_LAUNCH` | `agent` | `run --launch agent\|tmux` |
| `AI_SBX_DOTFILES` | unset | `chezmoi` renders the host's dotfiles into the sandbox |
| `AI_SBX_SIGNING_KEY` | unset | host path to an SSH signing key; its private half is copied in so the sandbox can sign commits |
## The tmux workspace
@@ -335,6 +336,33 @@ AI_SBX_TOOLS = "bun tmux neovim"
Without `tmux` the launcher says so and starts the agent directly.
## Signing commits from the sandbox
By default the sandbox holds no signing material, so its commits arrive unverified.
`AI_SBX_SIGNING_KEY` points at an SSH signing key on the host and copies **its private
half** into the sandbox:
```toml
AI_SBX_SIGNING_KEY = "~/.ssh/id_ed25519_signing"
```
Setup then writes `gpg.format`, `user.signingkey`, `commit.gpgsign` and `tag.gpgsign`
into the sandbox's global git config, and an `allowed_signers` entry mapping the
repository's `user.email` to the key so the sandbox can verify what it just signed. jj
is pointed at the same key through `~/.config/jj/conf.d/10-ai-sbx-signing.toml`, which
is read after `config.toml` and so overrides the host path a copied dotfile carries.
This is the one place the sandbox is deliberately given a real credential, so the
constraints are narrow:
- **Use a key that only signs.** An agent that can read the key can sign as you. A
signing key grants no repository access and is revocable on its own, so the worst
case is forged attestation rather than reach. Never point this at an authentication
key.
- **No passphrase.** Nothing in the sandbox can answer a prompt. Setup refuses an
encrypted key rather than letting every commit fail at the moment of signing.
- **Both halves must exist.** git names the signing key by its `.pub`.
## Carrying your dotfiles into the sandbox
`AI_SBX_DOTFILES=chezmoi` renders your chezmoi target state **on the host** — where the
+91
View File
@@ -10,6 +10,7 @@ DEFAULT_TEMPLATE="${AI_SBX_TEMPLATE:-}"
DEFAULT_TOOLS="${AI_SBX_TOOLS:-bun}"
DEFAULT_NETWORK="${AI_SBX_NETWORK:-}"
DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"
DEFAULT_SIGNING_KEY="${AI_SBX_SIGNING_KEY:-}"
DEFAULT_DOTFILES="${AI_SBX_DOTFILES:-}"
# Rendered dotfiles are checked for these before the archive enters the sandbox.
@@ -121,6 +122,13 @@ AI_SBX_TOOLS lists mise tools installed globally in the sandbox, defaulting
to bun because several Claude plugins run their hooks under it. Set it to an
empty string to install none.
AI_SBX_SIGNING_KEY is the path to an SSH signing key on the host. Its private
half is copied into the sandbox, so the agent can sign as you: use a key that
signs and nothing else, and never an authentication key. Unset, the default,
leaves the sandbox unable to sign and its commits arrive unverified. The key
must not be passphrase-protected, because nothing in the sandbox can answer
the prompt. git and jj are both pointed at it.
Setup and run install mise in the sandbox and resolve the repository's
pinned tools, so the agent runs the same versions you do. A personal
mise config that must stay out of the repository goes in the per-repository
@@ -1123,6 +1131,85 @@ install_sandbox_git_https() {
"$SANDBOX_NAME" >&2
}
# The private half genuinely lands in the sandbox, which is why the key is
# opt-in and must be signing-only: an agent that can read it can sign as you.
# A signing key is separately revocable and grants no repository access, so the
# damage is forged attestation rather than reach.
install_sandbox_signing_key() {
[[ -n "$DEFAULT_SIGNING_KEY" ]] || return 0
local private="${DEFAULT_SIGNING_KEY/#\~/$HOME}"
local public="$private.pub"
[[ -f "$private" ]] ||
die "AI_SBX_SIGNING_KEY does not exist: $private"
[[ -f "$public" ]] ||
die "No public half beside $private. SSH signing needs both, and git names the signing key by its .pub."
ssh-keygen -y -P '' -f "$private" >/dev/null 2>&1 ||
die "$private is passphrase-protected. Nothing in the sandbox can answer the prompt, so every commit would fail at the moment of signing. Use a dedicated signing key with no passphrase."
local principal
principal="$(git -C "$REPO_ROOT" config user.email)" ||
die "The repository has no user.email, so signatures could not be attributed to a principal."
local sandbox_home
# shellcheck disable=SC2016
sandbox_home="$(sbx exec "$SANDBOX_NAME" bash -c 'printf %s "$HOME"')"
[[ -n "$sandbox_home" ]] ||
die "Could not determine the sandbox home directory."
local staging
staging="$(mktemp -d)"
trap 'rm -rf "$staging"' RETURN
local name
name="$(basename "$private")"
mkdir -p "$staging/.ssh"
install -m 600 "$private" "$staging/.ssh/$name"
install -m 644 "$public" "$staging/.ssh/$name.pub"
# Without a principal mapping git reports "No principal matched" for the
# signatures it just produced, so the sandbox cannot verify its own commits.
printf '%s %s\n' "$principal" "$(cat "$public")" \
>"$staging/.ssh/allowed_signers"
tar -C "$staging" -cf - .ssh |
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" ||
die "Could not copy the signing key into $SANDBOX_NAME."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
set -e
name="$1"
chmod 700 "$HOME/.ssh"
chmod 600 "$HOME/.ssh/$name"
chmod 644 "$HOME/.ssh/$name.pub" "$HOME/.ssh/allowed_signers"
git config --global gpg.format ssh
git config --global user.signingkey "$HOME/.ssh/$name.pub"
git config --global commit.gpgsign true
git config --global tag.gpgsign true
git config --global gpg.ssh.allowedSignersFile "$HOME/.ssh/allowed_signers"
# jj reads conf.d after config.toml, so the host key path a copied
# dotfile carries is overridden without editing a file chezmoi owns.
mkdir -p "$HOME/.config/jj/conf.d"
cat >"$HOME/.config/jj/conf.d/10-ai-sbx-signing.toml" <<EOF
[signing]
backend = "ssh"
key = "$HOME/.ssh/$name.pub"
EOF
' _ "$name" </dev/null ||
die "Could not configure commit signing in $SANDBOX_NAME."
printf 'Installed the signing key %s into %s.\n' "$name" "$SANDBOX_NAME"
}
validate_launch_mode() {
case "$1" in
agent | tmux) ;;
@@ -1409,6 +1496,8 @@ setup_command() {
# otherwise land on top of the rewrite.
install_sandbox_git_https
install_sandbox_signing_key
install_sandbox_network
install_sandbox_secrets
@@ -1463,6 +1552,8 @@ config_command() {
install_sandbox_git_https
install_sandbox_signing_key
install_sandbox_network
install_sandbox_secrets
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
SANDBOX_NAME=ai-test
SANDBOX_HOME="$work/sandbox-home"
REPO_ROOT="$work/repo"
mkdir -p "$SANDBOX_HOME"
git init --quiet "$REPO_ROOT"
git -C "$REPO_ROOT" config user.email [email protected]
sbx() {
[[ "$1" == exec ]] ||
fail "unexpected sbx invocation: $*"
shift
if [[ "$1" == -i ]]; then
shift 2
"$@"
return
fi
shift
if [[ "$1" == bash && "$2" == -c ]]; then
local script="$3"
shift 3
HOME="$SANDBOX_HOME" bash -c "$script" "$@"
return
fi
fail "unexpected sbx exec command: $*"
}
sandbox_git() {
HOME="$SANDBOX_HOME" git config --global --get "$1"
}
mode() {
stat -c '%a' "$1"
}
ssh-keygen -q -t ed25519 -N '' -C signing -f "$work/signing" </dev/null
ssh-keygen -q -t ed25519 -N 'locked' -C locked -f "$work/locked" </dev/null
DEFAULT_SIGNING_KEY=""
install_sandbox_signing_key
[[ ! -e "$SANDBOX_HOME/.ssh" ]] ||
fail "an unset AI_SBX_SIGNING_KEY still put a key in the sandbox"
DEFAULT_SIGNING_KEY="$work/absent"
(install_sandbox_signing_key) 2>/dev/null &&
fail "a missing signing key was accepted"
DEFAULT_SIGNING_KEY="$work/signing"
mv "$work/signing.pub" "$work/signing.pub.hidden"
(install_sandbox_signing_key) 2>/dev/null &&
fail "a signing key with no public half was accepted"
mv "$work/signing.pub.hidden" "$work/signing.pub"
DEFAULT_SIGNING_KEY="$work/locked"
(install_sandbox_signing_key) 2>/dev/null &&
fail "a passphrase-protected signing key was accepted"
DEFAULT_SIGNING_KEY="$work/signing"
install_sandbox_signing_key >/dev/null
[[ -f "$SANDBOX_HOME/.ssh/signing" ]] ||
fail "the private signing key was not installed"
[[ "$(mode "$SANDBOX_HOME/.ssh")" == 700 ]] ||
fail ".ssh is mode $(mode "$SANDBOX_HOME/.ssh"), expected 700"
[[ "$(mode "$SANDBOX_HOME/.ssh/signing")" == 600 ]] ||
fail "the private key is mode $(mode "$SANDBOX_HOME/.ssh/signing"), expected 600"
diff -q "$work/signing" "$SANDBOX_HOME/.ssh/signing" >/dev/null ||
fail "the installed private key does not match the host key"
[[ "$(cat "$SANDBOX_HOME/.ssh/allowed_signers")" == \
"[email protected] $(cat "$work/signing.pub")" ]] ||
fail "allowed_signers does not map the repository principal to the key"
[[ "$(sandbox_git commit.gpgsign)" == true ]] ||
fail "commit signing was not enabled in the sandbox"
[[ "$(sandbox_git tag.gpgsign)" == true ]] ||
fail "tag signing was not enabled in the sandbox"
[[ "$(sandbox_git gpg.format)" == ssh ]] ||
fail "the signing format is $(sandbox_git gpg.format), expected ssh"
[[ "$(sandbox_git user.signingkey)" == "$SANDBOX_HOME/.ssh/signing.pub" ]] ||
fail "user.signingkey points at $(sandbox_git user.signingkey)"
[[ "$(sandbox_git gpg.ssh.allowedSignersFile)" == \
"$SANDBOX_HOME/.ssh/allowed_signers" ]] ||
fail "allowedSignersFile points at $(sandbox_git gpg.ssh.allowedSignersFile)"
override="$SANDBOX_HOME/.config/jj/conf.d/10-ai-sbx-signing.toml"
grep -Fqx "key = \"$SANDBOX_HOME/.ssh/signing.pub\"" "$override" 2>/dev/null ||
fail "jj was not pointed at the key installed in the sandbox"
grep -Fqx 'backend = "ssh"' "$override" 2>/dev/null ||
fail "the jj signing backend was not set to ssh"
signed="$work/signed"
git init --quiet "$signed"
HOME="$SANDBOX_HOME" git -C "$signed" \
-c user.name=Malcolm -c user.email=[email protected] \
commit --quiet --allow-empty -m probe
status="$(HOME="$SANDBOX_HOME" git -C "$signed" log -1 --format='%G?')"
[[ "$status" == G ]] ||
fail "the sandbox produced a commit with signature status $status, expected G"
((failures == 0)) ||
exit 1
printf 'ok: the sandbox signs and verifies its own commits\n'