Compare commits
18
Commits
v1.1.0
..
16ba06cc6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16ba06cc6d | ||
|
|
e0f6113320 | ||
|
|
b5dfae0696 | ||
|
|
53db7df812 | ||
|
|
14165503d5 | ||
|
|
ad2b33f984 | ||
|
|
2890b1dd98 | ||
|
|
d2b7a5ef63 | ||
|
|
93dc61a024 | ||
|
|
c195a82b82 | ||
|
|
6f4ba117b4 | ||
|
|
5e167d3a0d | ||
|
|
b8bf9f9eff | ||
|
|
dbe6c2e34b | ||
|
|
1fdbbff2e2 | ||
|
|
ace4e81f97 | ||
|
|
4af8c1c153 | ||
|
|
890d10a315 |
@@ -16,13 +16,11 @@ It provides a single command, `ai:sbx`, which:
|
|||||||
- **Derives the repository from `origin`.** No repository name is typed or configured,
|
- **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
|
so the sandbox identity cannot drift from the checkout you are standing in. The
|
||||||
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
|
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
|
||||||
- **Scopes GitHub access to one repository, with no per-repository token work.**
|
- **Scopes GitHub access to one repository.** Setup opens the GitHub token form in
|
||||||
Configure a GitHub App once, and every repository afterwards mints its own
|
your browser with the owner, expiry, name, and permissions already filled in — you
|
||||||
installation token — restricted to that single repository, carrying a fixed
|
pick the repository and paste the token back. It is stored with `sbx secret set` and
|
||||||
permission set, expiring in one hour. The token is stored with `sbx secret set` and
|
|
||||||
injected by Docker's host-side proxy; it is never placed in `GH_TOKEN`, never written
|
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. A manual fine-grained PAT
|
into the repository, and is not readable by the agent.
|
||||||
still works as a fallback.
|
|
||||||
- **Keeps your AWS admin profiles out of the sandbox entirely.** Your `~/.aws`
|
- **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
|
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
|
runs `aws configure export-credentials` against named read-only profiles you approve
|
||||||
@@ -32,8 +30,8 @@ It provides a single command, `ai:sbx`, which:
|
|||||||
grant — `api-portal-readonly` — while Terraform code references the account name,
|
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
|
`api-portal`. A trailing `-readonly` is stripped when the profile is written into the
|
||||||
sandbox, so unmodified Terraform resolves the read-only credentials.
|
sandbox, so unmodified Terraform resolves the read-only credentials.
|
||||||
- **Refreshes both credentials on every launch,** since installation tokens expire
|
- **Refreshes AWS credentials on every launch,** since exported SSO credentials are
|
||||||
hourly and exported SSO credentials are short-lived.
|
short-lived.
|
||||||
- **Requires nothing from the repository.** All state lives under
|
- **Requires nothing from the repository.** All state lives under
|
||||||
`~/.config/ai-sbx/`. Repositories that want first-class support can opt in with three
|
`~/.config/ai-sbx/`. Repositories that want first-class support can opt in with three
|
||||||
lines of `mise.toml`; repositories that do not are unaffected, and developers who do
|
lines of `mise.toml`; repositories that do not are unaffected, and developers who do
|
||||||
@@ -57,10 +55,11 @@ Install these on the **host** — none of them are needed inside the sandbox.
|
|||||||
| Tool | Purpose | Install |
|
| Tool | Purpose | Install |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [mise](https://mise.jdx.dev/) | Runs the task and distributes it | [Getting started](https://mise.jdx.dev/getting-started.html) |
|
| [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/) |
|
| [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 |
|
||||||
| `openssl`, `curl`, [`jq`](https://jqlang.org/) | Signs the App JWT, mints tokens | Already present on most systems |
|
| 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) | `aws configure export-credentials` | Required only when using `--aws-profile` |
|
| [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 |
|
| `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
|
**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
|
on 2026.7.17; verified broken on 2025.10.6, which drops `git::` entries silently — no
|
||||||
@@ -73,11 +72,21 @@ Verify:
|
|||||||
```bash
|
```bash
|
||||||
mise --version
|
mise --version
|
||||||
sbx version
|
sbx version
|
||||||
openssl version
|
|
||||||
jq --version
|
|
||||||
aws --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)
|
### User-level install (recommended)
|
||||||
|
|
||||||
Adding the task to your personal mise config makes `ai:sbx` available in every Git
|
Adding the task to your personal mise config makes `ai:sbx` available in every Git
|
||||||
@@ -88,7 +97,7 @@ Add to `~/.config/mise/config.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[task_config]
|
[task_config]
|
||||||
includes = [
|
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",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,7 +111,7 @@ registered with the forge:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
includes = [
|
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",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -110,9 +119,9 @@ Optional personal defaults:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[env]
|
[env]
|
||||||
AI_SBX_AGENT = "codex"
|
AI_SBX_AGENT = "claude"
|
||||||
AI_SBX_MODE = "clone"
|
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:
|
Confirm it loaded:
|
||||||
@@ -132,7 +141,7 @@ A repository whose team has adopted the workflow can add the same include to its
|
|||||||
```toml
|
```toml
|
||||||
[task_config]
|
[task_config]
|
||||||
includes = [
|
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,103 +154,7 @@ one.
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### 1. Create the GitHub App, once ever
|
### 1. Authenticate your read-only AWS profiles on the host
|
||||||
|
|
||||||
Creating a fine-grained PAT per repository is unavoidable toil — GitHub exposes no API
|
|
||||||
to create one, and the new-token page takes no prefill parameters, so it is manual
|
|
||||||
clicking every time. A GitHub App removes that entirely: installation tokens *are*
|
|
||||||
API-mintable, scoped to named repositories, and expire on their own.
|
|
||||||
|
|
||||||
GitHub also caps you at 50 fine-grained PATs and explicitly recommends an App for
|
|
||||||
automation.
|
|
||||||
|
|
||||||
**Register the App.** Profile picture → **Settings** (or **Your organizations** →
|
|
||||||
the org's **Settings**) → **Developer settings** → **GitHub Apps** → **New GitHub
|
|
||||||
App**.
|
|
||||||
|
|
||||||
Own it personally if the repositories you work on are reachable from your account.
|
|
||||||
Own it under the organization if you want it to survive you and be visible to
|
|
||||||
admins — that requires being an org owner.
|
|
||||||
|
|
||||||
Fill in:
|
|
||||||
|
|
||||||
| Field | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| GitHub App name | Anything unique across GitHub, max 34 characters — e.g. `mroberts-ai-sandbox` |
|
|
||||||
| Homepage URL | Required but unused. Your profile URL is fine |
|
|
||||||
| Webhook → Active | **Uncheck.** Nothing here listens for webhooks |
|
|
||||||
|
|
||||||
**Set repository permissions:**
|
|
||||||
|
|
||||||
| Permission | Access |
|
|
||||||
| --- | --- |
|
|
||||||
| Metadata | Read |
|
|
||||||
| Contents | Read and write |
|
|
||||||
| Pull requests | Read and write |
|
|
||||||
| Issues | Read and write |
|
|
||||||
| Workflows | Read and write |
|
|
||||||
| Actions | Read and write |
|
|
||||||
| Checks | Read |
|
|
||||||
| Commit statuses | Read |
|
|
||||||
| Code scanning alerts | Read and write |
|
|
||||||
| Secret scanning alerts | Read |
|
|
||||||
| Dependabot alerts | Read |
|
|
||||||
|
|
||||||
`Workflows` is the one people miss: pushing *any* commit that touches
|
|
||||||
`.github/workflows/**` fails without it, and it is a separate permission from
|
|
||||||
`Actions`. It has no read level — write is the only option.
|
|
||||||
|
|
||||||
Leave every other permission at **No access**, and grant no account or organization
|
|
||||||
permissions at all.
|
|
||||||
|
|
||||||
Under **Where can this GitHub App be installed?**, choose **Only on this account**.
|
|
||||||
|
|
||||||
Click **Create GitHub App**.
|
|
||||||
|
|
||||||
**Collect the credentials.** On the App's settings page:
|
|
||||||
|
|
||||||
1. Note the **App ID** — a number near the top. It is *not* the Client ID, and the
|
|
||||||
task rejects a client ID if you confuse them.
|
|
||||||
2. Scroll to **Private keys** → **Generate a private key**. A `.pem` downloads
|
|
||||||
immediately; GitHub never shows it again.
|
|
||||||
3. Move it somewhere durable and lock it down:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.config/ai-sbx
|
|
||||||
mv ~/Downloads/your-app.*.private-key.pem ~/.config/ai-sbx/app.pem
|
|
||||||
chmod 600 ~/.config/ai-sbx/app.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
This key is the root of the whole scheme — anything holding it can mint tokens for
|
|
||||||
every repository the App is installed on. Keep it on the host, never inside a
|
|
||||||
sandbox, never in a repository.
|
|
||||||
|
|
||||||
**Install the App.** On the same page, **Install App** → **Install** next to your
|
|
||||||
account → **Only select repositories** → pick the repositories the agent may reach →
|
|
||||||
**Install**.
|
|
||||||
|
|
||||||
Prefer *Only select repositories* over *All repositories*. Installation tokens are
|
|
||||||
additionally narrowed to the current repository at mint time, but the installation is
|
|
||||||
the outer bound, and it is the one you will forget about.
|
|
||||||
|
|
||||||
Installing on an organization you do not own sends an approval request to an owner.
|
|
||||||
|
|
||||||
**Record it:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mise run ai:sbx -- app \
|
|
||||||
--app-id 987654 \
|
|
||||||
--key ~/.config/ai-sbx/app.pem
|
|
||||||
```
|
|
||||||
|
|
||||||
The task verifies the ID is numeric and the key parses as RSA before storing anything,
|
|
||||||
then writes `~/.config/ai-sbx/github-app` at mode 600. Re-run it any time to rotate the
|
|
||||||
key or point at a different App.
|
|
||||||
|
|
||||||
To add a repository later, install the App on it and run `setup` there — no new key, no
|
|
||||||
new token, nothing to rotate.
|
|
||||||
|
|
||||||
### 2. Authenticate your read-only AWS profiles on the host
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws sso login --profile api-portal-readonly
|
aws sso login --profile api-portal-readonly
|
||||||
@@ -251,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
|
Setup fails fast with the exact `aws sso login` command if a profile is missing or its
|
||||||
session has expired.
|
session has expired.
|
||||||
|
|
||||||
### 3. Set up a repository, once
|
### 2. Set up a repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/src/api-portal
|
cd ~/src/api-portal
|
||||||
@@ -261,27 +174,67 @@ mise run ai:sbx -- setup \
|
|||||||
--aws-profile prod-readonly
|
--aws-profile prod-readonly
|
||||||
```
|
```
|
||||||
|
|
||||||
Derives the repository from `origin`, resolves the App installation, creates the
|
Derives the repository from `origin`, creates the sandbox, then opens the GitHub token
|
||||||
sandbox, and installs a first token. No prompts.
|
form in your browser with everything pre-filled:
|
||||||
|
|
||||||
Without a configured App, setup instead prompts you to paste a fine-grained PAT
|
```text
|
||||||
carrying the same permissions, restricted to that one repository, with the shortest
|
name ai-sbx api-portal
|
||||||
expiration you will tolerate.
|
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
|
||||||
|
```
|
||||||
|
|
||||||
### 4. Run the agent
|
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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run ai:sbx -- run
|
mise run ai:sbx -- run
|
||||||
```
|
```
|
||||||
|
|
||||||
Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
|
Refreshes AWS credentials, then attaches. Pass agent arguments after a second `--`:
|
||||||
agent arguments after a second `--`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run ai:sbx -- run -- "Review the Terraform plan for the staging workspace"
|
mise run ai:sbx -- run -- "Review the Terraform plan for the staging workspace"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Inside the sandbox
|
### 4. Inside the sandbox
|
||||||
|
|
||||||
`gh` is already authenticated through the proxy, for that repository only:
|
`gh` is already authenticated through the proxy, for that repository only:
|
||||||
|
|
||||||
@@ -290,6 +243,13 @@ gh pr list
|
|||||||
gh pr create --fill
|
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:
|
AWS named profiles work as Terraform expects:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -310,11 +270,12 @@ provider "aws" {
|
|||||||
|
|
||||||
| Command | Effect |
|
| Command | Effect |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `app --app-id ID --key PATH` | Record the GitHub App once, for every repository. Works outside a repository |
|
| `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 |
|
||||||
| `setup [options]` | Configure the repository, resolve the App installation, create the sandbox, install credentials |
|
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change. Always prompts |
|
||||||
| `run [-- args...]` | Mint a fresh GitHub token, refresh AWS credentials, attach to the agent |
|
| `run [--launch MODE] [-- args...]` | Refresh AWS credentials and the repository's mise tools, then attach to the agent or to a tmux workspace |
|
||||||
| `refresh` | Same, without attaching |
|
| `refresh` | Refresh AWS credentials, without attaching |
|
||||||
| `status` | Show repository, sandbox, agent, mode, App installation, profile mapping, stored secrets |
|
| `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 |
|
| `remove` | Remove the sandbox and this repository's local configuration |
|
||||||
|
|
||||||
### `setup` options
|
### `setup` options
|
||||||
@@ -322,19 +283,304 @@ provider "aws" {
|
|||||||
| Option | Default | Effect |
|
| Option | Default | Effect |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `--aws-profile NAME` | none | Host profile to expose. Repeatable. Trailing `-readonly` stripped inside the sandbox |
|
| `--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 |
|
| `--agent NAME` | `claude` | Sandbox agent. See `sbx create --help` for the list |
|
||||||
| `--clone` | on | Give the agent a Git worktree on its own branch |
|
| `--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 |
|
| `--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 |
|
| `--replace` | off | Destroy and recreate an existing sandbox |
|
||||||
|
|
||||||
### Environment defaults
|
### Environment defaults
|
||||||
|
|
||||||
| Variable | Default | Overrides |
|
| Variable | Default | Overrides |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `AI_SBX_AGENT` | `codex` | `--agent` |
|
| `AI_SBX_AGENT` | `claude` | `--agent` |
|
||||||
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
|
| `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
|
## AWS profile naming
|
||||||
|
|
||||||
@@ -358,14 +604,12 @@ Variants such as `_readonly`, `-ro`, and `-read-only` are **not** stripped.
|
|||||||
## Where state lives
|
## Where state lives
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.config/ai-sbx/github-app mode 600, App ID + key path
|
|
||||||
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
|
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository file holds repository identity, sandbox name, agent, mode, branch, App
|
Holds repository identity, sandbox name, agent, mode, and the approved host
|
||||||
installation ID, and the approved host profile names — no secrets. The App private key
|
profile names — no secrets. The GitHub token lives in the `sbx` secret store; AWS
|
||||||
stays wherever you put it; only its path is recorded. Tokens live in the `sbx` secret
|
credentials exist only inside the sandbox and only until they expire.
|
||||||
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`.
|
Inspect the current repository's state with `mise run ai:sbx -- status`.
|
||||||
|
|
||||||
@@ -375,13 +619,16 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
|||||||
or `mise.toml` could otherwise choose which credentials get loaded. Profile approval
|
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
|
lives in your user-owned config; the repository only supplies its own identity, which
|
||||||
is cross-checked against `origin` on every run.
|
is cross-checked against `origin` on every run.
|
||||||
- **The App private key is the real secret.** Tokens expire hourly; the key does not.
|
- **One token per repository, per developer, with an expiry.** Nothing is shared: no
|
||||||
Anything that reads it can mint tokens for every repository the App is installed on.
|
private key, no client secret, no broker. Each developer's token is capped by their
|
||||||
Host only, mode 600, never mounted into a sandbox. Rotate by generating a new key,
|
own access, and organization owners can require approval and enforce a maximum
|
||||||
re-running `app`, and deleting the old key at GitHub.
|
lifetime.
|
||||||
- **App identity, not yours.** Installation tokens act as the App, so its commits and
|
- **The token acts as you.** Its commits and comments carry your identity, so treat
|
||||||
comments are attributable and its access is revocable independently of your account —
|
the agent's output as your own work. Revoke at
|
||||||
the main practical advantage over a PAT, which acts as you.
|
[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
|
- **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;
|
that cannot cause damage if the agent misbehaves. Terraform `plan` needs read access;
|
||||||
`apply` should stay outside the sandbox.
|
`apply` should stay outside the sandbox.
|
||||||
@@ -393,14 +640,14 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tests/profile-mapping.test.sh
|
for test in tests/*.test.sh; do bash "$test"; done
|
||||||
bash tests/github-app-jwt.test.sh
|
shellcheck -x tasks/ai/sbx tasks/ai/workspace tests/*.sh
|
||||||
shellcheck -x tasks/ai/sbx tests/*.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The JWT test generates a throwaway keypair, verifies the signature with
|
The token test checks URL encoding, that `target_name` carries the owner rather than
|
||||||
`openssl dgst -verify`, confirms a tampered input fails to verify, and checks the
|
the full repository name, that every permission survives into the query at a valid
|
||||||
permission set against GitHub's schema. Neither test touches the network, GitHub, or
|
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`.
|
`sbx`.
|
||||||
|
|
||||||
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
|
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
+1118
-257
File diff suppressed because it is too large
Load Diff
@@ -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 "$@"
|
||||||
Executable
+110
@@ -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'
|
||||||
Executable
+66
@@ -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'
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
#!/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))
|
|
||||||
}
|
|
||||||
|
|
||||||
openssl genrsa -out "$work/key.pem" 2048 2>/dev/null
|
|
||||||
openssl rsa -in "$work/key.pem" -pubout -out "$work/pub.pem" 2>/dev/null
|
|
||||||
|
|
||||||
APP_ID=123456
|
|
||||||
APP_PRIVATE_KEY_FILE="$work/key.pem"
|
|
||||||
|
|
||||||
jwt="$(github_app_jwt)"
|
|
||||||
|
|
||||||
IFS='.' read -r header payload signature <<<"$jwt"
|
|
||||||
[[ -n "$header" && -n "$payload" && -n "$signature" ]] ||
|
|
||||||
fail "JWT is not three segments: $jwt"
|
|
||||||
|
|
||||||
[[ "$jwt" =~ ^[A-Za-z0-9_.-]+$ ]] ||
|
|
||||||
fail "JWT contains characters outside the base64url alphabet"
|
|
||||||
|
|
||||||
decode() {
|
|
||||||
local padded="$1"
|
|
||||||
while ((${#padded} % 4)); do
|
|
||||||
padded+="="
|
|
||||||
done
|
|
||||||
printf '%s' "$padded" | tr '_-' '/+' | openssl base64 -d -A
|
|
||||||
}
|
|
||||||
|
|
||||||
[[ "$(decode "$header" | jq -r '.alg')" == "RS256" ]] ||
|
|
||||||
fail "header alg is not RS256"
|
|
||||||
|
|
||||||
[[ "$(decode "$payload" | jq -r '.iss')" == "123456" ]] ||
|
|
||||||
fail "payload iss does not carry the App ID"
|
|
||||||
|
|
||||||
iat="$(decode "$payload" | jq -r '.iat')"
|
|
||||||
exp="$(decode "$payload" | jq -r '.exp')"
|
|
||||||
now="$(date +%s)"
|
|
||||||
|
|
||||||
((iat <= now)) || fail "iat is in the future ($iat > $now)"
|
|
||||||
((exp - iat <= 600)) || fail "lifetime exceeds GitHub's 10 minute cap"
|
|
||||||
((exp > now)) || fail "token is already expired on creation"
|
|
||||||
|
|
||||||
printf '%s' "$header.$payload" >"$work/signing_input"
|
|
||||||
decode "$signature" >"$work/sig.bin"
|
|
||||||
|
|
||||||
openssl dgst -sha256 -verify "$work/pub.pem" \
|
|
||||||
-signature "$work/sig.bin" "$work/signing_input" >/dev/null 2>&1 ||
|
|
||||||
fail "signature does not verify against the public key"
|
|
||||||
|
|
||||||
printf '%s' "$header.${payload}x" >"$work/tampered"
|
|
||||||
if openssl dgst -sha256 -verify "$work/pub.pem" \
|
|
||||||
-signature "$work/sig.bin" "$work/tampered" >/dev/null 2>&1; then
|
|
||||||
fail "a tampered signing input still verified"
|
|
||||||
fi
|
|
||||||
|
|
||||||
jq -e . >/dev/null <<<"$GITHUB_APP_PERMISSIONS" ||
|
|
||||||
fail "GITHUB_APP_PERMISSIONS is not valid JSON"
|
|
||||||
|
|
||||||
[[ "$(jq -r '.workflows' <<<"$GITHUB_APP_PERMISSIONS")" == "write" ]] ||
|
|
||||||
fail "workflows must be write; the schema defines no read level"
|
|
||||||
|
|
||||||
while read -r level; do
|
|
||||||
[[ "$level" == "read" || "$level" == "write" ]] ||
|
|
||||||
fail "invalid permission level: $level"
|
|
||||||
done < <(jq -r '.[]' <<<"$GITHUB_APP_PERMISSIONS")
|
|
||||||
|
|
||||||
if ((failures)); then
|
|
||||||
printf '%d assertion(s) failed\n' "$failures" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf 'All GitHub App JWT assertions passed.\n'
|
|
||||||
@@ -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'
|
||||||
@@ -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'
|
||||||
Executable
+67
@@ -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'
|
||||||
Executable
+45
@@ -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'
|
||||||
Executable
+93
@@ -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'
|
||||||
@@ -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'
|
||||||
Executable
+86
@@ -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'
|
||||||
@@ -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'
|
||||||
Reference in New Issue
Block a user