5 Commits
Author SHA1 Message Date
mroberts 16ba06cc6d 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.
2026-08-05 13:30:09 -05:00
mroberts e0f6113320 Rewrite GitHub SSH remotes to HTTPS in the sandbox
The in-container clone inherits origin verbatim from the host, which is
commonly an SSH URL. Nothing in the sandbox can satisfy SSH: there is no key
and port 22 is closed. Only HTTPS carries the Authorization header the proxy
substitutes the repository token into, so every push failed.

Setup now writes a global insteadOf rewrite for both SSH spellings. Doing it
globally rather than per-remote covers the clone, anything the agent clones
later, and submodules, and leaves the host's own .git/config untouched under
--direct, where the working tree is bind-mounted read-write.
2026-08-05 13:30:09 -05:00
mroberts b5dfae0696 Keep the stored GitHub token when setup runs again
The secret store outlives the sandbox, so recreating one to change its image
or add a profile does not lose the token. Prompting for a replacement anyway
made --replace impractical and trained the habit of minting tokens that are
never revoked.

Only a token scoped to this sandbox counts. A global one would authenticate
the agent too, but reaching every repository it can reach is what this task
exists to prevent. The token command still always prompts; it is the way to
replace an expired or revoked token.
2026-08-03 16:09:44 -05:00
mroberts 53db7df812 Give the sandbox a UTF-8 locale
A sandbox image ships without a locale, leaving LC_CTYPE at POSIX. Every
multibyte glyph then degrades to a placeholder, so Nerd Font icons in the
editor render as underscores and bash printf emits \uXXXX escapes literally.

The locale is probed for usability rather than matched by name, because
locale -a spells C.UTF-8 as C.utf8 on glibc and a name match would silently
find nothing. LANG alone is set, leaving individual categories overridable.
2026-08-03 15:26:41 -05:00
mroberts 14165503d5 Launch a tmux workspace and carry dotfiles into the sandbox
AI_SBX_LAUNCH=tmux (or run --launch tmux) attaches to a three-window tmux
session - agent, edit, shell - instead of the bare agent. The launcher is
vendored at tasks/ai/workspace and installed into the sandbox, so a custom
image and this task cannot drift. It is invoked through a login shell because
/etc/sandbox-persistent.sh is where PATH, the mise shims, the AWS credentials
and every secret placeholder live, and the tmux server hands that environment
to all three windows.

AI_SBX_DOTFILES=chezmoi renders the host chezmoi target state and unpacks it
into the sandbox, so no dotfiles repository, decryption key or network access
is needed inside. chezmoi archive decrypts as it renders, so the target list
is an allowlist, encrypted files resolving inside it are refused, and the
rendered archive is scanned for credential shapes before it enters the
sandbox.

Both default to off; with neither set, run behaves exactly as before.
2026-08-03 13:49:24 -05:00
10 changed files with 1363 additions and 14 deletions
+102 -8
View File
@@ -243,6 +243,13 @@ gh pr list
gh pr create --fill
```
So is `git push`. The clone inherits `origin` from the host, which is usually an SSH
URL, and SSH cannot work in the sandbox — there is no key and port 22 is closed. A
global `insteadOf` rewrites `[email protected]:` and `ssh://[email protected]/` to
`https://github.com/`, so the push traverses the proxy and picks up the token. Remotes
on other hosts are left alone, and under `--direct` the host's own `.git/config` is
never touched.
AWS named profiles work as Terraform expects:
```bash
@@ -263,11 +270,11 @@ provider "aws" {
| Command | Effect |
| --- | --- |
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles, Claude configuration and plugins, and mise |
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
| `run [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent |
| `setup [options]` | Configure the repository, create the sandbox, open the token form if no token is stored yet, install AWS profiles, Claude configuration and plugins, and mise |
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change. Always prompts |
| `run [--launch MODE] [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent or to a tmux workspace |
| `refresh` | Refresh AWS credentials, without attaching |
| `config` | Re-apply your Claude configuration and plugins after the host changes, without recreating the sandbox |
| `config` | Re-apply your Claude configuration, plugins and dotfiles after the host changes, without recreating the sandbox |
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
| `remove` | Remove the sandbox and this repository's local configuration |
@@ -294,6 +301,95 @@ provider "aws" {
| `AI_SBX_TEMPLATE` | unset | `--template` / `--stock-template` |
| `AI_SBX_TOOLS` | `bun` | mise tools installed globally in the sandbox; empty installs none |
| `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
`AI_SBX_LAUNCH=tmux`, or `run --launch tmux`, attaches to a three-window tmux session
inside the sandbox instead of the bare agent:
| Window | Contents |
| --- | --- |
| `agent` | The agent, started the same way `sbx run` starts it |
| `edit` | `nvim .` |
| `shell` | A prompt |
All three start in the workspace root and inherit the sandbox environment, so AWS
profiles and injected registry credentials work in every one of them.
The session is named `ai-sbx` and is attached to rather than recreated, so detaching
and re-running lands back in the same place with the agent's context intact. That also
means closing the terminal no longer ends the session — the agent keeps running inside
the sandbox until it is stopped.
Agent arguments (`run -- --resume`) apply to `agent` mode only; passing them with
`--launch tmux` is an error rather than a silent no-op.
The sandbox image must provide `tmux` and `nvim`. The stock image provides neither,
so add them with mise:
```toml
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
age identity already lives — and unpacks the resulting tar into the sandbox home. The
sandbox needs no dotfiles repository, no decryption key and no network for this, and
`DEV_CONTAINER=1` is set so `git.autoCommit` and `git.autoPush` stay off.
Which files travel is an allowlist of target paths, one per line, in
`~/.config/ai-sbx/dotfiles`:
```text
.config/nvim
.tmux.conf
.gitconfig
```
The allowlist is a security control, not a convenience. `chezmoi archive` **decrypts as
it renders**, so a full archive contains your `gh` tokens, `.npmrc`, NuGet credentials
and `.ssh/config` in plaintext — precisely what the proxy-injected GitHub token exists
to keep away from the agent. Two checks enforce this:
- every `encrypted_*` source file is resolved to its target, and setup fails if any
lands inside the allowlist;
- the rendered archive is scanned for credential shapes before it enters the sandbox.
Neither can infer intent from a filename, so review what you list once. A file named
`tokens.fish` that happens not to be encrypted is still a file full of tokens.
## Carrying your Claude configuration into the sandbox
@@ -544,10 +640,8 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
## Development
```bash
bash tests/profile-mapping.test.sh
bash tests/token-url.test.sh
bash tests/invocation-directory.test.sh
shellcheck -x tasks/ai/sbx tests/*.sh
for test in tests/*.test.sh; do bash "$test"; done
shellcheck -x tasks/ai/sbx tasks/ai/workspace tests/*.sh
```
The token test checks URL encoding, that `target_name` carries the owner rather than
+187
View File
@@ -0,0 +1,187 @@
# Spec: tmux workspace and dotfiles — `ai-sandbox`
Implementation spec for the task side. Background and the decisions behind it are in
[`tmux-workspace-plan.md`](tmux-workspace-plan.md); the image side is
`mroberts/claude-sbx` → `docs/base-image-spec.md`.
## Scope
1. `AI_SBX_LAUNCH` — attach to a tmux workspace instead of the bare agent.
2. `AI_SBX_DOTFILES` — render the host's chezmoi dotfiles into the sandbox.
Both default to off. With neither set, behaviour is byte-for-byte what it is today.
## 1. Launch mode
### Configuration
| Surface | Values | Default |
| --- | --- | --- |
| `AI_SBX_LAUNCH` | `agent`, `tmux` | `agent` |
| `run --launch MODE` | same | overrides the variable for one run |
Read as `DEFAULT_LAUNCH="${AI_SBX_LAUNCH:-agent}"`, matching the existing
`AI_SBX_AGENT` / `AI_SBX_TEMPLATE` / `AI_SBX_TOOLS` / `AI_SBX_NETWORK` pattern.
An unrecognised value must `die`, not fall through to `agent`. A typo that silently
does the wrong thing is worse than a stopped run.
Not persisted in the per-repository config. It is a property of this session, not of
the repository; the environment variable already covers the durable case.
### Dispatch
`run_command` keeps its current preamble — `load_config`, `sandbox_exists`,
`install_sandbox_aws_files`, `install_sandbox_mise` — and then branches:
```bash
case "$launch" in
agent)
exec sbx run "$SANDBOX_NAME" ${1:+-- "$@"}
;;
tmux)
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
bash -lc 'ai-sbx-workspace'
;;
esac
```
`bash -lc` is mandatory. `/etc/sandbox-persistent.sh` is where PATH, the mise shims,
the AWS credentials and every secret placeholder live; a non-login shell loses all of
it, and the symptom is "npm cannot authenticate", nothing that points at tmux.
Agent arguments (`run -- --foo`) apply to `agent` mode only. In `tmux` mode they are
rejected with an explanatory error rather than silently dropped.
### The launcher
Shipped as a file in this repository at `tasks/ai/workspace`, installed into the
sandbox at `~/.local/bin/ai-sbx-workspace` by a new `install_sandbox_workspace`,
alongside the existing mise install. If the image already provides
`/usr/local/bin/ai-sbx-workspace` (see the image spec) the copy is skipped, and the
image's copy is built from this same file so the two cannot diverge.
Behaviour:
| Requirement | Detail |
| --- | --- |
| Attach-or-create | If session `ai-sbx` exists, attach. Never create a second one |
| Three windows | `agent`, `edit`, `shell`, in that order |
| Working directory | All three start in the workspace root |
| Agent window | Runs `claude --dangerously-skip-permissions` |
| Edit window | Runs `nvim .` |
| Shell window | Left at a prompt |
| Selected window | `agent` |
The agent command is **observed**, not assumed: attaching with `sbx run` and sampling
the process table inside the sandbox shows `claude --dangerously-skip-permissions`.
Because it is agent-specific, resolve it through a small mapping keyed on
`CONFIG_AGENT`, defaulting to the bare agent name for agents whose invocation has not
been observed. Getting this wrong for `claude` would silently change the agent's
permission model, so the `claude` entry must be exact.
Degrade rather than fail: if `tmux` is missing in the sandbox, print how to install it
(`AI_SBX_TOOLS`, or the custom image) and fall back to launching the agent directly.
## 2. Dotfiles
### Configuration
| Surface | Values | Default |
| --- | --- | --- |
| `AI_SBX_DOTFILES` | `chezmoi`, unset | unset (off) |
| `~/.config/ai-sbx/dotfiles` | newline-separated target allowlist | required when enabled |
### Mechanism
Host-side render, copy in. No repo clone, no age key, no network inside the sandbox:
```bash
DEV_CONTAINER=1 chezmoi archive --format tar <target>... |
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home"
```
`DEV_CONTAINER=1` is required. The existing `.chezmoi.toml.tmpl` branches on it and
setting it disables `git.autoCommit` and `git.autoPush` — without it an agent in the
sandbox could push to the dotfiles repo.
### The allowlist is a security control, not a convenience
`chezmoi archive` **decrypts as it renders**. A full archive of the current source
contains, in plaintext:
```text
.config/gh/hosts.yml GitHub CLI auth tokens
.npmrc npm registry tokens
.nuget/NuGet/NuGet.Config NuGet credentials
.ssh/config
.mcp.json
.aider.conf.yml
```
Copying those in would hand the agent the credentials this project deliberately keeps
out — the GitHub token is proxy-injected as an unreadable placeholder, and
`hosts.yml` would defeat that in one step.
Therefore:
1. **Allowlist only.** A denylist rots as new encrypted files appear, and the failure
mode is silent credential exfiltration.
2. **Refuse on overlap.** Enumerate every `encrypted_*` file in `chezmoi source-path`,
resolve each with `chezmoi target-path`, and `die` if any resolved target is inside
the allowlist. Verified working: all six current entries resolve correctly.
3. **Scan the rendered archive** for credential shapes before it enters the sandbox,
reusing the guard already in the template build script.
Note `.config/fish/conf.d/tokens.fish` is **not** encrypted but is named as though it
holds secrets. Anything selected must be reviewed once by a human; the tooling cannot
infer intent from a filename.
### Suggested starting allowlist
```text
.config/nvim
.tmux.conf
.gitconfig
```
## Tests
Following the existing suite: pure functions and source-level assertions, no sandbox.
| Test | Asserts |
| --- | --- |
| launch default | unset `AI_SBX_LAUNCH` → `agent` |
| launch override | `--launch tmux` beats the variable |
| launch validation | an unknown value exits non-zero |
| dispatch | stubbed `sbx` shows `sbx run` for `agent`, `sbx exec -it` for `tmux` |
| login shell | the tmux branch contains `bash -lc` |
| agent command | the `claude` mapping is exactly `claude --dangerously-skip-permissions` |
| launcher | `bash -n` clean, shellcheck clean, creates exactly three windows |
| dotfiles overlap | an allowlist containing an encrypted target exits non-zero |
| dotfiles off | unset `AI_SBX_DOTFILES` performs no chezmoi call |
Each must fail when its guard is removed — the same regression check used for the
non-interactive and multi-line-network tests.
## Acceptance
1. Neither variable set → `run` behaves exactly as today.
2. `AI_SBX_LAUNCH=tmux` → three windows, agent running in the first, all in the
workspace root.
3. Detach, re-run → reattaches to the same session with the agent's context intact.
4. In the shell window, `npm ci` in `webui/` still authenticates, proving the
environment survived the login shell.
5. `--launch agent` overrides the variable for one run.
6. `AI_SBX_DOTFILES=chezmoi` with a valid allowlist → those targets appear in the
sandbox and no file from the encrypted set does.
7. Adding an encrypted target to the allowlist → setup fails with a clear message.
## Out of scope
- A `kind: sandbox` kit that makes `sbx run` itself open tmux. Considered and
rejected in the plan: it requires declaring the whole agent and satisfying the base
image contract, for a launch preference.
- Persisting launch mode per repository.
- Dotfiles managers other than chezmoi.
+390 -6
View File
@@ -9,6 +9,21 @@ DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
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.
# chezmoi archive decrypts as it renders, so this is the last line of defence
# behind the allowlist rather than the first.
DOTFILES_CREDENTIAL_PATTERNS=(
'gh[pousr]_[A-Za-z0-9]{16,}'
'github_pat_[A-Za-z0-9_]{20,}'
'-----BEGIN [A-Z ]*PRIVATE KEY-----'
'AKIA[0-9A-Z]{16}'
'_authToken[[:space:]]*='
'aws_secret_access_key'
)
# VAR|host[,host...]|requirement. Provisioned for every repository when the
# variable is present in the host environment. "docker" skips the entry on a
@@ -65,12 +80,14 @@ Usage:
mise run ai:sbx -- token
mise run ai:sbx -- refresh
mise run ai:sbx -- config
mise run ai:sbx -- run [-- agent arguments...]
mise run ai:sbx -- run [--launch agent|tmux] [-- agent arguments...]
mise run ai:sbx -- status
mise run ai:sbx -- remove
Setup opens a pre-filled GitHub token form in your browser. Use "token" on
its own to replace an expired or revoked token later.
Setup opens a pre-filled GitHub token form in your browser, unless a token is
already stored for the sandbox. The secret store outlives the sandbox, so
recreating one with --replace keeps its token. Use "token" on its own to
replace an expired or revoked token.
Set AI_SBX_TEMPLATE in your mise config to reuse one custom image across
every repository without repeating --template.
@@ -91,10 +108,27 @@ AI_SBX_NETWORK lists hosts to allow through the sandbox network policy, comma
or space separated. Hosts backing a provisioned secret are allowed
automatically, since a credential for a denied host can never be used.
AI_SBX_LAUNCH selects what "run" attaches to: "agent" starts the agent alone
and is the default, "tmux" attaches to a three-window workspace (agent, edit,
shell) that survives detaching. --launch overrides it for one run. Agent
arguments apply to "agent" only.
AI_SBX_DOTFILES=chezmoi renders the host's chezmoi dotfiles into the sandbox.
It requires an allowlist of target paths, one per line, in the user's config
directory as a "dotfiles" file. chezmoi decrypts as it renders, so setup
refuses to run when an encrypted file resolves inside the allowlist.
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
@@ -218,6 +252,17 @@ read_token() {
printf '%s' "$token"
}
# Only a token scoped to this sandbox counts. A global one would authenticate
# the agent too, but reaching every repository the token can reach is exactly
# what this task exists to prevent, so it is not treated as satisfying setup.
sandbox_has_github_token() {
sbx secret ls 2>/dev/null |
awk -v scope="$SANDBOX_NAME" '
$1 == scope && $2 == "service" && $3 == "github" { found = 1 }
END { exit !found }
'
}
install_github_token() {
local url
url="$(token_url)"
@@ -1031,6 +1076,288 @@ EOF
'
}
# A sandbox image ships without a locale, which leaves LC_CTYPE at POSIX. Every
# multibyte glyph then degrades to a placeholder: Nerd Font icons in the editor
# render as underscores, and bash printf emits \uXXXX escapes literally. LANG
# alone is enough, and leaves a user free to override individual categories.
install_sandbox_locale() {
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
persistent=/etc/sandbox-persistent.sh
marker="# BEGIN ai-sbx locale"
if grep -Fq "$marker" "$persistent" 2>/dev/null; then
exit 0
fi
# locale -a spells these inconsistently across distributions, so probe
# each one for usability rather than matching its name.
for candidate in C.UTF-8 en_US.UTF-8; do
if LC_ALL="$candidate" locale >/dev/null 2>&1; then
chosen="$candidate"
break
fi
done
[[ -n "${chosen:-}" ]] || exit 0
cat >>"$persistent" <<EOF
# BEGIN ai-sbx locale
export LANG=$chosen
# END ai-sbx locale
EOF
' </dev/null >/dev/null 2>&1 || true
}
# The in-container clone inherits origin verbatim from the host, which is
# commonly an SSH URL. Nothing in the sandbox can satisfy SSH - there is no key
# and port 22 is closed - and only HTTPS carries the Authorization header the
# proxy substitutes the GitHub token into. Rewriting globally covers the clone,
# any repository the agent clones later, and every submodule, while leaving the
# host's own .git/config untouched under --direct.
install_sandbox_git_https() {
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c '
key="url.https://github.com/.insteadOf"
# insteadOf is multi-valued, so a plain set would replace the first
# form with the second and a repeat setup would accumulate duplicates.
git config --global --unset-all "$key" 2>/dev/null
git config --global --add "$key" "[email protected]:"
git config --global --add "$key" "ssh://[email protected]/"
' </dev/null >/dev/null 2>&1 ||
printf 'Could not rewrite GitHub SSH remotes to HTTPS in %s.\n' \
"$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) ;;
*)
die "Unknown launch mode: $1 (expected agent or tmux)"
;;
esac
}
# The image may already carry the launcher. Its copy is built from this same
# file, so the two cannot drift, and skipping keeps a custom image authoritative
# about its own contents.
install_sandbox_workspace() {
local launcher
launcher="$(dirname "${BASH_SOURCE[0]}")/workspace"
[[ -f "$launcher" ]] ||
die "Workspace launcher is missing: $launcher"
if sbx exec "$SANDBOX_NAME" \
bash -c '[[ -x /usr/local/bin/ai-sbx-workspace ]]' \
</dev/null >/dev/null 2>&1; then
return 0
fi
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."
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'mkdir -p "$HOME/.local/bin"'
sbx cp "$launcher" "$SANDBOX_NAME:$sandbox_home/.local/bin/ai-sbx-workspace"
# shellcheck disable=SC2016
sbx exec "$SANDBOX_NAME" bash -c 'chmod 755 "$HOME/.local/bin/ai-sbx-workspace"'
}
# One target path per line, relative to the home directory. Comments and blank
# lines are ignored.
read_dotfiles_allowlist() {
local file="$CONFIG_ROOT/dotfiles"
[[ -f "$file" ]] ||
die "AI_SBX_DOTFILES is set but $file does not exist. List one target path per line, for example .config/nvim"
local line
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line%%#*}"
line="$(printf '%s' "$line" | xargs)"
[[ -n "$line" ]] || continue
printf '%s\n' "$line"
done <"$file"
}
# chezmoi archive decrypts as it renders, so an encrypted file inside the
# allowlist would arrive in the sandbox as plaintext credentials - defeating the
# proxy-injected GitHub token in a single step. A denylist would rot as new
# encrypted files appear, and the failure mode is silent, so refuse instead.
assert_no_encrypted_targets() {
local source_dir
source_dir="$(chezmoi source-path)" ||
die "Could not determine the chezmoi source directory."
local encrypted target allowed
while IFS= read -r encrypted; do
[[ -n "$encrypted" ]] || continue
target="$(chezmoi target-path "$encrypted" 2>/dev/null)" || continue
target="${target#"$HOME/"}"
for allowed in "$@"; do
[[ "$target" == "$allowed" || "$target" == "$allowed"/* ]] ||
continue
die "Dotfiles allowlist entry $allowed contains the encrypted file $target, which chezmoi would render in plaintext. Narrow $CONFIG_ROOT/dotfiles."
done
done < <(find "$source_dir" -type f -name '*encrypted_*' 2>/dev/null)
}
scan_dotfiles_archive() {
local archive="$1" pattern
for pattern in "${DOTFILES_CREDENTIAL_PATTERNS[@]}"; do
grep -aEq -- "$pattern" "$archive" ||
continue
die "The rendered dotfiles archive contains something shaped like a credential (matching /$pattern/). Narrow $CONFIG_ROOT/dotfiles."
done
}
# Rendered on the host, where the age identity already lives, and copied in as a
# tar. The sandbox needs no dotfiles repository, no key and no network for this.
install_sandbox_dotfiles() {
[[ -n "$DEFAULT_DOTFILES" ]] || return 0
[[ "$DEFAULT_DOTFILES" == chezmoi ]] ||
die "Unknown AI_SBX_DOTFILES value: $DEFAULT_DOTFILES (expected chezmoi)"
require_command chezmoi
local -a targets
mapfile -t targets < <(read_dotfiles_allowlist)
((${#targets[@]})) ||
die "The dotfiles allowlist $CONFIG_ROOT/dotfiles is empty."
assert_no_encrypted_targets "${targets[@]}"
local -a target_paths=()
local target
for target in "${targets[@]}"; do
target_paths+=("$HOME/$target")
done
local archive
archive="$(mktemp)"
trap 'rm -f "$archive"' RETURN
# DEV_CONTAINER=1 is required, not cosmetic: .chezmoi.toml.tmpl branches on
# it to disable git.autoCommit and git.autoPush, and without it an agent in
# the sandbox could push to the dotfiles repository.
DEV_CONTAINER=1 chezmoi archive --format tar "${target_paths[@]}" >"$archive" ||
die "chezmoi could not render the dotfiles archive."
scan_dotfiles_archive "$archive"
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."
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home" <"$archive" ||
die "Could not unpack the dotfiles archive in $SANDBOX_NAME."
rm -f "$archive"
trap - RETURN
printf 'Installed dotfiles into %s: %s\n' "$SANDBOX_NAME" "${targets[*]}"
}
create_sandbox() {
load_config
@@ -1149,12 +1476,28 @@ setup_command() {
create_sandbox
fi
install_github_token
# The secret store outlives the sandbox, so recreating one to change its
# image keeps the token. Prompting anyway would train the habit of minting
# replacement tokens and never revoking the old ones.
if sandbox_has_github_token; then
printf 'Keeping the GitHub token already stored for %s. Replace it with: mise run ai:sbx -- token\n' \
"$SANDBOX_NAME"
else
install_github_token
fi
install_sandbox_aws_files
install_sandbox_claude_config
install_sandbox_dotfiles
# After the dotfiles: the allowlist may carry a .gitconfig, which would
# otherwise land on top of the rewrite.
install_sandbox_git_https
install_sandbox_signing_key
install_sandbox_network
install_sandbox_secrets
@@ -1205,12 +1548,45 @@ config_command() {
install_sandbox_claude_config
install_sandbox_dotfiles
install_sandbox_git_https
install_sandbox_signing_key
install_sandbox_network
install_sandbox_secrets
}
run_command() {
local launch="$DEFAULT_LAUNCH"
# Everything after -- belongs to the agent, including anything that looks
# like an option of this task.
while (($#)); do
case "$1" in
--launch)
(($# >= 2)) || die "--launch requires a value"
launch="$2"
shift 2
;;
--)
shift
break
;;
*)
break
;;
esac
done
validate_launch_mode "$launch"
if [[ "$launch" == tmux ]] && (($#)); then
die "Agent arguments are only supported with --launch agent; got: $*"
fi
load_config
sandbox_exists ||
@@ -1224,8 +1600,16 @@ run_command() {
# runs every time rather than only at setup.
install_sandbox_mise
if (($#)) && [[ "$1" == "--" ]]; then
shift
install_sandbox_locale
if [[ "$launch" == tmux ]]; then
install_sandbox_workspace
# bash -lc is mandatory: /etc/sandbox-persistent.sh is where PATH, the
# mise shims, the AWS credentials and every secret placeholder live, and
# the tmux server inherits its environment from this shell.
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
bash -lc "ai-sbx-workspace $(printf '%q' "$CONFIG_AGENT")"
fi
if (($#)); then
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Runs inside the sandbox as ai-sbx-workspace. The caller must start it from a
# login shell: the tmux server inherits this process's environment, so PATH, the
# mise shims, the AWS credentials and every secret placeholder reach all three
# windows through it. A non-login shell here loses the lot, and the symptom is
# "npm cannot authenticate" rather than anything that points at tmux.
SESSION=ai-sbx
# The claude invocation is observed, not assumed: sampling the process table of
# a sandbox attached with "sbx run" shows this exact command line. Getting it
# wrong would silently change the agent's permission model, so agents whose
# invocation has not been observed fall back to the bare name.
agent_command() {
case "$1" in
claude)
printf '%s' 'claude --dangerously-skip-permissions'
;;
*)
printf '%s' "$1"
;;
esac
}
main() {
local agent="${1:-claude}"
local agent_cmd
agent_cmd="$(agent_command "$agent")"
if ! command -v tmux >/dev/null 2>&1; then
printf '%s\n' \
'tmux is not installed in this sandbox; starting the agent directly.' \
'' \
'Add tmux to AI_SBX_TOOLS, or use a custom image that carries it:' \
'' \
' AI_SBX_TOOLS = "bun tmux neovim"' \
'' >&2
# Deliberate word splitting: the command comes from the mapping above.
# shellcheck disable=SC2086
exec $agent_cmd
fi
# Attach-or-create. Detaching and re-running must land back in the same
# session with the agent's context intact, which is most of the point.
if tmux has-session -t "$SESSION" 2>/dev/null; then
exec tmux attach-session -t "$SESSION"
fi
tmux new-session -d -s "$SESSION" -n agent -c "$PWD"
tmux new-window -t "$SESSION:" -n edit -c "$PWD"
tmux new-window -t "$SESSION:" -n shell -c "$PWD"
tmux send-keys -t "$SESSION:agent" "$agent_cmd" C-m
tmux send-keys -t "$SESSION:edit" 'nvim .' C-m
tmux select-window -t "$SESSION:agent"
exec tmux attach-session -t "$SESSION"
}
main "$@"
+89
View File
@@ -0,0 +1,89 @@
#!/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))
}
HOME="$work/home"
CONFIG_ROOT="$work/config"
SANDBOX_NAME=ai-test
mkdir -p "$HOME" "$CONFIG_ROOT" "$work/source/dot_config/nvim"
: >"$work/source/dot_config/nvim/encrypted_private_secrets.lua.age"
: >"$work/source/encrypted_private_dot_npmrc.age"
chezmoi_calls=0
chezmoi() {
chezmoi_calls=$((chezmoi_calls + 1))
case "$1" in
source-path)
printf '%s\n' "$work/source"
;;
target-path)
case "$2" in
*nvim*) printf '%s/.config/nvim/secrets.lua\n' "$HOME" ;;
*) printf '%s/.npmrc\n' "$HOME" ;;
esac
;;
archive)
printf 'archive\n'
;;
esac
}
sbx() {
cat >/dev/null 2>&1 || true
printf '%s\n' "$HOME"
}
printf '%s\n' '.tmux.conf' '# a comment' '' '.gitconfig' >"$CONFIG_ROOT/dotfiles"
mapfile -t entries < <(read_dotfiles_allowlist)
[[ "${entries[*]}" == ".tmux.conf .gitconfig" ]] ||
fail "the allowlist should drop comments and blanks, got: ${entries[*]}"
if (assert_no_encrypted_targets .config/nvim) 2>/dev/null; then
fail "an allowlist entry containing an encrypted target was accepted"
fi
if (assert_no_encrypted_targets .npmrc) 2>/dev/null; then
fail "an allowlist entry that is itself an encrypted target was accepted"
fi
(assert_no_encrypted_targets .tmux.conf .gitconfig) 2>/dev/null ||
fail "an allowlist with no encrypted targets was rejected"
chezmoi_calls=0
DEFAULT_DOTFILES="" install_sandbox_dotfiles >/dev/null
((chezmoi_calls == 0)) ||
fail "AI_SBX_DOTFILES unset must not call chezmoi at all"
if (DEFAULT_DOTFILES=stow install_sandbox_dotfiles) >/dev/null 2>&1; then
fail "an unknown AI_SBX_DOTFILES value was accepted"
fi
printf 'token: github_pat_%s\n' "$(printf 'a%.0s' {1..30})" >"$work/archive"
if (scan_dotfiles_archive "$work/archive") 2>/dev/null; then
fail "a rendered archive holding a GitHub token was accepted"
fi
printf 'set -g mouse on\n' >"$work/archive"
(scan_dotfiles_archive "$work/archive") 2>/dev/null ||
fail "a clean archive was rejected"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All dotfiles assertions passed.\n'
+69
View File
@@ -0,0 +1,69 @@
#!/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))
}
HOME="$work/home"
SANDBOX_NAME=ai-test
mkdir -p "$HOME"
sbx() {
[[ "$1" == exec && "$2" == "$SANDBOX_NAME" && "$3" == bash && "$4" == -c ]] ||
fail "unexpected sbx invocation: $*"
HOME="$HOME" bash -c "$5"
}
resolved() {
git -C "$work/repo" ls-remote --get-url origin
}
git init --quiet "$work/repo"
for remote in \
'[email protected]:owner/repo.git' \
'ssh://[email protected]/owner/repo.git'; do
rm -f "$HOME/.gitconfig"
git -C "$work/repo" remote remove origin 2>/dev/null || true
git -C "$work/repo" remote add origin "$remote"
[[ "$(resolved)" == "$remote" ]] ||
fail "$remote was already rewritten before the sandbox was configured"
install_sandbox_git_https
[[ "$(resolved)" == 'https://github.com/owner/repo.git' ]] ||
fail "$remote resolved to $(resolved), not an HTTPS URL"
done
install_sandbox_git_https
install_sandbox_git_https
values="$(HOME="$HOME" git config --global --get-all \
'url.https://github.com/.insteadOf' | wc -l)"
[[ "$values" -eq 2 ]] ||
fail "repeated setup left $values insteadOf values, expected 2"
git -C "$work/repo" remote set-url origin '[email protected]:owner/repo.git'
[[ "$(resolved)" == '[email protected]:owner/repo.git' ]] ||
fail "a non-GitHub remote was rewritten to $(resolved)"
((failures == 0)) ||
exit 1
printf 'ok: GitHub SSH remotes are rewritten to HTTPS inside the sandbox\n'
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
TASK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/sbx"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$TASK"
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
SANDBOX_NAME=ai-repo-abc123
listing=""
sbx() {
printf '%s\n' "$listing"
}
with_listing() {
listing="$1"
sandbox_has_github_token
}
full_listing() {
cat <<'EOF'
SCOPE TYPE NAME SECRET
ai-repo-abc123 service github (stored)
(global) service anthropic (oauth configured)
CUSTOM SECRETS
SCOPE TARGETS ENV PLACEHOLDER SECRET
ai-repo-abc123 localstack.cloud LOCALSTACK_AUTH_TOKEN sbx-cs-0c2f39c1 ls-vOL***
EOF
}
with_listing "$(full_listing)" ||
fail "a sandbox-scoped github token was not detected"
with_listing "$(
cat <<'EOF'
SCOPE TYPE NAME SECRET
(global) service anthropic (oauth configured)
EOF
)" && fail "no github token stored, yet setup would have been skipped"
with_listing "$(
cat <<'EOF'
SCOPE TYPE NAME SECRET
(global) service github (stored)
EOF
)" && fail "a global github token must not satisfy a per-repository sandbox"
with_listing "$(
cat <<'EOF'
SCOPE TYPE NAME SECRET
ai-other-sandbox service github (stored)
EOF
)" && fail "another sandbox's github token must not count as this one's"
with_listing "$(
cat <<'EOF'
CUSTOM SECRETS
SCOPE TARGETS ENV PLACEHOLDER SECRET
ai-repo-abc123 github.com github sbx-cs-abc gh***
EOF
)" && fail "a custom secret must not be mistaken for the stored service token"
with_listing "" &&
fail "empty output should mean no token, not a stored one"
grep -q 'if sandbox_has_github_token; then' "$TASK" ||
fail "setup no longer guards install_github_token"
awk '/^token_command\(\)/, /^}/' "$TASK" | grep -q 'install_github_token' ||
fail "the token command must always prompt; it is the way to replace one"
awk '/^token_command\(\)/, /^}/' "$TASK" | grep -q 'sandbox_has_github_token' &&
fail "the token command must not skip when a token exists"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All GitHub token assertions passed.\n'
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
set -euo pipefail
TASK="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/sbx"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$TASK"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
mkdir -p "$work/bin"
cat >"$work/bin/sbx" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$SBX_LOG"
EOF
chmod 755 "$work/bin/sbx"
PATH="$work/bin:$PATH"
export PATH
SANDBOX_NAME=ai-test
REPO_ROOT=/workspace/repo
CONFIG_AGENT=claude
SBX_LOG="$work/log"
export SBX_LOG
dispatch() {
: >"$SBX_LOG"
(
load_config() { :; }
sandbox_exists() { :; }
install_sandbox_aws_files() { :; }
install_sandbox_mise() { :; }
install_sandbox_workspace() { :; }
run_command "$@"
) >/dev/null 2>&1 || true
cat "$SBX_LOG"
}
(
unset AI_SBX_LAUNCH
# shellcheck source=tasks/ai/sbx
source "$TASK"
[[ "$DEFAULT_LAUNCH" == agent ]]
) || fail "AI_SBX_LAUNCH unset should default to agent"
(
AI_SBX_LAUNCH=tmux
export AI_SBX_LAUNCH
# shellcheck source=tasks/ai/sbx
source "$TASK"
[[ "$DEFAULT_LAUNCH" == tmux ]]
) || fail "AI_SBX_LAUNCH=tmux was not read into DEFAULT_LAUNCH"
DEFAULT_LAUNCH=agent
[[ "$(dispatch)" == *"run ai-test"* ]] ||
fail "agent mode should dispatch to sbx run: $(dispatch)"
DEFAULT_LAUNCH=tmux
tmux_dispatch="$(dispatch)"
[[ "$tmux_dispatch" == *"exec -it -w /workspace/repo ai-test"* ]] ||
fail "tmux mode should dispatch to sbx exec -it: $tmux_dispatch"
[[ "$tmux_dispatch" == *"bash -lc ai-sbx-workspace claude"* ]] ||
fail "tmux mode must use a login shell and pass the agent: $tmux_dispatch"
[[ "$tmux_dispatch" != *"run ai-test"* ]] ||
fail "tmux mode should not also call sbx run: $tmux_dispatch"
DEFAULT_LAUNCH=tmux
[[ "$(dispatch --launch agent)" == *"run ai-test"* ]] ||
fail "--launch agent should beat AI_SBX_LAUNCH=tmux"
DEFAULT_LAUNCH=agent
[[ "$(dispatch --launch tmux)" == *"exec -it"* ]] ||
fail "--launch tmux should beat AI_SBX_LAUNCH=agent"
DEFAULT_LAUNCH=agent
[[ "$(dispatch -- --resume)" == *"run ai-test -- --resume"* ]] ||
fail "agent arguments should still reach sbx run"
DEFAULT_LAUNCH=agent
[[ "$(dispatch -- --launch tmux)" == *"run ai-test -- --launch tmux"* ]] ||
fail "--launch after -- belongs to the agent, not to the task"
DEFAULT_LAUNCH=agent
locale_dispatch="$(dispatch)"
[[ "$locale_dispatch" == *"BEGIN ai-sbx locale"* ]] ||
fail "run should install a UTF-8 locale, or Nerd Font glyphs render as placeholders: $locale_dispatch"
[[ "$locale_dispatch" == *"LC_ALL=\"\$candidate\" locale"* ]] ||
fail "the locale must be probed for usability, not matched by name against locale -a"
if (validate_launch_mode bogus) 2>/dev/null; then
fail "an unknown launch mode was accepted"
fi
if (
DEFAULT_LAUNCH=agent
load_config() { :; }
sandbox_exists() { :; }
install_sandbox_aws_files() { :; }
install_sandbox_mise() { :; }
run_command --launch bogus
) >/dev/null 2>&1; then
fail "run --launch bogus should exit non-zero"
fi
if (
DEFAULT_LAUNCH=tmux
load_config() { :; }
sandbox_exists() { :; }
install_sandbox_aws_files() { :; }
install_sandbox_mise() { :; }
install_sandbox_workspace() { :; }
run_command -- --resume
) >/dev/null 2>&1; then
fail "agent arguments in tmux mode should be rejected, not dropped"
fi
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All launch mode assertions passed.\n'
+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'
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
LAUNCHER="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/tasks/ai/workspace"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
mkdir -p "$work/bin"
cat >"$work/bin/tmux" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$TMUX_LOG"
if [[ "$1" == has-session ]]; then
exit "${TMUX_HAS_SESSION:-1}"
fi
EOF
cat >"$work/bin/claude" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$TMUX_LOG"
EOF
chmod 755 "$work/bin/tmux" "$work/bin/claude"
TMUX_LOG="$work/log"
export TMUX_LOG
launch() {
: >"$TMUX_LOG"
PATH="$work/bin:$PATH" bash "$LAUNCHER" "$@" >/dev/null 2>&1
cat "$TMUX_LOG"
}
bash -n "$LAUNCHER" ||
fail "the launcher is not syntactically valid"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck "$LAUNCHER" ||
fail "the launcher is not shellcheck clean"
fi
log="$(launch claude)"
windows="$(grep -cE 'new-session|new-window' <<<"$log")"
[[ "$windows" == 3 ]] ||
fail "expected exactly three windows, got $windows: $log"
for window in agent edit shell; do
grep -qE "(new-session|new-window).* -n $window " <<<"$log" ||
fail "no window named $window: $log"
done
grep -qF 'send-keys -t ai-sbx:agent claude --dangerously-skip-permissions C-m' <<<"$log" ||
fail "the claude mapping must be exactly claude --dangerously-skip-permissions: $log"
grep -qF 'send-keys -t ai-sbx:edit nvim . C-m' <<<"$log" ||
fail "the edit window should open nvim: $log"
grep -qF 'select-window -t ai-sbx:agent' <<<"$log" ||
fail "the agent window should be selected: $log"
grep -qF 'attach-session -t ai-sbx' <<<"$log" ||
fail "the launcher should attach to the session: $log"
log="$(launch codex)"
grep -qF 'send-keys -t ai-sbx:agent codex C-m' <<<"$log" ||
fail "an unobserved agent should fall back to its bare name: $log"
TMUX_HAS_SESSION=0
export TMUX_HAS_SESSION
log="$(launch claude)"
if grep -qE 'new-session|new-window' <<<"$log"; then
fail "an existing session must be attached to, never rebuilt: $log"
fi
grep -qF 'attach-session -t ai-sbx' <<<"$log" ||
fail "an existing session should be attached to: $log"
unset TMUX_HAS_SESSION
mkdir -p "$work/bare"
# shellcheck disable=SC2016
printf '#!%s\nprintf %%s "$*" >>"$TMUX_LOG"\n' "$(command -v bash)" \
>"$work/bare/claude"
chmod 755 "$work/bare/claude"
: >"$TMUX_LOG"
PATH="$work/bare" "$(command -v bash)" "$LAUNCHER" claude >/dev/null 2>&1 ||
fail "the launcher should not fail when tmux is missing"
fallback="$(cat "$TMUX_LOG")"
[[ "$fallback" == '--dangerously-skip-permissions' ]] ||
fail "without tmux the launcher should exec the agent, logged: $fallback"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All workspace launcher assertions passed.\n'