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
+130 -237
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
for opener in "${BROWSER:-}" xdg-open open; do
[[ -n "$opener" ]] || continue
if command -v "$opener" >/dev/null 2>&1; then
"$opener" "$url" >/dev/null 2>&1 &
return 0
fi
done
return 1
}
# 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
read_token() {
local token
now="$(date +%s)"
header='{"alg":"RS256","typ":"JWT"}'
payload="$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' \
"$((now - 60))" "$((now + 540))" "$APP_ID")"
# -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
signing_input="$(printf '%s' "$header" | base64url).$(printf '%s' "$payload" | base64url)"
[[ -n "$token" ]] ||
die "No token entered."
signature="$(
printf '%s' "$signing_input" |
openssl dgst -sha256 -sign "$APP_PRIVATE_KEY_FILE" -binary |
base64url
)"
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
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"
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_github_token
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 "$@"
;;