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.
This commit is contained in:
2026-07-30 14:25:37 -05:00
parent d43abe693e
commit b03fcd6dd7
3 changed files with 496 additions and 59 deletions
+151 -43
View File
@@ -16,10 +16,13 @@ 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.** A fine-grained PAT restricted to that - **Scopes GitHub access to one repository, with no per-repository token work.**
repository is stored with `sbx secret set`. Docker's host-side proxy injects it into Configure a GitHub App once, and every repository afterwards mints its own
outbound requests; the token is never placed in `GH_TOKEN`, never written into the installation token — restricted to that single repository, carrying a fixed
repository, and is not readable by the agent. 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
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 `~/.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
@@ -29,8 +32,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 credentials on every launch,** since exported SSO credentials are - **Refreshes both credentials on every launch,** since installation tokens expire
short-lived. hourly and exported SSO credentials are 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
@@ -55,17 +58,24 @@ Install these on the **host** — none of them are needed inside the sandbox.
| --- | --- | --- | | --- | --- | --- |
| [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 | Ships with [Docker Desktop](https://docs.docker.com/desktop/) |
| `openssl`, `curl`, [`jq`](https://jqlang.org/) | Signs the App JWT, mints tokens | Already present on most systems |
| [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) | `aws configure export-credentials` | Required only when using `--aws-profile` |
| [`jq`](https://jqlang.org/) | Parses exported 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 |
**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: Verify:
```bash ```bash
mise --version mise --version
sbx version sbx version
aws --version openssl version
jq --version jq --version
aws --version
``` ```
### User-level install (recommended) ### User-level install (recommended)
@@ -135,7 +145,103 @@ one.
## Usage ## Usage
### 1. Authenticate your read-only AWS profiles on the host ### 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:
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
@@ -145,7 +251,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.
### 2. Set up a repository, once ### 3. Set up a repository, once
```bash ```bash
cd ~/src/api-portal cd ~/src/api-portal
@@ -155,40 +261,27 @@ mise run ai:sbx -- setup \
--aws-profile prod-readonly --aws-profile prod-readonly
``` ```
This derives the repository from `origin`, creates the sandbox, then prompts for a Derives the repository from `origin`, resolves the App installation, creates the
fine-grained GitHub PAT. Create it at sandbox, and installs a first token. No prompts.
[github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
scoped to that one repository:
```text Without a configured App, setup instead prompts you to paste a fine-grained PAT
Resource owner: your user or organization carrying the same permissions, restricted to that one repository, with the shortest
Repository access: Only select repositories expiration you will tolerate.
Selected repository: owner/api-portal
Permissions:
Metadata: Read
Contents: Read and write
Pull requests: Read and write
Actions: Read, if required
Issues: Only if required
Workflows: No access unless explicitly required
Expiration: the shortest period you will tolerate
```
Paste it at the prompt. It is not written to shell history. ### 4. Run the agent
### 3. Run the agent
```bash ```bash
mise run ai:sbx -- run mise run ai:sbx -- run
``` ```
Refreshes AWS credentials, then attaches. Pass agent arguments after a second `--`: Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
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"
``` ```
### 4. Inside the sandbox ### 5. 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:
@@ -217,10 +310,11 @@ provider "aws" {
| Command | Effect | | Command | Effect |
| --- | --- | | --- | --- |
| `setup [options]` | Configure the repository, create the sandbox, store the GitHub token, install AWS profiles | | `app --app-id ID --key PATH` | Record the GitHub App once, for every repository. Works outside a repository |
| `run [-- args...]` | Refresh AWS credentials and attach to the agent | | `setup [options]` | Configure the repository, resolve the App installation, create the sandbox, install credentials |
| `refresh` | Refresh AWS credentials without attaching | | `run [-- args...]` | Mint a fresh GitHub token, refresh AWS credentials, attach to the agent |
| `status` | Show repository, sandbox, agent, mode, profile mapping, stored secrets | | `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 | | `remove` | Remove the sandbox and this repository's local configuration |
### `setup` options ### `setup` options
@@ -264,12 +358,14 @@ 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
``` ```
Holds repository identity, sandbox name, agent, mode, branch, and the approved host The repository file holds repository identity, sandbox name, agent, mode, branch, App
profile names. Tokens live in the `sbx` secret store; AWS credentials exist only inside installation ID, and the approved host profile names — no secrets. The App private key
the sandbox and only until they expire. 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`. Inspect the current repository's state with `mise run ai:sbx -- status`.
@@ -279,12 +375,18 @@ 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.
- **Fine-grained PATs, one per repository, with an expiration.** A classic PAT reaches - **The App private key is the real secret.** Tokens expire hourly; the key does not.
every repository you can reach; that is the thing this design exists to prevent. 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 - **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.
- **Rotation is manual.** Revoke a PAT at GitHub and re-run `setup` to replace it. - **`Contents: write` includes force-push and branch deletion.** There is no finer
split. Branch protection or rulesets are the actual guard, not token scoping.
- **`--direct` weakens isolation.** The agent writes directly to your working tree. - **`--direct` weakens isolation.** The agent writes directly to your working tree.
Prefer the default `--clone`. Prefer the default `--clone`.
@@ -292,9 +394,15 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
```bash ```bash
bash tests/profile-mapping.test.sh bash tests/profile-mapping.test.sh
shellcheck -x tasks/ai/sbx 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` 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 registers as `ai:sbx`, whereas a file literally named `tasks/ai:sbx` registers as
`ai_sbx`. Task files must be executable. `ai_sbx`. Task files must be executable.
+257 -11
View File
@@ -6,16 +6,39 @@ CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
DEFAULT_AGENT="${AI_SBX_AGENT:-codex}" DEFAULT_AGENT="${AI_SBX_AGENT:-codex}"
DEFAULT_MODE="${AI_SBX_MODE:-clone}" DEFAULT_MODE="${AI_SBX_MODE:-clone}"
DEFAULT_BRANCH="${AI_SBX_BRANCH:-ai-sbx}" DEFAULT_BRANCH="${AI_SBX_BRANCH:-ai-sbx}"
APP_CONFIG_FILE="$CONFIG_ROOT/github-app"
# Keys and levels are validated against GitHub's app-permissions schema.
# "workflows" has no read level; write is required to push any commit that
# touches .github/workflows.
GITHUB_APP_PERMISSIONS='{
"metadata": "read",
"contents": "write",
"pull_requests": "write",
"issues": "write",
"workflows": "write",
"actions": "write",
"checks": "read",
"statuses": "read",
"security_events": "write",
"secret_scanning_alerts": "read",
"vulnerability_alerts": "read"
}'
usage() { usage() {
cat <<'EOF' cat <<'EOF'
Usage: Usage:
mise run ai:sbx -- app --app-id ID --key PATH
mise run ai:sbx -- setup [options] mise run ai:sbx -- setup [options]
mise run ai:sbx -- refresh mise run ai:sbx -- refresh
mise run ai:sbx -- run [-- agent arguments...] mise run ai:sbx -- run [-- agent arguments...]
mise run ai:sbx -- status mise run ai:sbx -- status
mise run ai:sbx -- remove mise run ai:sbx -- remove
App options (configured once, for every repository):
--app-id ID Numeric GitHub App ID, not the client ID.
--key PATH The App's RSA private key (.pem).
Setup options: Setup options:
--aws-profile NAME Host AWS profile to expose inside the sandbox. --aws-profile NAME Host AWS profile to expose inside the sandbox.
May be supplied more than once. A trailing May be supplied more than once. A trailing
@@ -50,6 +73,130 @@ require_command() {
die "Required command not found: $1" die "Required command not found: $1"
} }
github_app_configured() {
[[ -f "$APP_CONFIG_FILE" ]]
}
load_app_config() {
github_app_configured ||
die "No GitHub App configured. Run: mise run ai:sbx -- app --app-id ID --key PATH"
# shellcheck disable=SC1090
source "$APP_CONFIG_FILE"
[[ -n "${APP_ID:-}" ]] ||
die "APP_ID is missing from $APP_CONFIG_FILE"
[[ -r "${APP_PRIVATE_KEY_FILE:-}" ]] ||
die "GitHub App private key is not readable: ${APP_PRIVATE_KEY_FILE:-unset}"
}
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
# GitHub caps App JWT lifetime at 10 minutes and rejects future iat values, so
# backdate slightly to tolerate clock skew and stay well inside the cap.
github_app_jwt() {
local now header payload signing_input signature
now="$(date +%s)"
header='{"alg":"RS256","typ":"JWT"}'
payload="$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' \
"$((now - 60))" "$((now + 540))" "$APP_ID")"
signing_input="$(printf '%s' "$header" | base64url).$(printf '%s' "$payload" | base64url)"
signature="$(
printf '%s' "$signing_input" |
openssl dgst -sha256 -sign "$APP_PRIVATE_KEY_FILE" -binary |
base64url
)"
printf '%s.%s' "$signing_input" "$signature"
}
github_api() {
local method="$1" path="$2" token="$3"
shift 3
curl --silent --show-error \
--request "$method" \
--header "Authorization: Bearer $token" \
--header "Accept: application/vnd.github+json" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com$path" \
"$@"
}
# GitHub answers errors with HTTP 4xx and a .message body, which curl alone
# treats as success, so every response is inspected before it is used.
github_api_field() {
local response="$1" field="$2" context="$3" value
if value="$(jq -er "$field" <<<"$response" 2>/dev/null)"; then
printf '%s' "$value"
return
fi
local message
message="$(jq -r '.message // "unrecognized response"' <<<"$response" 2>/dev/null)" ||
message="unparseable response"
die "$context: $message"
}
resolve_installation_id() {
local jwt response
jwt="$(github_app_jwt)"
response="$(github_api GET "/repos/$REPOSITORY/installation" "$jwt")"
github_api_field "$response" '.id' \
"GitHub App is not installed on $REPOSITORY"
}
mint_github_token() {
local jwt response body
jwt="$(github_app_jwt)"
body="$(
jq -nc \
--arg repo "${REPOSITORY#*/}" \
--argjson permissions "$GITHUB_APP_PERMISSIONS" \
'{repositories: [$repo], permissions: $permissions}'
)"
response="$(
github_api POST \
"/app/installations/$CONFIG_INSTALLATION_ID/access_tokens" \
"$jwt" --data "$body"
)"
github_api_field "$response" '.token' \
"Could not mint an installation token for $REPOSITORY"
}
install_github_token() {
require_command openssl
require_command curl
require_command jq
load_app_config
[[ -n "${CONFIG_INSTALLATION_ID:-}" ]] ||
die "Installation ID is missing. Re-run: mise run ai:sbx -- setup"
# --force is mandatory: without it a second write prompts for confirmation,
# reads the prompt from the already-consumed stdin, cancels, and still
# exits 0 — leaving the previous, expired token in place.
mint_github_token |
sbx secret set --force "$SANDBOX_NAME" github >/dev/null
printf 'Installed a fresh GitHub App token for %s (expires in 1 hour).\n' \
"$REPOSITORY"
}
# Terraform and provider blocks reference the account profile name, while the # Terraform and provider blocks reference the account profile name, while the
# host distinguishes the read-only grant with a -readonly suffix. The suffix is # host distinguishes the read-only grant with a -readonly suffix. The suffix is
# a host-side naming convention, so it is stripped on the way into the sandbox. # a host-side naming convention, so it is stripped on the way into the sandbox.
@@ -136,6 +283,7 @@ load_config() {
die "Agent is missing from $REPO_CONFIG_FILE" die "Agent is missing from $REPO_CONFIG_FILE"
CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}" CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}"
CONFIG_INSTALLATION_ID="${CONFIG_INSTALLATION_ID:-}"
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 || declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
CONFIG_AWS_PROFILES=() CONFIG_AWS_PROFILES=()
@@ -145,7 +293,8 @@ save_config() {
local agent="$1" local agent="$1"
local mode="$2" local mode="$2"
local branch="$3" local branch="$3"
shift 3 local installation_id="$4"
shift 4
local -a profiles=("$@") local -a profiles=("$@")
mkdir -p "$REPO_CONFIG_DIR" mkdir -p "$REPO_CONFIG_DIR"
@@ -157,6 +306,7 @@ save_config() {
printf 'CONFIG_AGENT=%q\n' "$agent" printf 'CONFIG_AGENT=%q\n' "$agent"
printf 'CONFIG_MODE=%q\n' "$mode" printf 'CONFIG_MODE=%q\n' "$mode"
printf 'CONFIG_BRANCH=%q\n' "$branch" printf 'CONFIG_BRANCH=%q\n' "$branch"
printf 'CONFIG_INSTALLATION_ID=%q\n' "$installation_id"
printf 'CONFIG_AWS_PROFILES=(' printf 'CONFIG_AWS_PROFILES=('
local profile local profile
@@ -427,7 +577,17 @@ setup_command() {
validate_aws_profile "$profile" validate_aws_profile "$profile"
done done
save_config "$agent" "$mode" "$branch" "${aws_profiles[@]}" local installation_id=""
if github_app_configured; then
require_command openssl
require_command curl
require_command jq
load_app_config
installation_id="$(resolve_installation_id)"
printf 'GitHub App installation for %s: %s\n' "$REPOSITORY" "$installation_id"
fi
save_config "$agent" "$mode" "$branch" "$installation_id" "${aws_profiles[@]}"
if sandbox_exists; then if sandbox_exists; then
if [[ "$replace" == true ]]; then if [[ "$replace" == true ]]; then
@@ -444,27 +604,40 @@ setup_command() {
create_sandbox create_sandbox
fi fi
if github_app_configured; then
install_github_token
else
cat <<EOF cat <<EOF
Configure a fine-grained GitHub token for this sandbox. No GitHub App is configured, so this sandbox needs a fine-grained token.
The token should be restricted to: Configure the App once instead, and every repository afterwards is automatic:
mise run ai:sbx -- app --app-id ID --key PATH
Otherwise, create a token restricted to:
Repository: $REPOSITORY Repository: $REPOSITORY
Sandbox: $SANDBOX_NAME Sandbox: $SANDBOX_NAME
Suggested permissions: Permissions:
Metadata: Read Metadata: Read
Contents: Read and write Contents: Read and write
Pull requests: Read and write Pull requests: Read and write
Actions: Read, if required Issues: Read and write
Issues: Only if required Workflows: Read and write
Workflows: No access unless explicitly required Actions: Read and write
Checks: Read
Commit statuses: Read
Code scanning alerts: Read and write
Secret scanning alerts: Read
Dependabot alerts: Read
EOF EOF
# Interactive prompt; the token is not placed in shell history. # Interactive prompt; the token is not placed in shell history.
sbx secret set "$SANDBOX_NAME" github sbx secret set --force "$SANDBOX_NAME" github
fi
install_sandbox_aws_files install_sandbox_aws_files
@@ -484,10 +657,66 @@ Run it with:
EOF EOF
} }
app_command() {
local app_id="" key=""
while (($#)); do
case "$1" in
--app-id)
(($# >= 2)) || die "--app-id requires a value"
app_id="$2"
shift 2
;;
--key)
(($# >= 2)) || die "--key requires a value"
key="$2"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
die "Unknown app option: $1"
;;
esac
done
[[ "$app_id" =~ ^[0-9]+$ ]] ||
die "--app-id must be the numeric App ID, not the client ID"
[[ -r "$key" ]] ||
die "Private key is not readable: ${key:-unset}"
key="$(cd "$(dirname "$key")" && printf '%s/%s' "$PWD" "$(basename "$key")")"
openssl rsa -in "$key" -noout 2>/dev/null ||
die "Not a usable RSA private key: $key"
mkdir -p "$CONFIG_ROOT"
chmod 700 "$CONFIG_ROOT"
{
printf 'APP_ID=%q\n' "$app_id"
printf 'APP_PRIVATE_KEY_FILE=%q\n' "$key"
} >"$APP_CONFIG_FILE"
chmod 600 "$APP_CONFIG_FILE"
printf 'Recorded GitHub App %s in %s\n' "$app_id" "$APP_CONFIG_FILE"
printf 'Install it on each repository, then run setup there.\n'
}
refresh_command() { refresh_command() {
load_config
sandbox_exists || sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup" die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
if github_app_configured; then
install_github_token
fi
install_sandbox_aws_files install_sandbox_aws_files
} }
@@ -497,7 +726,11 @@ run_command() {
sandbox_exists || sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup" die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
# Refresh the short-lived credentials before every session. # Both credentials are short-lived, so re-mint before every session.
if github_app_configured; then
install_github_token
fi
install_sandbox_aws_files install_sandbox_aws_files
if (($#)) && [[ "$1" == "--" ]]; then if (($#)) && [[ "$1" == "--" ]]; then
@@ -524,6 +757,15 @@ status_command() {
printf 'Branch: %s\n' "$CONFIG_BRANCH" printf 'Branch: %s\n' "$CONFIG_BRANCH"
fi fi
if github_app_configured; then
# shellcheck disable=SC1090
source "$APP_CONFIG_FILE"
printf 'GitHub: App %s, installation %s\n' \
"${APP_ID:-unset}" "${CONFIG_INSTALLATION_ID:-unresolved}"
else
printf 'GitHub: manual fine-grained token\n'
fi
printf 'AWS profiles (host -> sandbox):\n' printf 'AWS profiles (host -> sandbox):\n'
if ((${#CONFIG_AWS_PROFILES[@]})); then if ((${#CONFIG_AWS_PROFILES[@]})); then
local profile local profile
@@ -563,12 +805,16 @@ main() {
shift shift
fi fi
# Usage must work outside a repository and without the sandbox toolchain. # These work outside a repository and without the sandbox toolchain.
case "$command" in case "$command" in
-h | --help | help | "") -h | --help | help | "")
usage usage
return return
;; ;;
app)
app_command "$@"
return
;;
esac esac
require_command git require_command git
+83
View File
@@ -0,0 +1,83 @@
#!/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'