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
+262 -16
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
cat <<EOF
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:
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
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 "$SANDBOX_NAME" github
# Interactive prompt; the token is not placed in shell history.
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