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
+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'