Commit Graph
12 Commits
Author SHA1 Message Date
mroberts ad2b33f984 Split a multi-line AI_SBX_NETWORK correctly
A long host list is naturally written as a multi-line TOML string, but read
stops at the first newline, so only the first host was ever allowed. The rest
failed later as connection errors with nothing pointing back at the list.

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

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

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

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

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

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

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

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

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

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

sbx secret set-custom was measured to overwrite silently and has no --force
flag, so the non-interactive test now matches sbx secret set precisely rather
than by prefix.
2026-07-31 11:42:36 -05:00
mroberts 6f4ba117b4 Stop setup blocking on invisible sbx confirmation prompts
sbx skills import prompts before overwriting each skill already in the shared
store. setup called it with stdout and stderr redirected and stdin left
attached, so on any second run the prompt was invisible and setup hung
indefinitely partway through installing the Claude configuration.

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

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

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

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

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

The marketplace and enabled-plugin extraction move out of the install function
into host_marketplaces and host_enabled_plugins so they can be exercised
directly. The tests cover the pipe separator that keeps an absent repo from
shifting a url leftwards, rejection of marketplace sources that are neither
github nor git, disabled plugins being excluded, and the allowlist refusing to
carry credentials, transcripts or history.
2026-07-31 08:42:28 -05:00
mroberts ace4e81f97 Pass a custom template and mixin kits through to sbx
Sandboxes ignore the host ~/.claude by design: the agent runs as a separate
user with HOME elsewhere, so even a read-only mount is not picked up. Skills
can be shared with sbx skills import, but plugins carry commands, hooks and
MCP servers that only a custom image can deliver.

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

save_config now packs two arrays into one argument list separated by a count,
so it ships with a round-trip test covering empty arrays, values containing
spaces, and the boundary between kits and AWS profiles.
2026-07-30 16:29:25 -05:00
mroberts 890d10a315 Replace GitHub App tokens with a pre-filled token form
The App approach does not survive contact with a hundred developers and
hundreds of repositories. Minting installation tokens requires the App private
key on every developer's machine, and a key that widely distributed is a key
that grants org-wide minting to everyone holding it.

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

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

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

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

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

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

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

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

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

Adds a JWT test that verifies signatures against a generated public key and
confirms tampered input fails to verify.
2026-07-30 14:25:37 -05:00
mroberts d43abe693e Add repository-scoped AI sandbox mise task
Provides a shareable mise task, ai:sbx, that runs an AI coding agent in a
Docker Sandbox scoped to a single GitHub repository and a set of read-only
AWS roles.

The repository is derived from origin rather than configured, so the sandbox
identity cannot drift from the checkout in use. GitHub access is a
repository-scoped fine-grained PAT held in the sbx secret store and injected
by its host-side proxy, so the token is never exposed to the agent. The host
~/.aws directory and SSO token cache are never mounted; instead the host
exports short-lived credentials for approved read-only profiles and only
those land in the sandbox.

Host profiles are commonly suffixed to mark the grant (api-portal-readonly)
while Terraform references the account name (api-portal), so a trailing
-readonly is stripped when the profile is written into the sandbox. Two host
profiles that collapse to the same sandbox name are rejected during setup,
before any credentials are exported, since a silent overwrite would hand
Terraform the wrong identity under a plausible-looking name.

All state lives under ~/.config/ai-sbx; repositories supply nothing and need
no mise.toml.
2026-07-30 13:52:57 -05:00