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.
This commit is contained in:
2026-07-30 15:28:44 -05:00
parent d96f32d773
commit 890d10a315
4 changed files with 274 additions and 462 deletions
+69 -142
View File
@@ -16,13 +16,11 @@ 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, 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
- **Scopes GitHub access to one repository.** Setup opens the GitHub token form in
your browser with the owner, expiry, name, and permissions already filled in — you
pick the repository and paste the token back. It 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.
into the repository, and is not readable by the agent.
- **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
@@ -32,8 +30,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 both credentials on every launch,** since installation tokens expire
hourly and exported SSO credentials are short-lived.
- **Refreshes AWS credentials on every launch,** since 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
@@ -58,9 +56,9 @@ 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` |
| [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), [`jq`](https://jqlang.org/) | `aws configure export-credentials` | Required only when using `--aws-profile` |
| `git`, `sha256sum` | Repository identity | Already present on most systems |
| `xdg-open` / `open` / `$BROWSER` | Opens the token form | Optional — the link is printed if absent |
**mise must be recent enough to load remote `git::` task includes.** Verified working
on 2026.7.17; verified broken on 2025.10.6, which drops `git::` entries silently — no
@@ -73,9 +71,8 @@ Verify:
```bash
mise --version
sbx version
openssl version
jq --version
aws --version
jq --version
```
### User-level install (recommended)
@@ -88,7 +85,7 @@ Add to `~/.config/mise/config.toml`:
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -102,7 +99,7 @@ registered with the forge:
```toml
includes = [
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -132,7 +129,7 @@ A repository whose team has adopted the workflow can add the same include to its
```toml
[task_config]
includes = [
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
]
```
@@ -145,103 +142,7 @@ 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:
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
### 1. Authenticate your read-only AWS profiles on the host
```bash
aws sso login --profile api-portal-readonly
@@ -251,7 +152,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.
### 3. Set up a repository, once
### 2. Set up a repository
```bash
cd ~/src/api-portal
@@ -261,27 +162,49 @@ mise run ai:sbx -- setup \
--aws-profile prod-readonly
```
Derives the repository from `origin`, resolves the App installation, creates the
sandbox, and installs a first token. No prompts.
Derives the repository from `origin`, creates the sandbox, then opens the GitHub token
form in your browser with everything pre-filled:
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.
```text
name ai-sbx api-portal
target_name CareEvolution
expires_in 30
metadata=read contents=write pull_requests=write issues=write
workflows=write actions=write statuses=read security_events=write
```
### 4. Run the agent
Two things the form cannot pre-fill, so the task prints them as a checklist:
1. **Repository access → Only select repositories → `api-portal`.** GitHub has no
query parameter for repository selection.
2. **Checks: Read, Dependabot alerts: Read, Secret scanning alerts: Read.** These
three are absent from GitHub's pre-fill parameter list. Skip them if the agent
does not need to read CI status or triage security alerts.
Generate the token and paste it at the prompt. It is read with the terminal echo off
and piped straight into the `sbx` secret store, so it never reaches your shell history.
There is no API to create a fine-grained token — GitHub only supports pre-filling the
form — so this step is inherently a browser round trip. Rotating later is the same
round trip:
```bash
mise run ai:sbx -- token
```
### 3. Run the agent
```bash
mise run ai:sbx -- run
```
Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
agent arguments after a second `--`:
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"
```
### 5. Inside the sandbox
### 4. Inside the sandbox
`gh` is already authenticated through the proxy, for that repository only:
@@ -310,11 +233,11 @@ provider "aws" {
| 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 |
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles |
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
| `refresh` | Same, without attaching |
| `status` | Show repository, sandbox, agent, mode, App installation, profile mapping, stored secrets |
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
| `remove` | Remove the sandbox and this repository's local configuration |
### `setup` options
@@ -335,6 +258,7 @@ provider "aws" {
| `AI_SBX_AGENT` | `codex` | `--agent` |
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
| `AI_SBX_BRANCH` | `ai-sbx` | `--branch` |
| `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) |
## AWS profile naming
@@ -358,14 +282,12 @@ 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
```
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.
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
profile names — no secrets. The GitHub token lives 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`.
@@ -375,13 +297,16 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
or `mise.toml` could otherwise choose which credentials get loaded. Profile approval
lives in your user-owned config; the repository only supplies its own identity, which
is cross-checked against `origin` on 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.
- **One token per repository, per developer, with an expiry.** Nothing is shared: no
private key, no client secret, no broker. Each developer's token is capped by their
own access, and organization owners can require approval and enforce a maximum
lifetime.
- **The token acts as you.** Its commits and comments carry your identity, so treat
the agent's output as your own work. Revoke at
[Fine-grained tokens](https://github.com/settings/tokens?type=beta) and re-run
`token`.
- **Rotation is manual.** The token expires on the schedule you picked; `token`
replaces it. There is no automatic renewal, because there is no API to create one.
- **Read-only AWS roles.** The sandbox boundary limits reach, not intent. Grant roles
that cannot cause damage if the agent misbehaves. Terraform `plan` needs read access;
`apply` should stay outside the sandbox.
@@ -394,13 +319,15 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
```bash
bash tests/profile-mapping.test.sh
bash tests/github-app-jwt.test.sh
bash tests/token-url.test.sh
bash tests/invocation-directory.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
The token test checks URL encoding, that `target_name` carries the owner rather than
the full repository name, that every permission survives into the query at a valid
level, and that no parameter GitHub silently ignores is sent — an ignored parameter
reads as "granted" when reviewing the link. No test touches the network, GitHub, or
`sbx`.
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
+125 -232
View File
@@ -6,38 +6,43 @@ 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"
DEFAULT_TOKEN_DAYS="${AI_SBX_TOKEN_DAYS:-30}"
# 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"
}'
# GitHub accepts these as query parameters on the token creation form. A write
# level implies read, so only the highest level is listed. "workflows" is
# required to push any commit touching .github/workflows and is separate from
# "actions".
TOKEN_URL_PERMISSIONS=(
metadata=read
contents=write
pull_requests=write
issues=write
workflows=write
actions=write
statuses=read
security_events=write
)
# GitHub omits these from the pre-fill parameters, so they can only be ticked
# on the form itself.
TOKEN_MANUAL_PERMISSIONS=(
"Checks: Read"
"Dependabot alerts: Read"
"Secret scanning 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 -- token
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 opens a pre-filled GitHub token form in your browser. Use "token" on
its own to replace an expired or revoked token later.
Setup options:
--aws-profile NAME Host AWS profile to expose inside the sandbox.
@@ -73,130 +78,120 @@ require_command() {
die "Required command not found: $1"
}
github_app_configured() {
[[ -f "$APP_CONFIG_FILE" ]]
url_encode() {
local string="$1" index character encoded=""
for ((index = 0; index < ${#string}; index++)); do
character="${string:index:1}"
case "$character" in
[a-zA-Z0-9.~_-])
encoded+="$character"
;;
*)
printf -v character '%%%02X' "'$character"
encoded+="$character"
;;
esac
done
printf '%s' "$encoded"
}
load_app_config() {
github_app_configured ||
die "No GitHub App configured. Run: mise run ai:sbx -- app --app-id ID --key PATH"
token_url() {
local owner="${REPOSITORY%%/*}"
local name="${REPOSITORY#*/}"
local url="https://github.com/settings/personal-access-tokens/new"
# shellcheck disable=SC1090
source "$APP_CONFIG_FILE"
url+="?name=$(url_encode "ai-sbx $name")"
url+="&description=$(url_encode "AI agent sandbox for $REPOSITORY")"
url+="&target_name=$(url_encode "$owner")"
url+="&expires_in=$(url_encode "$DEFAULT_TOKEN_DAYS")"
[[ -n "${APP_ID:-}" ]] ||
die "APP_ID is missing from $APP_CONFIG_FILE"
local permission
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
url+="&$permission"
done
[[ -r "${APP_PRIVATE_KEY_FILE:-}" ]] ||
die "GitHub App private key is not readable: ${APP_PRIVATE_KEY_FILE:-unset}"
printf '%s' "$url"
}
base64url() {
openssl base64 -A | tr '+/' '-_' | tr -d '='
}
open_browser() {
local url="$1" opener
# 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
for opener in "${BROWSER:-}" xdg-open open; do
[[ -n "$opener" ]] || continue
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
if command -v "$opener" >/dev/null 2>&1; then
"$opener" "$url" >/dev/null 2>&1 &
return 0
fi
done
local message
message="$(jq -r '.message // "unrecognized response"' <<<"$response" 2>/dev/null)" ||
message="unparseable response"
die "$context: $message"
return 1
}
resolve_installation_id() {
local jwt response
read_token() {
local token
jwt="$(github_app_jwt)"
response="$(github_api GET "/repos/$REPOSITORY/installation" "$jwt")"
# -s keeps the token off the terminal; it never reaches shell history
# because it is read into a variable rather than typed as an argument.
IFS= read -rsp 'Paste token: ' token </dev/tty
printf '\n' >&2
github_api_field "$response" '.id' \
"GitHub App is not installed on $REPOSITORY"
}
[[ -n "$token" ]] ||
die "No token entered."
mint_github_token() {
local jwt response body
case "$token" in
github_pat_*) ;;
ghp_*)
die "That is a classic token. Generate a fine-grained token from the link above."
;;
*)
die "That does not look like a fine-grained token (expected a github_pat_ prefix)."
;;
esac
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"
printf '%s' "$token"
}
install_github_token() {
require_command openssl
require_command curl
require_command jq
local url
url="$(token_url)"
load_app_config
cat >&2 <<EOF
[[ -n "${CONFIG_INSTALLATION_ID:-}" ]] ||
die "Installation ID is missing. Re-run: mise run ai:sbx -- setup"
Create a fine-grained token for $REPOSITORY.
Everything except the repository is pre-filled. On the page:
1. Repository access -> Only select repositories -> ${REPOSITORY#*/}
2. Tick the permissions the form cannot pre-fill:
EOF
local permission
for permission in "${TOKEN_MANUAL_PERMISSIONS[@]}"; do
printf ' %s\n' "$permission" >&2
done
cat >&2 <<EOF
3. Generate token, then paste it below.
EOF
if open_browser "$url"; then
printf 'Opened your browser.\n\n' >&2
else
printf 'Open this link:\n\n%s\n\n' "$url" >&2
fi
# --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 |
# exits 0 - leaving the previous, expired token in place.
read_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"
printf 'Stored the token for %s in sandbox %s.\n' "$REPOSITORY" "$SANDBOX_NAME" >&2
}
# 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.
@@ -295,7 +290,6 @@ 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=()
@@ -305,8 +299,7 @@ save_config() {
local agent="$1"
local mode="$2"
local branch="$3"
local installation_id="$4"
shift 4
shift 3
local -a profiles=("$@")
mkdir -p "$REPO_CONFIG_DIR"
@@ -318,7 +311,6 @@ 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
@@ -589,17 +581,7 @@ setup_command() {
validate_aws_profile "$profile"
done
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[@]}"
save_config "$agent" "$mode" "$branch" "${aws_profiles[@]}"
if sandbox_exists; then
if [[ "$replace" == true ]]; then
@@ -616,40 +598,7 @@ setup_command() {
create_sandbox
fi
if github_app_configured; then
install_github_token
else
cat <<EOF
No GitHub App is configured, so this sandbox needs a fine-grained token.
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
Permissions:
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
EOF
# Interactive prompt; the token is not placed in shell history.
sbx secret set --force "$SANDBOX_NAME" github
fi
install_sandbox_aws_files
@@ -669,54 +618,13 @@ Run it with:
EOF
}
app_command() {
local app_id="" key=""
token_command() {
load_config
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
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
[[ "$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'
install_github_token
}
refresh_command() {
@@ -725,10 +633,6 @@ refresh_command() {
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
}
@@ -738,11 +642,8 @@ run_command() {
sandbox_exists ||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
# Both credentials are short-lived, so re-mint before every session.
if github_app_configured; then
install_github_token
fi
# The GitHub token is long-lived and stays in the sbx secret store; only
# the AWS credentials expire between sessions.
install_sandbox_aws_files
if (($#)) && [[ "$1" == "--" ]]; then
@@ -769,14 +670,7 @@ 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 'Token days: %s\n' "$DEFAULT_TOKEN_DAYS"
printf 'AWS profiles (host -> sandbox):\n'
if ((${#CONFIG_AWS_PROFILES[@]})); then
@@ -823,10 +717,6 @@ main() {
usage
return
;;
app)
app_command "$@"
return
;;
esac
require_command git
@@ -839,6 +729,9 @@ main() {
setup)
setup_command "$@"
;;
token)
token_command "$@"
;;
refresh)
refresh_command "$@"
;;
-83
View File
@@ -1,83 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
openssl genrsa -out "$work/key.pem" 2048 2>/dev/null
openssl rsa -in "$work/key.pem" -pubout -out "$work/pub.pem" 2>/dev/null
APP_ID=123456
APP_PRIVATE_KEY_FILE="$work/key.pem"
jwt="$(github_app_jwt)"
IFS='.' read -r header payload signature <<<"$jwt"
[[ -n "$header" && -n "$payload" && -n "$signature" ]] ||
fail "JWT is not three segments: $jwt"
[[ "$jwt" =~ ^[A-Za-z0-9_.-]+$ ]] ||
fail "JWT contains characters outside the base64url alphabet"
decode() {
local padded="$1"
while ((${#padded} % 4)); do
padded+="="
done
printf '%s' "$padded" | tr '_-' '/+' | openssl base64 -d -A
}
[[ "$(decode "$header" | jq -r '.alg')" == "RS256" ]] ||
fail "header alg is not RS256"
[[ "$(decode "$payload" | jq -r '.iss')" == "123456" ]] ||
fail "payload iss does not carry the App ID"
iat="$(decode "$payload" | jq -r '.iat')"
exp="$(decode "$payload" | jq -r '.exp')"
now="$(date +%s)"
((iat <= now)) || fail "iat is in the future ($iat > $now)"
((exp - iat <= 600)) || fail "lifetime exceeds GitHub's 10 minute cap"
((exp > now)) || fail "token is already expired on creation"
printf '%s' "$header.$payload" >"$work/signing_input"
decode "$signature" >"$work/sig.bin"
openssl dgst -sha256 -verify "$work/pub.pem" \
-signature "$work/sig.bin" "$work/signing_input" >/dev/null 2>&1 ||
fail "signature does not verify against the public key"
printf '%s' "$header.${payload}x" >"$work/tampered"
if openssl dgst -sha256 -verify "$work/pub.pem" \
-signature "$work/sig.bin" "$work/tampered" >/dev/null 2>&1; then
fail "a tampered signing input still verified"
fi
jq -e . >/dev/null <<<"$GITHUB_APP_PERMISSIONS" ||
fail "GITHUB_APP_PERMISSIONS is not valid JSON"
[[ "$(jq -r '.workflows' <<<"$GITHUB_APP_PERMISSIONS")" == "write" ]] ||
fail "workflows must be write; the schema defines no read level"
while read -r level; do
[[ "$level" == "read" || "$level" == "write" ]] ||
fail "invalid permission level: $level"
done < <(jq -r '.[]' <<<"$GITHUB_APP_PERMISSIONS")
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All GitHub App JWT assertions passed.\n'
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=tasks/ai/sbx
source "$(dirname "${BASH_SOURCE[0]}")/../tasks/ai/sbx"
failures=0
fail() {
printf 'FAIL: %s\n' "$1" >&2
failures=$((failures + 1))
}
assert_encodes() {
local input="$1" expected="$2" actual
actual="$(url_encode "$input")"
[[ "$actual" == "$expected" ]] ||
fail "url_encode '$input' produced '$actual', expected '$expected'"
}
assert_encodes "plain" "plain"
assert_encodes "a b" "a%20b"
assert_encodes "CareEvolution/api-portal" "CareEvolution%2Fapi-portal"
assert_encodes "a&b=c" "a%26b%3Dc"
assert_encodes "a?b#c" "a%3Fb%23c"
assert_encodes "keep.these~chars_-" "keep.these~chars_-"
REPOSITORY="CareEvolution/api-portal"
url="$(token_url)"
[[ "$url" == https://github.com/settings/personal-access-tokens/new\?* ]] ||
fail "URL does not target the token creation form: $url"
query="${url#*\?}"
[[ "$query" != *" "* ]] ||
fail "URL contains a raw space"
[[ "$query" == *"target_name=CareEvolution"* ]] ||
fail "target_name is not the repository owner"
[[ "$query" != *"target_name=CareEvolution%2Fapi-portal"* ]] ||
fail "target_name wrongly carries the full repository name"
[[ "$query" == *"expires_in=$DEFAULT_TOKEN_DAYS"* ]] ||
fail "expires_in is missing"
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
[[ "$query" == *"&$permission"* ]] ||
fail "permission missing from URL: $permission"
done
while read -r level; do
[[ "$level" == "read" || "$level" == "write" || "$level" == "admin" ]] ||
fail "invalid permission level: $level"
done < <(printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | cut -d= -f2)
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
fail "workflows must be requested at write"
for unsupported in checks= vulnerability_alerts= secret_scanning_alerts= repository=; do
[[ "$query" != *"$unsupported"* ]] ||
fail "URL sends a parameter the form ignores: $unsupported"
done
[[ ${#TOKEN_MANUAL_PERMISSIONS[@]} -eq 3 ]] ||
fail "expected 3 manually-ticked permissions, found ${#TOKEN_MANUAL_PERMISSIONS[@]}"
if ((failures)); then
printf '%d assertion(s) failed\n' "$failures" >&2
exit 1
fi
printf 'All token URL assertions passed.\n'