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:
@@ -16,10 +16,13 @@ It provides a single command, `ai:sbx`, which:
|
||||
- **Derives the repository from `origin`.** No repository name is typed or configured,
|
||||
so the sandbox identity cannot drift from the checkout you are standing in. The
|
||||
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
|
||||
- **Scopes GitHub access to one repository.** A fine-grained PAT restricted to that
|
||||
repository is stored with `sbx secret set`. Docker's host-side proxy injects it into
|
||||
outbound requests; the token is never placed in `GH_TOKEN`, never written into the
|
||||
repository, and is not readable by the agent.
|
||||
- **Scopes GitHub access to one repository, 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 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`
|
||||
directory and your SSO token cache are never mounted or copied. Instead, the host
|
||||
runs `aws configure export-credentials` against named read-only profiles you approve
|
||||
@@ -29,8 +32,8 @@ It provides a single command, `ai:sbx`, which:
|
||||
grant — `api-portal-readonly` — while Terraform code references the account name,
|
||||
`api-portal`. A trailing `-readonly` is stripped when the profile is written into the
|
||||
sandbox, so unmodified Terraform resolves the read-only credentials.
|
||||
- **Refreshes credentials on every launch,** since exported SSO credentials are
|
||||
short-lived.
|
||||
- **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 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) |
|
||||
| [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` |
|
||||
| [`jq`](https://jqlang.org/) | Parses exported 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:
|
||||
|
||||
```bash
|
||||
mise --version
|
||||
sbx version
|
||||
aws --version
|
||||
openssl version
|
||||
jq --version
|
||||
aws --version
|
||||
```
|
||||
|
||||
### User-level install (recommended)
|
||||
@@ -135,7 +145,103 @@ one.
|
||||
|
||||
## 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
|
||||
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
|
||||
session has expired.
|
||||
|
||||
### 2. Set up a repository, once
|
||||
### 3. Set up a repository, once
|
||||
|
||||
```bash
|
||||
cd ~/src/api-portal
|
||||
@@ -155,40 +261,27 @@ mise run ai:sbx -- setup \
|
||||
--aws-profile prod-readonly
|
||||
```
|
||||
|
||||
This derives the repository from `origin`, creates the sandbox, then prompts for a
|
||||
fine-grained GitHub PAT. Create it at
|
||||
[github.com/settings/personal-access-tokens](https://github.com/settings/personal-access-tokens)
|
||||
scoped to that one repository:
|
||||
Derives the repository from `origin`, resolves the App installation, creates the
|
||||
sandbox, and installs a first token. No prompts.
|
||||
|
||||
```text
|
||||
Resource owner: your user or organization
|
||||
Repository access: Only select repositories
|
||||
Selected repository: owner/api-portal
|
||||
Permissions:
|
||||
Metadata: Read
|
||||
Contents: Read and write
|
||||
Pull requests: Read and write
|
||||
Actions: Read, if required
|
||||
Issues: Only if required
|
||||
Workflows: No access unless explicitly required
|
||||
Expiration: the shortest period you will tolerate
|
||||
```
|
||||
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.
|
||||
|
||||
Paste it at the prompt. It is not written to shell history.
|
||||
|
||||
### 3. Run the agent
|
||||
### 4. Run the agent
|
||||
|
||||
```bash
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -217,10 +310,11 @@ provider "aws" {
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `setup [options]` | Configure the repository, create the sandbox, store the GitHub token, install AWS profiles |
|
||||
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
|
||||
| `refresh` | Refresh AWS credentials without attaching |
|
||||
| `status` | Show repository, sandbox, agent, mode, profile mapping, stored secrets |
|
||||
| `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
|
||||
@@ -264,12 +358,14 @@ Variants such as `_readonly`, `-ro`, and `-read-only` are **not** stripped.
|
||||
## Where state lives
|
||||
|
||||
```text
|
||||
~/.config/ai-sbx/github-app mode 600, App ID + key path
|
||||
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
|
||||
```
|
||||
|
||||
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
|
||||
profile names. Tokens live in the `sbx` secret store; AWS credentials exist only inside
|
||||
the sandbox and only until they expire.
|
||||
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`.
|
||||
|
||||
@@ -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
|
||||
lives in your user-owned config; the repository only supplies its own identity, which
|
||||
is cross-checked against `origin` on every run.
|
||||
- **Fine-grained PATs, one per repository, with an expiration.** A classic PAT reaches
|
||||
every repository you can reach; that is the thing this design exists to prevent.
|
||||
- **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 `plan` needs read access;
|
||||
`apply` should stay outside the sandbox.
|
||||
- **Rotation is manual.** Revoke a PAT at GitHub and re-run `setup` to replace it.
|
||||
- **`Contents: write` includes force-push and branch deletion.** There is no finer
|
||||
split. Branch protection or rulesets are the actual guard, not token scoping.
|
||||
- **`--direct` weakens isolation.** The agent writes directly to your working tree.
|
||||
Prefer the default `--clone`.
|
||||
|
||||
@@ -292,9 +394,15 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
||||
|
||||
```bash
|
||||
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`
|
||||
registers as `ai:sbx`, whereas a file literally named `tasks/ai:sbx` registers as
|
||||
`ai_sbx`. Task files must be executable.
|
||||
|
||||
+257
-11
@@ -6,16 +6,39 @@ CONFIG_ROOT="${XDG_CONFIG_HOME:-$HOME/.config}/ai-sbx"
|
||||
DEFAULT_AGENT="${AI_SBX_AGENT:-codex}"
|
||||
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
|
||||
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() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
mise run ai:sbx -- app --app-id ID --key PATH
|
||||
mise run ai:sbx -- setup [options]
|
||||
mise run ai:sbx -- refresh
|
||||
mise run ai:sbx -- run [-- agent arguments...]
|
||||
mise run ai:sbx -- status
|
||||
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:
|
||||
--aws-profile NAME Host AWS profile to expose inside the sandbox.
|
||||
May be supplied more than once. A trailing
|
||||
@@ -50,6 +73,130 @@ require_command() {
|
||||
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
|
||||
# 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.
|
||||
@@ -136,6 +283,7 @@ load_config() {
|
||||
die "Agent is missing from $REPO_CONFIG_FILE"
|
||||
|
||||
CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}"
|
||||
CONFIG_INSTALLATION_ID="${CONFIG_INSTALLATION_ID:-}"
|
||||
|
||||
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
|
||||
CONFIG_AWS_PROFILES=()
|
||||
@@ -145,7 +293,8 @@ save_config() {
|
||||
local agent="$1"
|
||||
local mode="$2"
|
||||
local branch="$3"
|
||||
shift 3
|
||||
local installation_id="$4"
|
||||
shift 4
|
||||
local -a profiles=("$@")
|
||||
|
||||
mkdir -p "$REPO_CONFIG_DIR"
|
||||
@@ -157,6 +306,7 @@ save_config() {
|
||||
printf 'CONFIG_AGENT=%q\n' "$agent"
|
||||
printf 'CONFIG_MODE=%q\n' "$mode"
|
||||
printf 'CONFIG_BRANCH=%q\n' "$branch"
|
||||
printf 'CONFIG_INSTALLATION_ID=%q\n' "$installation_id"
|
||||
|
||||
printf 'CONFIG_AWS_PROFILES=('
|
||||
local profile
|
||||
@@ -427,7 +577,17 @@ setup_command() {
|
||||
validate_aws_profile "$profile"
|
||||
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 [[ "$replace" == true ]]; then
|
||||
@@ -444,27 +604,40 @@ setup_command() {
|
||||
create_sandbox
|
||||
fi
|
||||
|
||||
if github_app_configured; then
|
||||
install_github_token
|
||||
else
|
||||
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
|
||||
Sandbox: $SANDBOX_NAME
|
||||
|
||||
Suggested permissions:
|
||||
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
|
||||
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
|
||||
|
||||
EOF
|
||||
|
||||
# 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
|
||||
|
||||
@@ -484,10 +657,66 @@ Run it with:
|
||||
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() {
|
||||
load_config
|
||||
|
||||
sandbox_exists ||
|
||||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
||||
|
||||
if github_app_configured; then
|
||||
install_github_token
|
||||
fi
|
||||
|
||||
install_sandbox_aws_files
|
||||
}
|
||||
|
||||
@@ -497,7 +726,11 @@ run_command() {
|
||||
sandbox_exists ||
|
||||
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
|
||||
|
||||
if (($#)) && [[ "$1" == "--" ]]; then
|
||||
@@ -524,6 +757,15 @@ status_command() {
|
||||
printf 'Branch: %s\n' "$CONFIG_BRANCH"
|
||||
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'
|
||||
if ((${#CONFIG_AWS_PROFILES[@]})); then
|
||||
local profile
|
||||
@@ -563,12 +805,16 @@ main() {
|
||||
shift
|
||||
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
|
||||
-h | --help | help | "")
|
||||
usage
|
||||
return
|
||||
;;
|
||||
app)
|
||||
app_command "$@"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
require_command git
|
||||
|
||||
Executable
+83
@@ -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'
|
||||
Reference in New Issue
Block a user