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.
ai-sandbox
A shareable mise task that runs an AI coding agent inside a Docker Sandbox, scoped to exactly one GitHub repository and one set of read-only AWS roles.
Description
Giving an agent your everyday credentials gives it your everyday blast radius. A
long-lived PAT reaches every repository you can reach; ~/.aws reaches every role your
SSO session can assume, admin included. This task narrows both, and puts the boundary
somewhere the agent cannot edit.
It provides a single command, ai:sbx, which:
- Derives the repository from
origin. No repository name is typed or configured, so the sandbox identity cannot drift from the checkout you are standing in. The sandbox name isai-<owner>-<repo>-<digest>, stable across runs. - Scopes GitHub access to one repository, with no per-repository token work.
Configure a GitHub App once, and every repository afterwards mints its own
installation token — restricted to that single repository, carrying a fixed
permission set, expiring in one hour. The token is stored with
sbx secret setand injected by Docker's host-side proxy; it is never placed inGH_TOKEN, never written into the repository, and is not readable by the agent. A manual fine-grained PAT still works as a fallback. - Keeps your AWS admin profiles out of the sandbox entirely. Your
~/.awsdirectory and your SSO token cache are never mounted or copied. Instead, the host runsaws configure export-credentialsagainst named read-only profiles you approve once, and only the resulting short-lived role credentials are written into the sandbox. The agent cannot use your SSO session to discover or request other roles. - Renames profiles for Terraform. Host profiles are commonly suffixed to mark the
grant —
api-portal-readonly— while Terraform code references the account name,api-portal. A trailing-readonlyis stripped when the profile is written into the sandbox, so unmodified Terraform resolves the read-only credentials. - Refreshes both credentials on every launch, since installation tokens expire hourly and exported SSO credentials are short-lived.
- Requires nothing from the repository. All state lives under
~/.config/ai-sbx/. Repositories that want first-class support can opt in with three lines ofmise.toml; repositories that do not are unaffected, and developers who do not use mise never notice.
What the sandbox receives
| Receives | Does not receive |
|---|---|
| Temporary read-only role credentials | Your SSO access or refresh token |
Profile names, minus the -readonly suffix |
Your host ~/.aws/config or credentials |
| Proxy-injected GitHub auth for one repository | A readable GitHub token |
| The repository working tree | Admin profile names or role assignments |
Installation
Dependencies
Install these on the host — none of them are needed inside the sandbox.
| Tool | Purpose | Install |
|---|---|---|
| mise | Runs the task and distributes it | Getting started |
Docker Sandboxes (sbx) |
Sandbox, secret store, credential proxy | Ships with Docker Desktop |
openssl, curl, jq |
Signs the App JWT, mints tokens | Already present on most systems |
| AWS CLI v2 | aws configure export-credentials |
Required only when using --aws-profile |
git, sha256sum |
Repository identity | Already present on most systems |
mise must be recent enough to load remote git:: task includes. Verified working
on 2026.7.17; verified broken on 2025.10.6, which drops git:: entries silently — no
error, no clone attempted, mise tasks ls simply prints nothing. If that is what you
see, run mise self-update (note: mise upgrade updates your tools, not mise
itself).
Verify:
mise --version
sbx version
openssl version
jq --version
aws --version
User-level install (recommended)
Adding the task to your personal mise config makes ai:sbx available in every Git
repository on the machine, without touching a single repository.
Add to ~/.config/mise/config.toml:
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
Pin ref to a tag or, better, a commit SHA. The task executes on your host with your
credentials — a moving ref means an unreviewed change runs the next time the cache
expires.
The repository is public, so https needs no credentials and works on a fresh machine
with no SSH agent. For a private fork, use the SSH form instead, which requires a key
registered with the forge:
includes = [
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
Optional personal defaults:
[env]
AI_SBX_AGENT = "codex"
AI_SBX_MODE = "clone"
AI_SBX_BRANCH = "ai-sbx"
Confirm it loaded:
mise tasks ls # expect: ai:sbx
mise caches the clone under $MISE_CACHE_DIR/remote-git-tasks-cache. Force a refetch
with MISE_TASK_REMOTE_NO_CACHE=true.
Repository-level install (optional)
A repository whose team has adopted the workflow can add the same include to its
mise.toml:
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
]
Developers with mise get ai:sbx; developers without mise are unaffected. Do not put
AWS profile names or tokens in a repository config — the approved profile list is
per-user state, and no secret belongs in a repository.
Both installs can coexist. Nothing about the workflow requires the repository-level one.
Usage
1. Create the GitHub App, once ever
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:
- 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.
- Scroll to Private keys → Generate a private key. A
.pemdownloads immediately; GitHub never shows it again. - Move it somewhere durable and lock it down:
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:
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
aws sso login --profile api-portal-readonly
aws sso login --profile prod-readonly
Setup fails fast with the exact aws sso login command if a profile is missing or its
session has expired.
3. Set up a repository, once
cd ~/src/api-portal
mise run ai:sbx -- setup \
--aws-profile api-portal-readonly \
--aws-profile prod-readonly
Derives the repository from origin, resolves the App installation, creates the
sandbox, and installs a first token. No prompts.
Without a configured App, setup instead prompts you to paste a fine-grained PAT carrying the same permissions, restricted to that one repository, with the shortest expiration you will tolerate.
4. Run the agent
mise run ai:sbx -- run
Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
agent arguments after a second --:
mise run ai:sbx -- run -- "Review the Terraform plan for the staging workspace"
5. Inside the sandbox
gh is already authenticated through the proxy, for that repository only:
gh pr list
gh pr create --fill
AWS named profiles work as Terraform expects:
aws sts get-caller-identity --profile api-portal
AWS_PROFILE=api-portal terraform init
AWS_PROFILE=api-portal terraform plan
provider "aws" {
profile = "api-portal"
region = "us-east-1"
}
Commands
| 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, resolve the App installation, create the sandbox, install credentials |
run [-- args...] |
Mint a fresh GitHub token, refresh AWS credentials, attach to the agent |
refresh |
Same, without attaching |
status |
Show repository, sandbox, agent, mode, App installation, profile mapping, stored secrets |
remove |
Remove the sandbox and this repository's local configuration |
setup options
| Option | Default | Effect |
|---|---|---|
--aws-profile NAME |
none | Host profile to expose. Repeatable. Trailing -readonly stripped inside the sandbox |
--agent NAME |
codex |
Sandbox agent. See sbx create --help for the list |
--clone |
on | Give the agent a Git worktree on its own branch |
--direct |
off | Mount the host working tree read-write |
--branch NAME |
ai-sbx |
Branch used by --clone |
--replace |
off | Destroy and recreate an existing sandbox |
Environment defaults
| Variable | Default | Overrides |
|---|---|---|
AI_SBX_AGENT |
codex |
--agent |
AI_SBX_MODE |
clone |
--clone / --direct |
AI_SBX_BRANCH |
ai-sbx |
--branch |
AWS profile naming
Only a trailing -readonly is removed. Everything else passes through:
| Host profile | Sandbox profile |
|---|---|
api-portal-readonly |
api-portal |
prod-readonly |
prod |
dev |
dev |
readonly-first |
readonly-first |
team-readonly-readonly |
team-readonly |
Two host profiles that collapse to the same sandbox name — dev-readonly and dev —
are rejected at setup, before any credentials are exported. Silently letting one
overwrite the other would hand Terraform the wrong identity under a name that looks
right.
Variants such as _readonly, -ro, and -read-only are not stripped.
Where state lives
~/.config/ai-sbx/github-app mode 600, App ID + key path
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
The repository file holds repository identity, sandbox name, agent, mode, branch, App
installation ID, and the approved host profile names — no secrets. The App private key
stays wherever you put it; only its path is recorded. Tokens live in the sbx secret
store; AWS credentials exist only inside the sandbox and only until they expire.
Inspect the current repository's state with mise run ai:sbx -- status.
Security notes
- The repository is not the boundary. A repository-controlled file such as
.envrcormise.tomlcould otherwise choose which credentials get loaded. Profile approval lives in your user-owned config; the repository only supplies its own identity, which is cross-checked againstoriginon every run. - The App private key is the real secret. Tokens expire hourly; the key does not.
Anything that reads it can mint tokens for every repository the App is installed on.
Host only, mode 600, never mounted into a sandbox. Rotate by generating a new key,
re-running
app, and deleting the old key at GitHub. - App identity, not yours. Installation tokens act as the App, so its commits and comments are attributable and its access is revocable independently of your account — the main practical advantage over a PAT, which acts as you.
- Read-only AWS roles. The sandbox boundary limits reach, not intent. Grant roles
that cannot cause damage if the agent misbehaves. Terraform
planneeds read access;applyshould stay outside the sandbox. Contents: writeincludes force-push and branch deletion. There is no finer split. Branch protection or rulesets are the actual guard, not token scoping.--directweakens isolation. The agent writes directly to your working tree. Prefer the default--clone.
Development
bash tests/profile-mapping.test.sh
bash tests/github-app-jwt.test.sh
shellcheck -x tasks/ai/sbx tests/*.sh
The JWT test generates a throwaway keypair, verifies the signature with
openssl dgst -verify, confirms a tampered input fails to verify, and checks the
permission set against GitHub's schema. Neither test touches the network, GitHub, or
sbx.
Task names come from directory nesting, not from colons in filenames: tasks/ai/sbx
registers as ai:sbx, whereas a file literally named tasks/ai:sbx registers as
ai_sbx. Task files must be executable.