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:
+130
-237
@@ -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 "$@"
|
||||
;;
|
||||
|
||||
Reference in New Issue
Block a user