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:
@@ -16,13 +16,11 @@ 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, 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
|
||||
- **Scopes GitHub access to one repository.** Setup opens the GitHub token form in
|
||||
your browser with the owner, expiry, name, and permissions already filled in — you
|
||||
pick the repository and paste the token back. It 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.
|
||||
into the repository, and is not readable by the agent.
|
||||
- **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
|
||||
@@ -32,8 +30,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 both credentials on every launch,** since installation tokens expire
|
||||
hourly and exported SSO credentials are short-lived.
|
||||
- **Refreshes AWS credentials on every launch,** since 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
|
||||
@@ -58,9 +56,9 @@ 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` |
|
||||
| [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), [`jq`](https://jqlang.org/) | `aws configure export-credentials` | Required only when using `--aws-profile` |
|
||||
| `git`, `sha256sum` | Repository identity | Already present on most systems |
|
||||
| `xdg-open` / `open` / `$BROWSER` | Opens the token form | Optional — the link is printed if absent |
|
||||
|
||||
**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
|
||||
@@ -73,9 +71,8 @@ Verify:
|
||||
```bash
|
||||
mise --version
|
||||
sbx version
|
||||
openssl version
|
||||
jq --version
|
||||
aws --version
|
||||
jq --version
|
||||
```
|
||||
|
||||
### User-level install (recommended)
|
||||
@@ -88,7 +85,7 @@ Add to `~/.config/mise/config.toml`:
|
||||
```toml
|
||||
[task_config]
|
||||
includes = [
|
||||
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
|
||||
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
|
||||
]
|
||||
```
|
||||
|
||||
@@ -102,7 +99,7 @@ registered with the forge:
|
||||
|
||||
```toml
|
||||
includes = [
|
||||
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
|
||||
"git::ssh://[email protected]/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
|
||||
]
|
||||
```
|
||||
|
||||
@@ -132,7 +129,7 @@ A repository whose team has adopted the workflow can add the same include to its
|
||||
```toml
|
||||
[task_config]
|
||||
includes = [
|
||||
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.0.0",
|
||||
"git::https://git.mroberts.dev/mroberts/ai-sandbox.git//tasks?ref=v1.2.0",
|
||||
]
|
||||
```
|
||||
|
||||
@@ -145,103 +142,7 @@ one.
|
||||
|
||||
## Usage
|
||||
|
||||
### 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
|
||||
### 1. Authenticate your read-only AWS profiles on the host
|
||||
|
||||
```bash
|
||||
aws sso login --profile api-portal-readonly
|
||||
@@ -251,7 +152,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.
|
||||
|
||||
### 3. Set up a repository, once
|
||||
### 2. Set up a repository
|
||||
|
||||
```bash
|
||||
cd ~/src/api-portal
|
||||
@@ -261,27 +162,49 @@ mise run ai:sbx -- setup \
|
||||
--aws-profile prod-readonly
|
||||
```
|
||||
|
||||
Derives the repository from `origin`, resolves the App installation, creates the
|
||||
sandbox, and installs a first token. No prompts.
|
||||
Derives the repository from `origin`, creates the sandbox, then opens the GitHub token
|
||||
form in your browser with everything pre-filled:
|
||||
|
||||
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.
|
||||
```text
|
||||
name ai-sbx api-portal
|
||||
target_name CareEvolution
|
||||
expires_in 30
|
||||
metadata=read contents=write pull_requests=write issues=write
|
||||
workflows=write actions=write statuses=read security_events=write
|
||||
```
|
||||
|
||||
### 4. Run the agent
|
||||
Two things the form cannot pre-fill, so the task prints them as a checklist:
|
||||
|
||||
1. **Repository access → Only select repositories → `api-portal`.** GitHub has no
|
||||
query parameter for repository selection.
|
||||
2. **Checks: Read, Dependabot alerts: Read, Secret scanning alerts: Read.** These
|
||||
three are absent from GitHub's pre-fill parameter list. Skip them if the agent
|
||||
does not need to read CI status or triage security alerts.
|
||||
|
||||
Generate the token and paste it at the prompt. It is read with the terminal echo off
|
||||
and piped straight into the `sbx` secret store, so it never reaches your shell history.
|
||||
|
||||
There is no API to create a fine-grained token — GitHub only supports pre-filling the
|
||||
form — so this step is inherently a browser round trip. Rotating later is the same
|
||||
round trip:
|
||||
|
||||
```bash
|
||||
mise run ai:sbx -- token
|
||||
```
|
||||
|
||||
### 3. Run the agent
|
||||
|
||||
```bash
|
||||
mise run ai:sbx -- run
|
||||
```
|
||||
|
||||
Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
|
||||
agent arguments after a second `--`:
|
||||
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"
|
||||
```
|
||||
|
||||
### 5. Inside the sandbox
|
||||
### 4. Inside the sandbox
|
||||
|
||||
`gh` is already authenticated through the proxy, for that repository only:
|
||||
|
||||
@@ -310,11 +233,11 @@ provider "aws" {
|
||||
|
||||
| Command | Effect |
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `setup [options]` | Configure the repository, create the sandbox, open the token form, install AWS profiles |
|
||||
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
|
||||
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
|
||||
| `refresh` | Same, without attaching |
|
||||
| `status` | Show repository, sandbox, agent, mode, App installation, profile mapping, stored secrets |
|
||||
| `status` | Show repository, sandbox, agent, mode, token expiry setting, profile mapping, stored secrets |
|
||||
| `remove` | Remove the sandbox and this repository's local configuration |
|
||||
|
||||
### `setup` options
|
||||
@@ -335,6 +258,7 @@ provider "aws" {
|
||||
| `AI_SBX_AGENT` | `codex` | `--agent` |
|
||||
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
|
||||
| `AI_SBX_BRANCH` | `ai-sbx` | `--branch` |
|
||||
| `AI_SBX_TOKEN_DAYS` | `30` | token expiry pre-filled on the form (1–366, or `none`) |
|
||||
|
||||
## AWS profile naming
|
||||
|
||||
@@ -358,14 +282,12 @@ 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
|
||||
```
|
||||
|
||||
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.
|
||||
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
|
||||
profile names — no secrets. The GitHub token lives 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`.
|
||||
|
||||
@@ -375,13 +297,16 @@ 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.
|
||||
- **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.
|
||||
- **One token per repository, per developer, with an expiry.** Nothing is shared: no
|
||||
private key, no client secret, no broker. Each developer's token is capped by their
|
||||
own access, and organization owners can require approval and enforce a maximum
|
||||
lifetime.
|
||||
- **The token acts as you.** Its commits and comments carry your identity, so treat
|
||||
the agent's output as your own work. Revoke at
|
||||
[Fine-grained tokens](https://github.com/settings/tokens?type=beta) and re-run
|
||||
`token`.
|
||||
- **Rotation is manual.** The token expires on the schedule you picked; `token`
|
||||
replaces it. There is no automatic renewal, because there is no API to create one.
|
||||
- **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.
|
||||
@@ -394,13 +319,15 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
||||
|
||||
```bash
|
||||
bash tests/profile-mapping.test.sh
|
||||
bash tests/github-app-jwt.test.sh
|
||||
bash tests/token-url.test.sh
|
||||
bash tests/invocation-directory.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
|
||||
The token test checks URL encoding, that `target_name` carries the owner rather than
|
||||
the full repository name, that every permission survives into the query at a valid
|
||||
level, and that no parameter GitHub silently ignores is sent — an ignored parameter
|
||||
reads as "granted" when reviewing the link. No test touches the network, GitHub, or
|
||||
`sbx`.
|
||||
|
||||
Task names come from directory nesting, not from colons in filenames: `tasks/ai/sbx`
|
||||
|
||||
+125
-232
@@ -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
|
||||
|
||||
# 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
|
||||
for opener in "${BROWSER:-}" xdg-open open; do
|
||||
[[ -n "$opener" ]] || continue
|
||||
|
||||
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
|
||||
if command -v "$opener" >/dev/null 2>&1; then
|
||||
"$opener" "$url" >/dev/null 2>&1 &
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
local message
|
||||
message="$(jq -r '.message // "unrecognized response"' <<<"$response" 2>/dev/null)" ||
|
||||
message="unparseable response"
|
||||
|
||||
die "$context: $message"
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_installation_id() {
|
||||
local jwt response
|
||||
read_token() {
|
||||
local token
|
||||
|
||||
jwt="$(github_app_jwt)"
|
||||
response="$(github_api GET "/repos/$REPOSITORY/installation" "$jwt")"
|
||||
# -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
|
||||
|
||||
github_api_field "$response" '.id' \
|
||||
"GitHub App is not installed on $REPOSITORY"
|
||||
}
|
||||
[[ -n "$token" ]] ||
|
||||
die "No token entered."
|
||||
|
||||
mint_github_token() {
|
||||
local jwt response body
|
||||
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
|
||||
|
||||
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_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 "$@"
|
||||
;;
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/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'
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/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
|
||||
|
||||
fail() {
|
||||
printf 'FAIL: %s\n' "$1" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
assert_encodes() {
|
||||
local input="$1" expected="$2" actual
|
||||
actual="$(url_encode "$input")"
|
||||
|
||||
[[ "$actual" == "$expected" ]] ||
|
||||
fail "url_encode '$input' produced '$actual', expected '$expected'"
|
||||
}
|
||||
|
||||
assert_encodes "plain" "plain"
|
||||
assert_encodes "a b" "a%20b"
|
||||
assert_encodes "CareEvolution/api-portal" "CareEvolution%2Fapi-portal"
|
||||
assert_encodes "a&b=c" "a%26b%3Dc"
|
||||
assert_encodes "a?b#c" "a%3Fb%23c"
|
||||
assert_encodes "keep.these~chars_-" "keep.these~chars_-"
|
||||
|
||||
REPOSITORY="CareEvolution/api-portal"
|
||||
url="$(token_url)"
|
||||
|
||||
[[ "$url" == https://github.com/settings/personal-access-tokens/new\?* ]] ||
|
||||
fail "URL does not target the token creation form: $url"
|
||||
|
||||
query="${url#*\?}"
|
||||
[[ "$query" != *" "* ]] ||
|
||||
fail "URL contains a raw space"
|
||||
|
||||
[[ "$query" == *"target_name=CareEvolution"* ]] ||
|
||||
fail "target_name is not the repository owner"
|
||||
|
||||
[[ "$query" != *"target_name=CareEvolution%2Fapi-portal"* ]] ||
|
||||
fail "target_name wrongly carries the full repository name"
|
||||
|
||||
[[ "$query" == *"expires_in=$DEFAULT_TOKEN_DAYS"* ]] ||
|
||||
fail "expires_in is missing"
|
||||
|
||||
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
|
||||
[[ "$query" == *"&$permission"* ]] ||
|
||||
fail "permission missing from URL: $permission"
|
||||
done
|
||||
|
||||
while read -r level; do
|
||||
[[ "$level" == "read" || "$level" == "write" || "$level" == "admin" ]] ||
|
||||
fail "invalid permission level: $level"
|
||||
done < <(printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | cut -d= -f2)
|
||||
|
||||
printf '%s\n' "${TOKEN_URL_PERMISSIONS[@]}" | grep -qx 'workflows=write' ||
|
||||
fail "workflows must be requested at write"
|
||||
|
||||
for unsupported in checks= vulnerability_alerts= secret_scanning_alerts= repository=; do
|
||||
[[ "$query" != *"$unsupported"* ]] ||
|
||||
fail "URL sends a parameter the form ignores: $unsupported"
|
||||
done
|
||||
|
||||
[[ ${#TOKEN_MANUAL_PERMISSIONS[@]} -eq 3 ]] ||
|
||||
fail "expected 3 manually-ticked permissions, found ${#TOKEN_MANUAL_PERMISSIONS[@]}"
|
||||
|
||||
if ((failures)); then
|
||||
printf '%d assertion(s) failed\n' "$failures" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'All token URL assertions passed.\n'
|
||||
Reference in New Issue
Block a user