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,
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
View File
@@ -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
+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'