20 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
mroberts ad2b33f984 Split a multi-line AI_SBX_NETWORK correctly
A long host list is naturally written as a multi-line TOML string, but read
stops at the first newline, so only the first host was ever allowed. The rest
failed later as connection errors with nothing pointing back at the list.

Newlines and carriage returns are now flattened alongside commas before
splitting, and the test covers a multi-line value; it fails without the fix.

Also records the measured host requirements for a full Neovim configuration.
The Balanced policy already permits github, npm, pypi, crates, go, ubuntu,
nodejs, hashicorp releases, Copilot and the LLM APIs, which covers 93 lazy.nvim
plugins, both mason registries and all 55 mason packages. Only the .NET and
Terraform registries need declaring.
2026-08-03 12:37:53 -05:00
mroberts 2890b1dd98 Open the network policy for hosts a sandbox actually needs
Sandboxes default to a deny-everything-else policy, so the registry credentials
provisioned as custom secrets were unusable: npm.fontawesome.com,
proget.careevolution.com and localstack.cloud were all denied, and the request
never left the sandbox for the proxy to substitute a token into. The failure
looked like a connection error rather than a policy decision.

Provisioning a secret now allows its hosts in the same step, since a credential
for a denied host cannot be used by definition. AI_SBX_NETWORK declares any
further hosts, comma or space separated, for private registries that back no
secret.

The marketplace loop's inline policy call moves into the shared helper so the
two cannot drift.

The Balanced policy already permits github.com, codeload and the
githubusercontent hosts, registry.npmjs.org, pypi.org, files.pythonhosted.org,
crates.io and the Go proxies, so npm, pip, cargo, go and a plugin-managed
Neovim need nothing declared. Only private hosts do.
2026-08-03 10:42:56 -05:00
mroberts d2b7a5ef63 Provision LOCALSTACK_AUTH_TOKEN for every repository
LocalStack is a common enough dependency that declaring it per repository is
busywork, and the token is already in the host environment when it is needed at
all. It is now provisioned host-wide, through the same custom-secret path as
the per-repository declarations, so the value stays out of the sandbox and the
proxy substitutes it on requests to localstack.cloud.

Provisioning is conditional on the sandbox having Docker. LocalStack runs as a
container, so on a sandbox created from a non-docker template the credential
would be dead weight, and the entry is skipped with a message rather than
stored. An unset variable is skipped silently, which costs nothing on a machine
that never uses LocalStack.

The provisioning body moves into provision_secret so the host-wide and
per-repository paths cannot drift apart.
2026-07-31 12:09:56 -05:00
mroberts 93dc61a024 Provision repository registry credentials as sandbox secrets
Repositories need registry tokens to install dependencies, and those live
behind 1Password or a keychain that only exists on the host. A secrets file in
the user's per-repository config names each variable, the hosts it
authenticates to, and a command that prints it; the command runs on the host
from the repository root and its output becomes an sbx custom secret.

Custom secrets keep the value out of the sandbox entirely: the environment
variable is set to a placeholder and the proxy substitutes the real secret into
outbound request headers for the declared hosts. A committed .npmrc using
${VAR} interpolation therefore works unchanged while the agent sees only the
placeholder. Placeholders are derived from the repository and variable name so
re-running setup does not invalidate one already exported into a running
sandbox, and the value is piped rather than passed as --value, which would put
it in the process list.

The declaration lives in user config rather than the repository for the same
reason AWS profile approval does: a checkout must not choose which host
commands run or which credentials resolve.

A failed resolver has its own stderr surfaced, since it names where to obtain
the credential, and the remaining secrets still provision.

sbx secret set-custom was measured to overwrite silently and has no --force
flag, so the non-interactive test now matches sbx secret set precisely rather
than by prefix.
2026-07-31 11:42:36 -05:00
mroberts c195a82b82 Install plugin runtimes into the sandbox
Claude plugins bring their own runtime requirements. claude-mem and others run
their hooks under bun, which the sandbox image does not carry, so installing
plugins left every prompt in the sandbox failing with a Bun not found hook
error - after setup had reported success, since the dependency only surfaces
when a hook fires.

AI_SBX_TOOLS lists mise tools to install globally in the sandbox and defaults
to bun. mise is already present and resolves these from GitHub releases, which
the default network policy allows. Setting the variable to an empty string
installs nothing.
2026-07-31 09:12:00 -05:00
mroberts 6f4ba117b4 Stop setup blocking on invisible sbx confirmation prompts
sbx skills import prompts before overwriting each skill already in the shared
store. setup called it with stdout and stderr redirected and stdin left
attached, so on any second run the prompt was invisible and setup hung
indefinitely partway through installing the Claude configuration.

sbx rm prompts the same way, which would have blocked --replace and remove
once a sandbox was in use.

Both now pass --force and read from /dev/null. This is the third instance of
the pattern after sbx secret set, which cancelled silently and still exited 0,
so a test now asserts at the source level that every prompting subcommand is
invoked non-interactively. The test was confirmed to fail when either --force
is removed.
2026-07-31 09:04:21 -05:00
mroberts 5e167d3a0d State the Checks API gap instead of prescribing an impossible tick
Fine-grained tokens have no Checks permission. GitHub's permission reference
lists no Checks section and no check-run endpoint, and checks is absent from
the token form's pre-fill parameters, so the earlier instruction to tick
Checks: Read asked for a box that does not exist. A token created with every
listed permission still could not read check runs, which is what surfaced this.

The prompt now states the consequence rather than offering a remedy: gh pr
checks reports commit statuses only and gh run view returns no annotations.
Both degrade to empty output rather than a permission error, so without the
note they read as a broken CI integration. Job logs are unaffected; they fall
under Actions, which is granted.

secret_scanning_alerts and vulnerability_alerts move into the pre-filled URL,
leaving nothing for the operator to tick beyond repository selection.
2026-07-31 08:48:26 -05:00
mroberts b8bf9f9eff Pre-fill the alert permissions and test the plugin manifest parsing
Two of the three permissions treated as manual are in fact pre-fillable. The
earlier check scraped the rendered docs page, whose table splits those rows in
a way the parse missed; the docs source lists secret_scanning_alerts and
vulnerability_alerts as supported query parameters. Only checks is genuinely
absent, so the manual list shrinks to that one entry.

That entry now names what breaks without it. Checks: Read governs the status
rollup behind gh pr checks and the annotations behind gh run view, and both
degrade to empty results rather than permission errors, so an unticked box
reads as a broken CI integration rather than a missing scope.

The marketplace and enabled-plugin extraction move out of the install function
into host_marketplaces and host_enabled_plugins so they can be exercised
directly. The tests cover the pipe separator that keeps an absent repo from
shifting a url leftwards, rejection of marketplace sources that are neither
github nor git, disabled plugins being excluded, and the allowlist refusing to
carry credentials, transcripts or history.
2026-07-31 08:42:28 -05:00
mroberts dbe6c2e34b Default the sandbox agent to claude
The Claude configuration, plugin and skills support added for sandboxes only
applies when the agent is claude, so codex was no longer the sensible default.
2026-07-31 08:39:30 -05:00
mroberts 1fdbbff2e2 Install Claude configuration, plugins and mise into sandboxes
Custom templates are the documented way to carry user-level configuration into a
sandbox, but sbx v0.37.x silently drops every layer stacked on the base image
(docker/sbx-releases#366), so nothing baked into an image arrives. This installs
the same material into a stock sandbox after creation instead.

setup copies an allowlist of ~/.claude into the sandbox, imports skills into the
shared store, and adds each known marketplace before installing every enabled
plugin. Enablement survives here precisely because it happens after creation:
claude plugin install writes enabledPlugins itself, whereas sbx recreates
settings.json when the sandbox is created.

Tools come from mise, copied from the host because mise.jdx.dev is outside the
default network policy. Tools resolve through shims rather than mise activate,
which only fires for interactive shells and would leave the agent silently using
system versions.

sbx exec drains stdin, which truncated both install loops to their first entry,
and tab is an IFS whitespace character, which collapsed the empty repo field and
shifted the URL into it for git-sourced marketplaces. Both are handled.
2026-07-31 08:18:43 -05:00
mroberts ace4e81f97 Pass a custom template and mixin kits through to sbx
Sandboxes ignore the host ~/.claude by design: the agent runs as a separate
user with HOME elsewhere, so even a read-only mount is not picked up. Skills
can be shared with sbx skills import, but plugins carry commands, hooks and
MCP servers that only a custom image can deliver.

Adds --template, --stock-template and a repeatable --kit, persisted per
repository so run and refresh reuse them. AI_SBX_TEMPLATE supplies the default
image, so one custom template can be declared once in the user's mise config
and apply to every repository, with --template overriding it per repository
and --stock-template opting out.

save_config now packs two arrays into one argument list separated by a count,
so it ships with a round-trip test covering empty arrays, values containing
spaces, and the boundary between kits and AWS profiles.
2026-07-30 16:29:25 -05:00
mroberts 4af8c1c153 Target sbx 0.37 clone mode
sbx 0.29 isolated the agent with --branch, creating a host-side Git worktree.
0.37 removed that flag and reinstated --clone, which gives the agent a private
in-container clone mounted read-only and exposes its commits through a
sandbox-<name> git remote on the host. Setup fails outright against 0.37 with
'--branch is no longer supported'.

Drops the branch name plumbing entirely, since the sandbox now owns the clone
and there is no host branch to name.

Documents the two host prerequisites this surfaced: membership of the kvm
group, because sandboxes are microVMs, and the docker-sbx package rather than
docker-sandbox-bin on Arch derivatives - the latter installs only the CLI,
omitting the microVM kernel, rootfs and nerdbox shim, which makes sbx fall back
to mounting filesystems on the host and fail for any non-root user.
2026-07-30 16:12:58 -05:00
mroberts 890d10a315 Replace GitHub App tokens with a pre-filled token form
The App approach does not survive contact with a hundred developers and
hundreds of repositories. Minting installation tokens requires the App private
key on every developer's machine, and a key that widely distributed is a key
that grants org-wide minting to everyone holding it.

Device flow looked like the way out, since it needs no private key, but
testing showed it does not scope. A token requested with repository_id for one
repository reached a second repository in the same installation: a
permission-gated endpoint returned 200 where an installation token scoped to
one repository returned 403 for the same public repository. GitHub accepts
repository_id and silently ignores it. Per-repo scoping therefore requires
either the private key or the client secret, and neither can live on a
developer's machine.

Fine-grained PATs do scope per repository and share no secret, and GitHub
supports pre-filling the creation form via URL parameters, which removes the
toil that made them unattractive. Setup now builds that URL from the origin
remote and opens it, leaving the operator to select the repository and paste
the result.

Three permissions - checks, vulnerability_alerts and secret_scanning_alerts -
are absent from GitHub's pre-fill parameters, so they are printed as a
checklist instead of sent as parameters that would be silently dropped and
look granted. There is no parameter for repository selection either.

Tokens are no longer re-minted per launch, since a PAT outlives a session; the
new token subcommand replaces one on expiry or revocation.
2026-07-30 15:28:44 -05:00
mroberts d96f32d773 Resolve the repository from the invoking directory
A task included from the global mise config runs with the config root as its
working directory - $HOME - rather than the directory the user invoked it from.
Deriving the repository from the current directory therefore failed everywhere
except a project-level include, which defeats the point of installing the task
once and using it in every repository.

mise passes the real directory as MISE_ORIGINAL_CWD, so enter it before
resolving the repository, falling back to the current directory when the task
is run directly rather than through mise.
2026-07-30 14:35:40 -05:00
mroberts b03fcd6dd7 Mint GitHub App installation tokens instead of per-repo PATs
GitHub exposes no API to create a fine-grained PAT and no way to prefill the
creation form, so every repository meant hand-clicking a permission set and
remembering to rotate it. Installation tokens are API-mintable, so configuring
one GitHub App removes the per-repository work entirely.

A new 'app' subcommand records the App ID and private key path once. Setup then
resolves the installation for the repository, and run and refresh mint a fresh
token scoped to that single repository before every launch. Tokens expire in an
hour on their own, which retires manual rotation.

sbx secret set is invoked with --force because without it a second write prompts
for confirmation, reads the prompt from the stdin already consumed by the token,
cancels, and still exits 0 - leaving the previous, expired token in place.

The permission set is validated against GitHub's app-permissions schema. Notably
workflows has no read level, and write is required to push any commit touching
.github/workflows, which is a separate permission from actions.

Also corrects several sbx invocations that did not match the installed CLI:
--no-share-skills and --clone are not create flags, isolation is --branch; run
takes a sandbox name rather than --name; exec takes no -- separator; ls --quiet
replaces parsing tabular output; and the sandbox home is queried rather than
assumed to be /home/agent.

Adds a JWT test that verifies signatures against a generated public key and
confirms tampered input fails to verify.
2026-07-30 14:25:37 -05:00
18 changed files with 3303 additions and 100 deletions
+402 -47
View File
@@ -16,10 +16,11 @@ It provides a single command, `ai:sbx`, which:
- **Derives the repository from `origin`.** No repository name is typed or configured,
so the sandbox identity cannot drift from the checkout you are standing in. The
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
- **Scopes GitHub access to one repository.** A fine-grained PAT restricted to that
repository is stored with `sbx secret set`. Docker's host-side proxy injects it into
outbound requests; the token is never placed in `GH_TOKEN`, never written into the
repository, and is not readable by the agent.
- **Scopes GitHub access to one repository.** Setup opens the GitHub token form in
your browser with the owner, expiry, name, and permissions already filled in — you
pick the repository and paste the token back. It is stored with `sbx secret set` and
injected by Docker's host-side proxy; it is never placed in `GH_TOKEN`, never written
into the repository, and is not readable by the agent.
- **Keeps your AWS admin profiles out of the sandbox entirely.** Your `~/.aws`
directory and your SSO token cache are never mounted or copied. Instead, the host
runs `aws configure export-credentials` against named read-only profiles you approve
@@ -29,7 +30,7 @@ It provides a single command, `ai:sbx`, which:
grant — `api-portal-readonly` — while Terraform code references the account name,
`api-portal`. A trailing `-readonly` is stripped when the profile is written into the
sandbox, so unmodified Terraform resolves the read-only credentials.
- **Refreshes credentials on every launch,** since exported SSO credentials are
- **Refreshes AWS credentials on every launch,** since exported SSO credentials are
short-lived.
- **Requires nothing from the repository.** All state lives under
`~/.config/ai-sbx/`. Repositories that want first-class support can opt in with three
@@ -54,10 +55,17 @@ Install these on the **host** — none of them are needed inside the sandbox.
| Tool | Purpose | Install |
| --- | --- | --- |
| [mise](https://mise.jdx.dev/) | Runs the task and distributes it | [Getting started](https://mise.jdx.dev/getting-started.html) |
| [Docker Sandboxes (`sbx`)](https://docs.docker.com/ai/sandboxes/) | Sandbox, secret store, credential proxy | Ships with [Docker Desktop](https://docs.docker.com/desktop/) |
| [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) | `aws configure export-credentials` | Required only when using `--aws-profile` |
| [`jq`](https://jqlang.org/) | Parses exported credentials | Required only when using `--aws-profile` |
| [Docker Sandboxes (`sbx`)](https://docs.docker.com/ai/sandboxes/) | Sandbox, secret store, credential proxy | [Get started](https://docs.docker.com/ai/sandboxes/get-started/) — Docker Desktop is **not** required |
| KVM + membership of the `kvm` group | Sandboxes are microVMs | `sudo usermod -aG kvm $USER`, then re-login |
| [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), [`jq`](https://jqlang.org/) | `aws configure export-credentials` | Required only when using `--aws-profile` |
| `git`, `sha256sum` | Repository identity | Already present on most systems |
| `xdg-open` / `open` / `$BROWSER` | Opens the token form | Optional — the link is printed if absent |
**mise must be recent enough to load remote `git::` task includes.** Verified working
on 2026.7.17; verified broken on 2025.10.6, which drops `git::` entries silently — no
error, no clone attempted, `mise tasks ls` simply prints nothing. If that is what you
see, run `mise self-update` (note: `mise upgrade` updates your *tools*, not mise
itself).
Verify:
@@ -66,8 +74,19 @@ mise --version
sbx version
aws --version
jq --version
lsmod | grep kvm # must show kvm_intel, kvm_amd or kvm
id -nG | grep -w kvm # you must be in the kvm group
```
`sbx` requires **0.37 or later** — `--clone` replaced the older `--branch` flag, and
this task uses `--clone`.
On Arch derivatives, install the **`docker-sbx`** AUR package, not
`docker-sandbox-bin`. The latter ships only the CLI binary, omitting the microVM
kernel, rootfs, and `containerd-shim-nerdbox-v1`. Without those, `sbx` has no VM to
boot and falls back to mounting filesystems on the host, which fails with
`operation not permitted` for any non-root user.
### User-level install (recommended)
Adding the task to your personal mise config makes `ai:sbx` available in every Git
@@ -78,7 +97,7 @@ Add to `~/.config/mise/config.toml`:
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -92,7 +111,7 @@ registered with the forge:
```toml
includes = [
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -100,9 +119,9 @@ Optional personal defaults:
```toml
[env]
AI_SBX_AGENT = "codex"
AI_SBX_AGENT = "claude"
AI_SBX_MODE = "clone"
AI_SBX_BRANCH = "ai-sbx"
# AI_SBX_TEMPLATE = "git.mroberts.dev/you/claude-sbx:v1" # unset = stock image
```
Confirm it loaded:
@@ -122,7 +141,7 @@ A repository whose team has adopted the workflow can add the same include to its
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -145,7 +164,7 @@ aws sso login --profile prod-readonly
Setup fails fast with the exact `aws sso login` command if a profile is missing or its
session has expired.
### 2. Set up a repository, once
### 2. Set up a repository
```bash
cd ~/src/api-portal
@@ -155,26 +174,53 @@ mise run ai:sbx -- setup \
--aws-profile prod-readonly
```
This derives the repository from `origin`, creates the sandbox, then prompts for a
fine-grained GitHub PAT. Create it at
[github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
scoped to that one repository:
Derives the repository from `origin`, creates the sandbox, then opens the GitHub token
form in your browser with everything pre-filled:
```text
Resource owner: your user or organization
Repository access: Only select repositories
Selected repository: owner/api-portal
Permissions:
Metadata: Read
Contents: Read and write
Pull requests: Read and write
Actions: Read, if required
Issues: Only if required
Workflows: No access unless explicitly required
Expiration: the shortest period you will tolerate
name ai-sbx api-portal
target_name CareEvolution
expires_in 30
metadata=read contents=write pull_requests=write issues=write
workflows=write actions=write statuses=read security_events=write
secret_scanning_alerts=read vulnerability_alerts=read
```
Paste it at the prompt. It is not written to shell history.
One thing the form cannot pre-fill: **Repository access → Only select repositories
→ `api-portal`.** GitHub has no query parameter for repository selection.
### The Checks API is out of reach
Fine-grained tokens cannot read check runs. This is not a permission you forgot to
grant — GitHub's permission reference has no Checks section and lists no check-run
endpoint, so there is no box to tick. Inside the sandbox:
| Command | Behaviour |
| --- | --- |
| `gh pr checks` | shows commit statuses only, not check runs |
| `gh run view` | returns no annotations |
| `gh run view --log` | works — job logs fall under Actions |
Both degrade to empty output rather than a permission error, so they read as a broken
CI integration unless you know why. A 403 names what it wanted in the
`X-Accepted-GitHub-Permissions` response header.
Only an installation token from a GitHub App can reach the Checks API. That was
evaluated and rejected for this workflow: minting one requires the App private key on
every developer's machine, and device-flow user tokens — the alternative that needs no
key — were measured and do **not** honour `repository_id`, so they reach every
repository in the installation.
Generate the token and paste it at the prompt. It is read with the terminal echo off
and piped straight into the `sbx` secret store, so it never reaches your shell history.
There is no API to create a fine-grained token — GitHub only supports pre-filling the
form — so this step is inherently a browser round trip. Rotating later is the same
round trip:
```bash
mise run ai:sbx -- token
```
### 3. Run the agent
@@ -197,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
@@ -217,10 +270,12 @@ provider "aws" {
| Command | Effect |
| --- | --- |
| `setup [options]` | Configure the repository, create the sandbox, store the GitHub token, install AWS profiles |
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
| `refresh` | Refresh AWS credentials without attaching |
| `status` | Show repository, sandbox, agent, mode, profile mapping, stored secrets |
| `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, 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 |
### `setup` options
@@ -228,19 +283,304 @@ provider "aws" {
| Option | Default | Effect |
| --- | --- | --- |
| `--aws-profile NAME` | none | Host profile to expose. Repeatable. Trailing `-readonly` stripped inside the sandbox |
| `--agent NAME` | `codex` | Sandbox agent. See `sbx create --help` for the list |
| `--clone` | on | Give the agent a Git worktree on its own branch |
| `--agent NAME` | `claude` | Sandbox agent. See `sbx create --help` for the list |
| `--clone` | on | Give the agent a private in-container clone; its commits reach the host via the `sandbox-<name>` git remote |
| `--template REF` | `AI_SBX_TEMPLATE` | Custom sandbox image |
| `--stock-template` | off | Ignore `AI_SBX_TEMPLATE` for this repository |
| `--kit PATH` | none | Mixin kit to apply. Repeatable |
| `--direct` | off | Mount the host working tree read-write |
| `--branch NAME` | `ai-sbx` | Branch used by `--clone` |
| `--replace` | off | Destroy and recreate an existing sandbox |
### Environment defaults
| Variable | Default | Overrides |
| --- | --- | --- |
| `AI_SBX_AGENT` | `codex` | `--agent` |
| `AI_SBX_AGENT` | `claude` | `--agent` |
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
| `AI_SBX_BRANCH` | `ai-sbx` | `--branch` |
| `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) |
| `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
Sandboxes deliberately ignore your host `~/.claude`. The agent runs as a separate
`agent` user with `HOME` pointing elsewhere, so even a read-only mount of `~/.claude`
is not picked up. Three mechanisms exist, covering progressively more:
**Skills — supported, no build required:**
```bash
sbx skills import # add --dry-run to preview
```
Copies each skill directory from `~/.claude/skills` (and `~/.agents/skills`,
`~/.copilot/skills`, `~/.cursor/skills`, `~/.factory/skills`) into a shared store
mounted into every new sandbox. Symlinks and loose top-level files are skipped. Skills
under `~/.claude/plugins/` are **not** scanned — only the top-level skills directory.
**Plugin runtimes.** Plugins bring their own dependencies: several — claude-mem among
them — run their hooks under `bun`, which the sandbox image does not carry. A missing
runtime shows up as a hook error on *every* prompt rather than at install time:
```text
SessionStart:startup hook error
Failed with non-blocking status code: Error: Bun not found.
```
`AI_SBX_TOOLS` installs tools globally in the sandbox with mise, and defaults to `bun`
for exactly this reason. Add to it for other runtimes:
```toml
AI_SBX_TOOLS = "bun deno"
```
**Kits — for tools, env vars, network rules, and startup commands:**
```bash
mise run ai:sbx -- setup --kit ~/kits/my-kit
```
**`setup` — configuration and plugins, no image required:**
For the `claude` agent, `setup` copies `CLAUDE.md`, `AGENTS.md`, `agents`, `commands`
and `hooks` from `~/.claude` into the sandbox, runs `sbx skills import`, then adds
every marketplace in `~/.claude/plugins/known_marketplaces.json` and installs every
plugin your host has enabled. `config` re-applies all of it without recreating the
sandbox.
The copy is an allowlist. Credentials, conversation transcripts, `history.jsonl` and
shell snapshots are never copied, and anything added to `~/.claude` later stays on the
host until the allowlist names it.
A marketplace on a host other than `github.com` gets a matching network policy rule,
scoped to that sandbox — the default policy denies it otherwise.
Plugin *enablement* survives here because `claude plugin install` writes
`enabledPlugins` itself, after the sandbox has been created. Baking plugins into an
image does not survive: sbx recreates `~/.claude/settings.json` at creation, which
drops enablement while leaving the plugin files in place.
**Templates — for toolchains and anything else the base image lacks:**
```bash
export AI_SBX_TEMPLATE=ghcr.io/you/claude-sbx:v1
mise run ai:sbx -- setup # every repository now uses it
```
Set `AI_SBX_TEMPLATE` once in `~/.config/mise/config.toml` and every repository picks
it up; override per repository with `--template`, or opt out with `--stock-template`.
Two constraints worth knowing before building one:
- The sandbox's Docker daemon pulls templates **from a registry** and does not share
your host image store, so the image must be pushed somewhere reachable. Docker Hub
reuses your `sbx login`; for other registries use `sbx secret set --registry`.
- **sbx v0.37.0 and v0.37.1 cannot consume custom templates.** Every layer stacked on
the base image is silently dropped: the sandbox boots with base content only and
`sbx create` still exits 0. This is upstream
[#366](https://github.com/docker/sbx-releases/issues/366) — the erofs snapshotter
stopped building the merged `fsmeta`. v0.35.0 is unaffected. Until it is fixed, a
template is an expensive no-op and `setup` is the mechanism that works.
## Repository toolchains
`setup` and `run` install mise in the sandbox and resolve the repository's pinned
tools, so the agent runs the versions the project specifies rather than whatever the
base image ships.
The mise binary is copied from the host: `mise.jdx.dev` is outside the default network
policy, so the network installer fails at the tarball step. Tools resolve through
**shims** rather than `mise activate` — the agent runs non-interactive shells, which
never fire the activation hook and would otherwise silently get system versions.
A personal mise config that must stay out of the repository goes in the repository's
config directory:
```bash
$XDG_CONFIG_HOME/ai-sbx/repos/<digest>/mise.local.toml
```
It is copied to the workspace root on every run. This matters under `--clone`, where
the agent gets a fresh git clone and an untracked `mise.local.toml` on the host would
not reach it. Repositories with no mise configuration are left alone.
## Registry credentials
Repositories often need registry tokens — npm, NuGet — to install dependencies. The
task resolves them on the host, where 1Password and your keychain live, and provisions
them so the value never enters the sandbox.
Declare them per repository in your **own** config, not the repository's:
```text
~/.config/ai-sbx/repos/<digest>/secrets
```
```text
# VAR | host[,host...] | command printing the value, run from the repository root
FONTAWESOME_API_KEY | npm.fontawesome.com | scripts/npm-auth.sh print FONTAWESOME_API_KEY
PROGET_NPM_TOKEN | proget.careevolution.com | scripts/npm-auth.sh print PROGET_NPM_TOKEN
```
The `<digest>` is the suffix of the sandbox name, so read it off
`mise run ai:sbx -- status`.
**The value is never in the sandbox.** Each entry becomes an `sbx` custom secret: the
sandbox environment variable is set to a *placeholder*, and the proxy substitutes the
real secret into outbound request headers for the listed hosts. A committed `.npmrc`
using `${FONTAWESOME_API_KEY}` interpolation therefore works unchanged, while an agent
reading the variable sees only `sbx-cs-…`.
The declaration lives in user config for the same reason AWS profile approval does: a
repository must not be able to choose which host commands run or which credentials get
resolved. The command runs on your host, with your credentials.
Point it at whatever the repository already uses. A resolver that collapses several
sources into one `print <VAR>` interface is ideal, because the search order stays in
the repository where it belongs. If resolution fails, the command's own stderr is
surfaced — it names the variable and where to obtain it — and the remaining secrets
still provision.
Placeholders are derived from the repository and variable name, so re-running `setup`
does not invalidate a value already exported inside a running sandbox.
### Network policy
Sandboxes default to Docker's `Balanced` policy: deny everything except common
development hosts. That already covers more than it appears to — `github.com`,
`codeload.github.com`, `raw` and `objects.githubusercontent.com`,
`registry.npmjs.org`, `pypi.org`, `files.pythonhosted.org`, `crates.io`,
`proxy.golang.org` are all permitted, so npm, pip, cargo, go, and a plugin-managed
Neovim config need nothing added.
Private registries do not. Declare them once:
```toml
AI_SBX_NETWORK = "artifactory.example.com, *.internal.example.com"
```
**Hosts backing a provisioned secret are allowed automatically.** A credential for a
denied host is dead weight — the request never leaves the sandbox, so the proxy never
substitutes anything, and the failure looks like a hang or a connection error rather
than a policy decision.
Check any host against the effective policy with:
```bash
sbx policy check network --sandbox <sandbox> npm.fontawesome.com
```
### Host-wide credentials
Some credentials are worth provisioning everywhere rather than declaring per
repository. These are taken from your host environment automatically:
| Variable | Hosts | Provisioned when |
| --- | --- | --- |
| `LOCALSTACK_AUTH_TOKEN` | `localstack.cloud`, `*.localstack.cloud` | the variable is set **and** the sandbox has Docker |
The Docker condition matters: LocalStack runs as a container, so on a sandbox created
from a non-`-docker` template the token would be dead weight. It is skipped there with
a message rather than stored.
Nothing happens if the variable is unset, so this costs nothing on a machine that
does not use LocalStack.
> **Unverified.** LocalStack runs as a *nested* container inside the sandbox's own
> Docker daemon. Whether its outbound activation request traverses the `sbx` proxy —
> and therefore gets the placeholder substituted — has not been tested. If activation
> fails reporting an invalid token, the placeholder is reaching LocalStack literally,
> and the token needs exporting as a real value instead.
## AWS profile naming
@@ -267,9 +607,9 @@ Variants such as `_readonly`, `-ro`, and `-read-only` are **not** stripped.
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
```
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
profile names. Tokens live in the `sbx` secret store; AWS credentials exist only inside
the sandbox and only until they expire.
Holds repository identity, sandbox name, agent, mode, and the approved host
profile names — no secrets. The GitHub token lives in the `sbx` secret store; AWS
credentials exist only inside the sandbox and only until they expire.
Inspect the current repository's state with `mise run ai:sbx -- status`.
@@ -279,22 +619,37 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
or `mise.toml` could otherwise choose which credentials get loaded. Profile approval
lives in your user-owned config; the repository only supplies its own identity, which
is cross-checked against `origin` on every run.
- **Fine-grained PATs, one per repository, with an expiration.** A classic PAT reaches
every repository you can reach; that is the thing this design exists to prevent.
- **One token per repository, per developer, with an expiry.** Nothing is shared: no
private key, no client secret, no broker. Each developer's token is capped by their
own access, and organization owners can require approval and enforce a maximum
lifetime.
- **The token acts as you.** Its commits and comments carry your identity, so treat
the agent's output as your own work. Revoke at
[Fine-grained tokens](https://github.com/settings/tokens?type=beta) and re-run
`token`.
- **Rotation is manual.** The token expires on the schedule you picked; `token`
replaces it. There is no automatic renewal, because there is no API to create one.
- **Read-only AWS roles.** The sandbox boundary limits reach, not intent. Grant roles
that cannot cause damage if the agent misbehaves. Terraform `plan` needs read access;
`apply` should stay outside the sandbox.
- **Rotation is manual.** Revoke a PAT at GitHub and re-run `setup` to replace it.
- **`Contents: write` includes force-push and branch deletion.** There is no finer
split. Branch protection or rulesets are the actual guard, not token scoping.
- **`--direct` weakens isolation.** The agent writes directly to your working tree.
Prefer the default `--clone`.
## Development
```bash
bash tests/profile-mapping.test.sh
shellcheck -x tasks/ai/sbx tests/profile-mapping.test.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
the full repository name, that every permission survives into the query at a valid
level, and that no parameter GitHub silently ignores is sent — an ignored parameter
reads as "granted" when reviewing the link. No test touches the network, GitHub, or
`sbx`.
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
registers as `ai:sbx`, whereas a file literally named `tasks/ai:sbx` registers as
`ai_sbx`. Task files must be executable.
+343
View File
@@ -0,0 +1,343 @@
# Plan: launch a tmux workspace instead of the bare agent
Give `ai:sbx` an option to attach to a tmux session with three windows — agent, editor,
shell — rather than dropping straight into the agent.
Spans two repositories: the option and launch logic here, the tools and dotfiles in
the base image (`mroberts/claude-sbx`).
## What was established
Measured against `sbx` 0.37.0 and a live sandbox, not assumed:
| Finding | Consequence |
| --- | --- |
| PID 1 is `tini -- sh -c … sleep infinity`; no agent process runs until attach | `sbx run` execs the agent on attach. Launching tmux instead displaces nothing |
| `sbx exec -it SANDBOX CMD` allocates a TTY and keeps stdin open | A tmux session can be attached without a kit or a custom image |
| `sandbox.entrypoint.run` in a `kind: sandbox` kit replaces the image entrypoint | The alternative route: `sbx run` itself opens tmux |
| `tmux`, `nvim`, `vim` are all absent from `claude-code-docker` | Something must supply them |
| mise resolves `tmux` 3.7b (aqua/asdf) and `neovim` 0.12.4 (aqua) | `AI_SBX_TOOLS` can supply both with no image work |
| `files/home/` in a kit maps to `/home/agent/` | Dotfiles can ship without an image rebuild too |
| `/etc/sandbox-persistent.sh` carries PATH, AWS, and secret placeholders | Any window started via a **login** shell inherits the environment |
That last row is load-bearing: tmux windows must start login shells (`bash -l`), or
they lose mise shims, AWS credentials, and every secret placeholder.
## Two decisions
### How to launch
**Recommended: `sbx exec -it`, from `run_command`.**
```bash
exec sbx exec -it -w "$REPO_ROOT" "$SANDBOX_NAME" \
bash -lc 'ai-sbx-workspace'
```
No kit, no custom image, no change to how the sandbox is created. Reversible per run.
`sbx run` remains available untouched for anyone who wants the plain agent.
The alternative — a `kind: sandbox` kit with `entrypoint.run` — makes `sbx run`
itself open tmux, which is tidier semantically. It costs more: the kit must declare
the whole agent (image and entrypoint), the image must satisfy the base image
contract (non-root `agent` at UID 1000, passwordless sudo, proxy variables preserved
across sudo), and the agent's own launch flags must be reproduced. Not worth it for a
launch preference.
### Where tmux and neovim come from
**Recommended: start with `AI_SBX_TOOLS`, move to the image once it settles.**
```toml
AI_SBX_TOOLS = "bun tmux neovim"
```
Already implemented and needs no new code. Costs a per-sandbox install on first
`setup`, which is why the image is the eventual home — but proving the layout is
worth more than saving that minute, and the image cannot be iterated on as quickly.
## Part 1 — changes here
### 1. `AI_SBX_LAUNCH`
Follows the existing `AI_SBX_AGENT` / `AI_SBX_TEMPLATE` / `AI_SBX_TOOLS` pattern:
an environment variable read at the top of the task, overridable per invocation.
| Value | Behaviour |
| --- | --- |
| `agent` | Current behaviour: `sbx run`. **Default** |
| `tmux` | Attach to the workspace session |
Plus `--launch agent|tmux` on `run` for a one-off override. Persisting it per
repository in `save_config` is the wrong call — it is a preference about *this*
session, not a property of the repository, and the environment variable already
covers the durable case.
### 2. The launcher
A script the task installs into the sandbox, not an inline `sbx exec` string. Three
reasons: it must be idempotent, it needs real logic, and quoting a multi-window tmux
invocation through two shells is how mistakes happen.
```bash
#!/usr/bin/env bash
set -euo pipefail
session=ai-sbx
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_COMMAND" C-m
tmux send-keys -t "$session:edit" "nvim ." C-m
tmux select-window -t "$session:agent"
exec tmux attach-session -t "$session"
```
Attach-or-create matters: detaching and re-running must land back in the same session
with the agent's context intact, which is most of the point.
Install it the way `mise` already is — copied to `~/.local/bin/` inside the sandbox
during `install_sandbox_mise`, or a sibling `install_sandbox_workspace`.
### 3. Resolve the agent command — do this first
**The one real unknown.** `sbx run` execs the agent with flags this project has never
seen, because the container only sleeps until attach. Claude Code is very likely
started with `--dangerously-skip-permissions` — the Sandboxes FAQ describes a kit
that exists specifically to *drop* that flag — but that is inference, not
observation.
Determine it before writing the launcher:
```bash
sbx run --name <sandbox> &
sbx exec <sandbox> ps -eo args | grep -i claude
```
If it cannot be recovered, fall back to `claude` plain and document the difference,
because silently changing the agent's permission model would be worse than the
inconvenience.
### 4. Tests
Consistent with the existing suite — pure functions and source-level assertions, no
sandbox required:
- `AI_SBX_LAUNCH` defaults to `agent`; `--launch` overrides it; an unknown value is
rejected rather than silently treated as `agent`.
- `run_command` dispatches to `sbx run` for `agent` and `sbx exec -it` for `tmux`,
asserted with a stubbed `sbx`, as `create_sandbox` already is.
- The launcher script is syntax-checked and shellcheck-clean.
- The launcher uses a **login** shell, since a non-login shell loses the whole
environment. Assert `bash -lc` appears.
## Part 2 — what to bake into the base image
Once the layout settles, move it out of `AI_SBX_TOOLS` and into
`mroberts/claude-sbx`, per `docs/HANDOFF.md` there.
### Packages
```dockerfile
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends tmux \
&& rm -rf /var/lib/apt/lists/*
```
Neovim is the awkward one: Ubuntu ships an old version, and a modern config will
expect ≥ 0.10. Prefer the upstream tarball or keep it on mise rather than `apt`.
### Dotfiles
`files/home/` in a kit, or `COPY --chown=agent:agent` in the template:
```
.tmux.conf mouse on, sane scrollback, obvious status line
.config/nvim/ the smallest config that is pleasant on a fresh machine
```
Ship the **full** config. The network objection that would have argued for trimming it
does not survive measurement — see below — so the only real cost is a slower first
launch while plugins and LSP servers download.
### The launcher
Bake the same script at `/usr/local/bin/ai-sbx-workspace` so the task can call it
without installing anything. Keep the task's copy as the fallback for stock images —
the two must not drift, so it should live in one place here and be copied into the
image build rather than maintained twice.
### Verify
Check what the base image already provides before adding anything:
```bash
docker run --rm docker/sandbox-templates:claude-code-docker \
bash -lc 'for c in tmux nvim vim git node python3; do printf "%-8s %s\n" "$c" "$(command -v $c || echo MISSING)"; done'
```
## Dotfiles (chezmoi)
Short answer: **mise supplies the binary, but not the dotfiles, and baking them into
the image is the wrong home for them.** A third path fits better — render on the host,
copy the result in — which is how `~/.claude` is already handled.
### Why not mise alone
`mise` installs chezmoi fine (`aqua:twpayne/chezmoi`, 2.71.1), so
`AI_SBX_TOOLS = "bun tmux neovim chezmoi"` puts the binary in every sandbox. Getting
the *content* in is where it stops, for two independent reasons:
| Blocker | Detail |
| --- | --- |
| The repo is private | `github.com/mickeyr/DotFiles` returns 404 anonymously. The sandbox's GitHub token is a fine-grained PAT scoped to the repository being worked on, so it cannot clone a personal repo |
| Six files are age-encrypted | The identity lives at `~/.config/chezmoi/key.txt` on the host. `chezmoi apply` in the sandbox would need that private key copied in |
So `chezmoi init --apply github.com/mickeyr/DotFiles` inside a sandbox fails twice
over. Fixing it by copying an age private key into an environment an agent can read is
worse than the problem.
### Why not the image
Dotfiles are personal and change often; the image is shared and slow to rebuild. Every
nvim tweak would mean a rebuild and a registry push. Worse, the image is pushed to a
registry — anything baked in travels with it.
### Recommended: `chezmoi archive` on the host, allowlisted
`chezmoi archive` renders the target state on the host, where the age key already is,
and emits a tar. No repo access, no key, and no network needed inside the sandbox.
```bash
DEV_CONTAINER=1 chezmoi archive --format tar <target>... |
sbx exec -i "$SANDBOX_NAME" tar -x -C "$sandbox_home"
```
Measured: 241 entries, 542 KB for the full set; `chezmoi archive ~/.config/nvim`
scopes it to 62.
**`DEV_CONTAINER=1` is required, not cosmetic.** The existing `.chezmoi.toml.tmpl`
already branches on it, and setting it disables `git.autoCommit` and `git.autoPush` —
without it, an agent operating in the sandbox could push to the dotfiles repo.
### The part that must not be got wrong
`chezmoi archive` **decrypts** as it renders. A full archive therefore contains, in
plaintext:
```text
.config/gh/hosts.yml GitHub CLI auth tokens
.npmrc npm registry tokens
.nuget/NuGet/NuGet.Config NuGet credentials
.ssh/config
.config/fish/conf.d/tokens.fish
.mcp.json
.aider.conf.yml
```
Copying that in would hand the agent the very credentials this project spends its
effort keeping out — the GitHub token is proxy-injected as a placeholder precisely so
it is unreadable, and `.config/gh/hosts.yml` would undo that in one step.
So the copy must be an **allowlist of targets**, matching `CLAUDE_CONFIG_ALLOW`:
```bash
CHEZMOI_TARGET_ALLOW=(
.config/nvim
.config/fish # audit: conf.d/tokens.fish must not be included
.tmux.conf
.gitconfig
)
```
A denylist is not good enough. New encrypted files appear over time, and the failure
mode is silent credential exfiltration into an agent's environment.
Two mechanical checks worth building in, since both are cheap:
- Enumerate encrypted targets and refuse to proceed if any is inside the allowlist:
`chezmoi target-path` resolves each `encrypted_*` source file to its target, and all
six mapped correctly when tested.
- Scan the rendered archive for credential shapes before it enters the sandbox — the
same guard the template build script already uses.
### Where it goes
A `install_sandbox_dotfiles` alongside `install_sandbox_claude_config`, gated on
`AI_SBX_DOTFILES` (unset = off). It runs on the host, so it needs no chezmoi in the
sandbox at all — which makes the mise entry optional, useful only if the agent should
be able to run `chezmoi` itself.
## Risks
**Environment loss.** Any window not started as a login shell loses PATH, AWS
credentials, and secret placeholders. This will look like "npm suddenly cannot
authenticate" rather than anything to do with tmux.
**Detach semantics.** `sbx exec -it` with a detached tmux session leaves the agent
running inside the sandbox after the terminal closes. Desirable, but different from
today, where closing the terminal ends the session. Worth stating in the README.
**Nested tmux.** A developer already inside tmux on the host gets a nested session.
Setting a distinct prefix in the sandbox `.tmux.conf` avoids a confusing fight over
`C-b`.
**Terminal type.** `TERM` must survive into the sandbox or nvim renders badly.
`sbx exec -t` should handle it; confirm rather than assume.
**Plugin bootstrap — measured host by host with `sbx policy check network`.**
Already permitted by `Balanced`, so nothing to declare:
```text
github.com codeload.github.com raw/objects.githubusercontent.com
registry.npmjs.org pypi.org files.pythonhosted.org
crates.io static.crates.io proxy.golang.org sum.golang.org
nodejs.org deb.debian.org archive/security.ubuntu.com
releases.hashicorp.com checkpoint-api.hashicorp.com
api.githubcopilot.com copilot-proxy.githubusercontent.com
api.anthropic.com api.openai.com
```
That covers all 93 pinned lazy.nvim plugins, both mason registries (`mason-org` and
`crashdummyy`, both `github:`), all 55 mason packages, Copilot, and codecompanion.
Denied, and therefore declared in `AI_SBX_NETWORK`:
| Host | Needed by |
| --- | --- |
| `*.nuget.org` | roslyn, easy-dotnet, NuGet restore |
| `pkgs.dev.azure.com` | Azure-hosted NuGet feeds |
| `builds.dotnet.microsoft.com`, `dotnetcli.azureedge.net`, `dotnetbuilds.azureedge.net`, `dotnetcli.blob.core.windows.net`, `ci.dot.net` | .NET SDK downloads |
| `registry.terraform.io` | provider downloads for terragrunt |
| `mise.jdx.dev` | mise self-resolution |
| `default.exp-tas.com` | Copilot feature flags |
Confirmed reachable from inside the sandbox afterwards: `api.nuget.org` and
`registry.terraform.io` both return HTTP 200.
Wildcards match a single label: `*.nuget.org` covers `api.`, `www.`, `globalcdn.` and
the bare domain, but `*.azureedge.net` does **not** reach
`dotnetcli.blob.core.windows.net`. Prefer explicit hosts over a broad CDN wildcard —
`*.azureedge.net` would admit every Azure CDN customer, not just Microsoft's.
The work registries — `npm.fontawesome.com`, `proget.careevolution.com`,
`localstack.cloud` — are allowed automatically, since they back provisioned secrets.
## Acceptance
1. `AI_SBX_LAUNCH` unset → `mise run ai:sbx -- run` behaves exactly as today.
2. `AI_SBX_LAUNCH=tmux` → three windows, agent running in the first, all three in the
repository root.
3. Detach and re-run → reattaches to the same session, agent context intact.
4. Inside the shell window, `npm ci` in `webui/` still authenticates — proving the
environment survived.
5. `--launch agent` overrides the variable for one run.
+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.
+1172 -53
View File
File diff suppressed because it is too large Load Diff
+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 "$@"
+110
View File
@@ -0,0 +1,110 @@
#!/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))
}
assert_url() {
local kind="$1" repo="$2" url="$3" expected="$4" actual
if actual="$(marketplace_url "$kind" "$repo" "$url")"; then
[[ "$actual" == "$expected" ]] ||
fail "marketplace_url $kind '$repo' '$url' gave '$actual', expected '$expected'"
else
[[ "$expected" == "<fail>" ]] ||
fail "marketplace_url $kind '$repo' '$url' failed, expected '$expected'"
fi
}
assert_url github obra/superpowers-marketplace "" \
"https://github.com/obra/superpowers-marketplace.git"
assert_url git "" https://git.example.com/x.git "https://git.example.com/x.git"
assert_url github "" "" "<fail>"
assert_url git "" "" "<fail>"
assert_url local /some/path "" "<fail>"
cat >"$work/known_marketplaces.json" <<'EOF'
{
"superpowers-marketplace": {
"source": { "source": "github", "repo": "obra/superpowers-marketplace" }
},
"mroberts": {
"source": { "source": "git", "url": "https://git.mroberts.dev/mroberts/claude-plugin.git" }
},
"bundled": {
"source": { "source": "local" }
}
}
EOF
mapfile -t lines < <(host_marketplaces "$work/known_marketplaces.json")
((${#lines[@]} == 3)) ||
fail "expected 3 marketplace lines, got ${#lines[@]}"
IFS='|' read -r name kind repo url <<<"${lines[1]}"
[[ "$name" == "mroberts" ]] || fail "name mis-parsed: $name"
[[ "$kind" == "git" ]] || fail "source kind mis-parsed: $kind"
[[ -z "$repo" ]] || fail "absent repo should be empty, got '$repo'"
[[ "$url" == "https://git.mroberts.dev/mroberts/claude-plugin.git" ]] ||
fail "url shifted into the wrong field: '$url'"
IFS='|' read -r name kind repo url <<<"${lines[2]}"
[[ "$kind" == "local" ]] || fail "unsupported kind mis-parsed: $kind"
marketplace_url "$kind" "$repo" "$url" >/dev/null 2>&1 &&
fail "a local marketplace should be rejected, not turned into a URL"
cat >"$work/settings.json" <<'EOF'
{
"enabledPlugins": {
"caveman@caveman": true,
"ponytail@ponytail": true,
"disabled-thing@somewhere": false
},
"other": "ignored"
}
EOF
mapfile -t plugins < <(host_enabled_plugins "$work/settings.json")
((${#plugins[@]} == 2)) ||
fail "expected 2 enabled plugins, got ${#plugins[@]}: ${plugins[*]}"
printf '%s\n' "${plugins[@]}" | grep -qx 'disabled-thing@somewhere' &&
fail "a disabled plugin was treated as enabled"
printf '%s\n' "${plugins[@]}" | grep -qx 'caveman@caveman' ||
fail "an enabled plugin is missing"
printf '{}\n' >"$work/empty.json"
mapfile -t none < <(host_enabled_plugins "$work/empty.json")
((${#none[@]} == 0)) || fail "empty settings produced ${#none[@]} plugins"
for forbidden in .credentials.json projects transcripts history.jsonl file-history cache backups; do
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx "$forbidden" &&
fail "CLAUDE_CONFIG_ALLOW must not carry $forbidden"
done
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx skills &&
fail "skills must not be copied; it is seeded with 'sbx skills import'"
printf '%s\n' "${CLAUDE_CONFIG_ALLOW[@]}" | grep -qx CLAUDE.md ||
fail "CLAUDE_CONFIG_ALLOW should carry CLAUDE.md"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All Claude manifest assertions passed.\n'
+66
View File
@@ -0,0 +1,66 @@
#!/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))
}
REPOSITORY="CareEvolution/api-portal"
SANDBOX_NAME="ai-test"
CONFIG_ROOT="$work/config"
REPO_CONFIG_DIR="$CONFIG_ROOT/repos/deadbeef"
REPO_CONFIG_FILE="$REPO_CONFIG_DIR/config"
roundtrip() {
local agent="$1" mode="$2" template="$3" kit_count="$4"
shift 4
rm -rf "$CONFIG_ROOT"
save_config "$agent" "$mode" "$template" "$kit_count" "$@"
unset CONFIG_KITS CONFIG_AWS_PROFILES CONFIG_TEMPLATE
load_config
}
roundtrip codex clone "" 0
[[ "$CONFIG_TEMPLATE" == "" ]] || fail "empty template did not survive: $CONFIG_TEMPLATE"
((${#CONFIG_KITS[@]} == 0)) || fail "expected no kits, got ${#CONFIG_KITS[@]}"
((${#CONFIG_AWS_PROFILES[@]} == 0)) || fail "expected no profiles, got ${#CONFIG_AWS_PROFILES[@]}"
roundtrip claude direct ghcr.io/me/img:v1 0 dev-readonly prod-readonly
[[ "$CONFIG_TEMPLATE" == "ghcr.io/me/img:v1" ]] || fail "template lost: $CONFIG_TEMPLATE"
((${#CONFIG_KITS[@]} == 0)) || fail "profiles leaked into kits: ${CONFIG_KITS[*]}"
[[ "${CONFIG_AWS_PROFILES[*]}" == "dev-readonly prod-readonly" ]] ||
fail "profiles wrong: ${CONFIG_AWS_PROFILES[*]}"
roundtrip claude clone img:v2 2 /kits/a /kits/b api-portal
((${#CONFIG_KITS[@]} == 2)) || fail "expected 2 kits, got ${#CONFIG_KITS[@]}"
[[ "${CONFIG_KITS[*]}" == "/kits/a /kits/b" ]] || fail "kits wrong: ${CONFIG_KITS[*]}"
[[ "${CONFIG_AWS_PROFILES[*]}" == "api-portal" ]] ||
fail "kits leaked into profiles: ${CONFIG_AWS_PROFILES[*]}"
roundtrip claude clone "reg/img:v3" 1 "/kits/with space" "profile one"
[[ "${CONFIG_KITS[0]}" == "/kits/with space" ]] || fail "kit with space mangled: ${CONFIG_KITS[0]}"
[[ "${CONFIG_AWS_PROFILES[0]}" == "profile one" ]] ||
fail "profile with space mangled: ${CONFIG_AWS_PROFILES[0]}"
[[ "$(stat -c '%a' "$REPO_CONFIG_FILE")" == "600" ]] ||
fail "config file is not mode 600"
grep -q 'CONFIG_TEMPLATE=' "$REPO_CONFIG_FILE" || fail "template not persisted"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All config round-trip assertions passed.\n'
+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'
+48
View File
@@ -0,0 +1,48 @@
#!/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))
}
mkdir -p "$work/invoked" "$work/elsewhere"
(
cd "$work/elsewhere"
MISE_ORIGINAL_CWD="$work/invoked"
export MISE_ORIGINAL_CWD
enter_invocation_directory
[[ "$PWD" == "$work/invoked" ]]
) || fail "did not enter MISE_ORIGINAL_CWD"
(
cd "$work/elsewhere"
unset MISE_ORIGINAL_CWD
enter_invocation_directory
[[ "$PWD" == "$work/elsewhere" ]]
) || fail "did not stay in PWD when MISE_ORIGINAL_CWD is unset"
if (
cd "$work/elsewhere"
MISE_ORIGINAL_CWD="$work/does-not-exist"
export MISE_ORIGINAL_CWD
enter_invocation_directory
) 2>/dev/null; then
fail "an unusable MISE_ORIGINAL_CWD was accepted"
fi
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All invocation directory 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'
+67
View File
@@ -0,0 +1,67 @@
#!/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
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
SANDBOX_NAME=ai-test
REPOSITORY=owner/repo
allowed=""
sbx() {
if [[ "$1 $2" == "policy allow" ]]; then
allowed+="${*: -1} "
fi
cat >/dev/null 2>&1 || true
}
allowed=""
DEFAULT_NETWORK="a.example.com,b.example.com c.example.com" install_sandbox_network >/dev/null
for host in a.example.com b.example.com c.example.com; do
[[ "$allowed" == *"$host"* ]] ||
fail "install_sandbox_network skipped $host (comma and space must both split)"
done
allowed=""
DEFAULT_NETWORK="$(printf '*.nuget.org,\nregistry.terraform.io,\nmise.jdx.dev')" \
install_sandbox_network >/dev/null
for host in '*.nuget.org' registry.terraform.io mise.jdx.dev; do
[[ "$allowed" == *"$host"* ]] ||
fail "multi-line AI_SBX_NETWORK dropped $host"
done
allowed=""
DEFAULT_NETWORK="" install_sandbox_network >/dev/null
[[ -z "${allowed// /}" ]] ||
fail "an empty AI_SBX_NETWORK should allow nothing, got: $allowed"
allowed=""
provision_secret TOKEN "reg.example.com,*.cdn.example.com" secret-value >/dev/null
[[ "$allowed" == *"reg.example.com"* ]] ||
fail "provision_secret did not allow reg.example.com"
[[ "$allowed" == *"*.cdn.example.com"* ]] ||
fail "provision_secret did not allow the wildcard host"
allowed=""
provision_secret TOKEN '*.localstack.cloud' secret-value >/dev/null
[[ "$allowed" == *'*.localstack.cloud'* ]] ||
fail "wildcard host was mangled: '$allowed'"
allowed=""
allow_sandbox_host "" >/dev/null
[[ -z "${allowed// /}" ]] || fail "an empty host should be ignored"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All network policy assertions passed.\n'
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -euo pipefail
TASK="$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
code() {
grep -n '^[^#]*sbx ' "$TASK" | grep -v 'ai:sbx'
}
require_force() {
local pattern="$1" line
local found=false
while IFS= read -r line; do
found=true
[[ "$line" == *"--force"* ]] ||
fail "missing --force, will block on a prompt: ${line#*:}"
done < <(code | grep -F "$pattern" || true)
[[ "$found" == true ]] ||
fail "no invocation of '$pattern' found; has it been renamed?"
}
require_force 'sbx secret set '
require_force 'sbx skills import'
require_force 'sbx rm '
while IFS= read -r line; do
[[ "$line" == *"</dev/null"* ]] ||
fail "missing </dev/null: ${line#*:}"
done < <(code | grep -F 'sbx skills import' || true)
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All non-interactive invocation assertions passed.\n'
+93
View File
@@ -0,0 +1,93 @@
#!/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))
}
REPOSITORY="CareEvolution/api-portal"
REPO_CONFIG_DIR="$work"
cat >"$work/secrets" <<'EOF'
# Registry credentials for this repository
FONTAWESOME_API_KEY | npm.fontawesome.com | scripts/npm-auth.sh print FONTAWESOME_API_KEY
PROGET_NPM_TOKEN|proget.careevolution.com|scripts/npm-auth.sh print PROGET_NPM_TOKEN
MULTI | a.example.com,b.example.com | echo hi # trailing comment
MISSING_COMMAND | host.example.com
NO_HOST || echo hi
EOF
mapfile -t lines < <(read_secret_declarations 2>/dev/null)
((${#lines[@]} == 3)) ||
fail "expected 3 valid declarations, got ${#lines[@]}: ${lines[*]}"
IFS='|' read -r var hosts command <<<"${lines[0]}"
[[ "$var" == "FONTAWESOME_API_KEY" ]] || fail "var mis-parsed: '$var'"
[[ "$hosts" == "npm.fontawesome.com" ]] || fail "hosts mis-parsed: '$hosts'"
[[ "$command" == "scripts/npm-auth.sh print FONTAWESOME_API_KEY" ]] ||
fail "command mis-parsed: '$command'"
IFS='|' read -r var hosts command <<<"${lines[1]}"
[[ "$var" == "PROGET_NPM_TOKEN" ]] || fail "unpadded var mis-parsed: '$var'"
[[ "$hosts" == "proget.careevolution.com" ]] || fail "unpadded host mis-parsed: '$hosts'"
IFS='|' read -r var hosts command <<<"${lines[2]}"
[[ "$hosts" == "a.example.com,b.example.com" ]] || fail "multi-host mis-parsed: '$hosts'"
[[ "$command" == "echo hi" ]] || fail "trailing comment not stripped: '$command'"
printf '%s\n' "${lines[@]}" | grep -q MISSING_COMMAND &&
fail "a declaration without a command was accepted"
printf '%s\n' "${lines[@]}" | grep -q NO_HOST &&
fail "a declaration without a host was accepted"
REPO_CONFIG_DIR="$work/nonexistent"
mapfile -t none < <(read_secret_declarations 2>/dev/null)
((${#none[@]} == 0)) || fail "absent file produced ${#none[@]} declarations"
first="$(secret_placeholder FONTAWESOME_API_KEY)"
second="$(secret_placeholder FONTAWESOME_API_KEY)"
[[ "$first" == "$second" ]] || fail "placeholder is not stable: $first vs $second"
[[ "$first" == sbx-cs-* ]] || fail "placeholder lacks the sbx-cs- prefix: $first"
[[ "$(secret_placeholder PROGET_NPM_TOKEN)" != "$first" ]] ||
fail "two variables share one placeholder"
REPOSITORY="other/repo"
[[ "$(secret_placeholder FONTAWESOME_API_KEY)" != "$first" ]] ||
fail "placeholder does not vary by repository"
for entry in "${HOST_WIDE_SECRETS[@]}"; do
IFS='|' read -r var hosts requirement <<<"$entry"
[[ -n "$var" ]] || fail "host-wide entry has no variable: $entry"
[[ -n "$hosts" ]] || fail "host-wide entry $var has no hosts"
[[ "$requirement" == docker || "$requirement" == "-" ]] ||
fail "host-wide entry $var has an unknown requirement: '$requirement'"
done
printf '%s\n' "${HOST_WIDE_SECRETS[@]}" | grep -q '^LOCALSTACK_AUTH_TOKEN|' ||
fail "LOCALSTACK_AUTH_TOKEN should be provisioned host-wide"
printf '%s\n' "${HOST_WIDE_SECRETS[@]}" | grep '^LOCALSTACK_AUTH_TOKEN|' |
grep -q 'localstack\.cloud' ||
fail "LOCALSTACK_AUTH_TOKEN must target localstack.cloud"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All secret declaration 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'
+86
View File
@@ -0,0 +1,86 @@
#!/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
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
assert_encodes() {
local input="$1" expected="$2" actual
actual="$(url_encode "$input")"
[[ "$actual" == "$expected" ]] ||
fail "url_encode '$input' produced '$actual', expected '$expected'"
}
assert_encodes "plain" "plain"
assert_encodes "a b" "a%20b"
assert_encodes "CareEvolution/api-portal" "CareEvolution%2Fapi-portal"
assert_encodes "a&b=c" "a%26b%3Dc"
assert_encodes "a?b#c" "a%3Fb%23c"
assert_encodes "keep.these~chars_-" "keep.these~chars_-"
REPOSITORY="CareEvolution/api-portal"
url="$(token_url)"
[[ "$url" == https://github.com/settings/personal-access-tokens/new\?* ]] ||
fail "URL does not target the token creation form: $url"
query="${url#*\?}"
[[ "$query" != *" "* ]] ||
fail "URL contains a raw space"
[[ "$query" == *"target_name=CareEvolution"* ]] ||
fail "target_name is not the repository owner"
[[ "$query" != *"target_name=CareEvolution%2Fapi-portal"* ]] ||
fail "target_name wrongly carries the full repository name"
[[ "$query" == *"expires_in=$DEFAULT_TOKEN_DAYS"* ]] ||
fail "expires_in is missing"
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
[[ "$query" == *"&$permission"* ]] ||
fail "permission missing from URL: $permission"
done
while read -r level; do
[[ "$level" == "read" || "$level" == "write" || "$level" == "admin" ]] ||
fail "invalid permission level: $level"
done < <(printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | cut -d= -f2)
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
fail "workflows must be requested at write"
for unsupported in checks= repository=; do
[[ "$query" != *"$unsupported"* ]] ||
fail "URL sends a parameter the form ignores: $unsupported"
done
for expected in secret_scanning_alerts=read vulnerability_alerts=read statuses=read actions=write; do
[[ "$query" == *"&$expected"* ]] ||
fail "permission dropped out of the pre-filled URL: $expected"
done
((${#TOKEN_LIMITATIONS[@]})) ||
fail "the limitations list is empty; the Checks gap must be stated"
printf '%s\n' "${TOKEN_LIMITATIONS[@]}" | grep -q 'gh pr checks' ||
fail "the limitations must name gh pr checks"
printf '%s\n' "${TOKEN_LIMITATIONS[@]}" | grep -qi 'tick\|check the box' &&
fail "the limitations must not imply Checks can be granted"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All token URL assertions passed.\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'