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,
|
- **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
|
so the sandbox identity cannot drift from the checkout you are standing in. The
|
||||||
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
|
sandbox name is `ai-<owner>-<repo>-<digest>`, stable across runs.
|
||||||
- **Scopes GitHub access to one repository, with no per-repository token work.**
|
- **Scopes GitHub access to one repository.** Setup opens the GitHub token form in
|
||||||
Configure a GitHub App once, and every repository afterwards mints its own
|
your browser with the owner, expiry, name, and permissions already filled in — you
|
||||||
installation token — restricted to that single repository, carrying a fixed
|
pick the repository and paste the token back. It is stored with `sbx secret set` and
|
||||||
permission set, expiring in one hour. The token is stored with `sbx secret set` and
|
|
||||||
injected by Docker's host-side proxy; it is never placed in `GH_TOKEN`, never written
|
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
|
into the repository, and is not readable by the agent.
|
||||||
still works as a fallback.
|
|
||||||
- **Keeps your AWS admin profiles out of the sandbox entirely.** Your `~/.aws`
|
- **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
|
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
|
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,
|
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
|
`api-portal`. A trailing `-readonly` is stripped when the profile is written into the
|
||||||
sandbox, so unmodified Terraform resolves the read-only credentials.
|
sandbox, so unmodified Terraform resolves the read-only credentials.
|
||||||
- **Refreshes both credentials on every launch,** since installation tokens expire
|
- **Refreshes AWS credentials on every launch,** since exported SSO credentials are
|
||||||
hourly and exported SSO credentials are short-lived.
|
short-lived.
|
||||||
- **Requires nothing from the repository.** All state lives under
|
- **Requires nothing from the repository.** All state lives under
|
||||||
`~/.config/ai-sbx/`. Repositories that want first-class support can opt in with three
|
`~/.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
|
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) |
|
| [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/) |
|
| [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), [`jq`](https://jqlang.org/) | `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) | `aws configure export-credentials` | Required only when using `--aws-profile` |
|
|
||||||
| `git`, `sha256sum` | Repository identity | Already present on most systems |
|
| `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
|
**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
|
on 2026.7.17; verified broken on 2025.10.6, which drops `git::` entries silently — no
|
||||||
@@ -73,9 +71,8 @@ Verify:
|
|||||||
```bash
|
```bash
|
||||||
mise --version
|
mise --version
|
||||||
sbx version
|
sbx version
|
||||||
openssl version
|
|
||||||
jq --version
|
|
||||||
aws --version
|
aws --version
|
||||||
|
jq --version
|
||||||
```
|
```
|
||||||
|
|
||||||
### User-level install (recommended)
|
### User-level install (recommended)
|
||||||
@@ -88,7 +85,7 @@ Add to `~/.config/mise/config.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[task_config]
|
[task_config]
|
||||||
includes = [
|
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
|
```toml
|
||||||
includes = [
|
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
|
```toml
|
||||||
[task_config]
|
[task_config]
|
||||||
includes = [
|
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
|
## Usage
|
||||||
|
|
||||||
### 1. Create the GitHub App, once ever
|
### 1. Authenticate your read-only AWS profiles on the host
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
aws sso login --profile api-portal-readonly
|
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
|
Setup fails fast with the exact `aws sso login` command if a profile is missing or its
|
||||||
session has expired.
|
session has expired.
|
||||||
|
|
||||||
### 3. Set up a repository, once
|
### 2. Set up a repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/src/api-portal
|
cd ~/src/api-portal
|
||||||
@@ -261,27 +162,49 @@ mise run ai:sbx -- setup \
|
|||||||
--aws-profile prod-readonly
|
--aws-profile prod-readonly
|
||||||
```
|
```
|
||||||
|
|
||||||
Derives the repository from `origin`, resolves the App installation, creates the
|
Derives the repository from `origin`, creates the sandbox, then opens the GitHub token
|
||||||
sandbox, and installs a first token. No prompts.
|
form in your browser with everything pre-filled:
|
||||||
|
|
||||||
Without a configured App, setup instead prompts you to paste a fine-grained PAT
|
```text
|
||||||
carrying the same permissions, restricted to that one repository, with the shortest
|
name ai-sbx api-portal
|
||||||
expiration you will tolerate.
|
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
|
```bash
|
||||||
mise run ai:sbx -- run
|
mise run ai:sbx -- run
|
||||||
```
|
```
|
||||||
|
|
||||||
Mints a fresh one-hour GitHub token, refreshes AWS credentials, then attaches. Pass
|
Refreshes AWS credentials, then attaches. Pass agent arguments after a second `--`:
|
||||||
agent arguments after a second `--`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run ai:sbx -- run -- "Review the Terraform plan for the staging workspace"
|
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:
|
`gh` is already authenticated through the proxy, for that repository only:
|
||||||
|
|
||||||
@@ -310,11 +233,11 @@ provider "aws" {
|
|||||||
|
|
||||||
| Command | Effect |
|
| 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, create the sandbox, open the token form, install AWS profiles |
|
||||||
| `setup [options]` | Configure the repository, resolve the App installation, create the sandbox, install credentials |
|
| `token` | Replace the GitHub token for this repository — expiry, revocation, permission change |
|
||||||
| `run [-- args...]` | Mint a fresh GitHub token, refresh AWS credentials, attach to the agent |
|
| `run [-- args...]` | Refresh AWS credentials and attach to the agent |
|
||||||
| `refresh` | Same, without attaching |
|
| `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 |
|
| `remove` | Remove the sandbox and this repository's local configuration |
|
||||||
|
|
||||||
### `setup` options
|
### `setup` options
|
||||||
@@ -335,6 +258,7 @@ provider "aws" {
|
|||||||
| `AI_SBX_AGENT` | `codex` | `--agent` |
|
| `AI_SBX_AGENT` | `codex` | `--agent` |
|
||||||
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
|
| `AI_SBX_MODE` | `clone` | `--clone` / `--direct` |
|
||||||
| `AI_SBX_BRANCH` | `ai-sbx` | `--branch` |
|
| `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
|
## AWS profile naming
|
||||||
|
|
||||||
@@ -358,14 +282,12 @@ Variants such as `_readonly`, `-ro`, and `-read-only` are **not** stripped.
|
|||||||
## Where state lives
|
## Where state lives
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.config/ai-sbx/github-app mode 600, App ID + key path
|
|
||||||
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
|
~/.config/ai-sbx/repos/<digest>/config mode 600, no secrets
|
||||||
```
|
```
|
||||||
|
|
||||||
The repository file holds repository identity, sandbox name, agent, mode, branch, App
|
Holds repository identity, sandbox name, agent, mode, branch, and the approved host
|
||||||
installation ID, and the approved host profile names — no secrets. The App private key
|
profile names — no secrets. The GitHub token lives in the `sbx` secret store; AWS
|
||||||
stays wherever you put it; only its path is recorded. Tokens live in the `sbx` secret
|
credentials exist only inside the sandbox and only until they expire.
|
||||||
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`.
|
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
|
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
|
lives in your user-owned config; the repository only supplies its own identity, which
|
||||||
is cross-checked against `origin` on every run.
|
is cross-checked against `origin` on every run.
|
||||||
- **The App private key is the real secret.** Tokens expire hourly; the key does not.
|
- **One token per repository, per developer, with an expiry.** Nothing is shared: no
|
||||||
Anything that reads it can mint tokens for every repository the App is installed on.
|
private key, no client secret, no broker. Each developer's token is capped by their
|
||||||
Host only, mode 600, never mounted into a sandbox. Rotate by generating a new key,
|
own access, and organization owners can require approval and enforce a maximum
|
||||||
re-running `app`, and deleting the old key at GitHub.
|
lifetime.
|
||||||
- **App identity, not yours.** Installation tokens act as the App, so its commits and
|
- **The token acts as you.** Its commits and comments carry your identity, so treat
|
||||||
comments are attributable and its access is revocable independently of your account —
|
the agent's output as your own work. Revoke at
|
||||||
the main practical advantage over a PAT, which acts as you.
|
[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
|
- **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;
|
that cannot cause damage if the agent misbehaves. Terraform `plan` needs read access;
|
||||||
`apply` should stay outside the sandbox.
|
`apply` should stay outside the sandbox.
|
||||||
@@ -394,13 +319,15 @@ Inspect the current repository's state with `mise run ai:sbx -- status`.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash tests/profile-mapping.test.sh
|
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
|
shellcheck -x tasks/ai/sbx tests/*.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The JWT test generates a throwaway keypair, verifies the signature with
|
The token test checks URL encoding, that `target_name` carries the owner rather than
|
||||||
`openssl dgst -verify`, confirms a tampered input fails to verify, and checks the
|
the full repository name, that every permission survives into the query at a valid
|
||||||
permission set against GitHub's schema. Neither test touches the network, GitHub, or
|
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`.
|
`sbx`.
|
||||||
|
|
||||||
Task names come from directory nesting, not from colons in filenames: `tasks/ai/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_AGENT="${AI_SBX_AGENT:-codex}"
|
||||||
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
|
DEFAULT_MODE="${AI_SBX_MODE:-clone}"
|
||||||
DEFAULT_BRANCH="${AI_SBX_BRANCH:-ai-sbx}"
|
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.
|
# GitHub accepts these as query parameters on the token creation form. A write
|
||||||
# "workflows" has no read level; write is required to push any commit that
|
# level implies read, so only the highest level is listed. "workflows" is
|
||||||
# touches .github/workflows.
|
# required to push any commit touching .github/workflows and is separate from
|
||||||
GITHUB_APP_PERMISSIONS='{
|
# "actions".
|
||||||
"metadata": "read",
|
TOKEN_URL_PERMISSIONS=(
|
||||||
"contents": "write",
|
metadata=read
|
||||||
"pull_requests": "write",
|
contents=write
|
||||||
"issues": "write",
|
pull_requests=write
|
||||||
"workflows": "write",
|
issues=write
|
||||||
"actions": "write",
|
workflows=write
|
||||||
"checks": "read",
|
actions=write
|
||||||
"statuses": "read",
|
statuses=read
|
||||||
"security_events": "write",
|
security_events=write
|
||||||
"secret_scanning_alerts": "read",
|
)
|
||||||
"vulnerability_alerts": "read"
|
|
||||||
}'
|
# 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() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
Usage:
|
Usage:
|
||||||
mise run ai:sbx -- app --app-id ID --key PATH
|
|
||||||
mise run ai:sbx -- setup [options]
|
mise run ai:sbx -- setup [options]
|
||||||
|
mise run ai:sbx -- token
|
||||||
mise run ai:sbx -- refresh
|
mise run ai:sbx -- refresh
|
||||||
mise run ai:sbx -- run [-- agent arguments...]
|
mise run ai:sbx -- run [-- agent arguments...]
|
||||||
mise run ai:sbx -- status
|
mise run ai:sbx -- status
|
||||||
mise run ai:sbx -- remove
|
mise run ai:sbx -- remove
|
||||||
|
|
||||||
App options (configured once, for every repository):
|
Setup opens a pre-filled GitHub token form in your browser. Use "token" on
|
||||||
--app-id ID Numeric GitHub App ID, not the client ID.
|
its own to replace an expired or revoked token later.
|
||||||
--key PATH The App's RSA private key (.pem).
|
|
||||||
|
|
||||||
Setup options:
|
Setup options:
|
||||||
--aws-profile NAME Host AWS profile to expose inside the sandbox.
|
--aws-profile NAME Host AWS profile to expose inside the sandbox.
|
||||||
@@ -73,130 +78,120 @@ require_command() {
|
|||||||
die "Required command not found: $1"
|
die "Required command not found: $1"
|
||||||
}
|
}
|
||||||
|
|
||||||
github_app_configured() {
|
url_encode() {
|
||||||
[[ -f "$APP_CONFIG_FILE" ]]
|
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() {
|
token_url() {
|
||||||
github_app_configured ||
|
local owner="${REPOSITORY%%/*}"
|
||||||
die "No GitHub App configured. Run: mise run ai:sbx -- app --app-id ID --key PATH"
|
local name="${REPOSITORY#*/}"
|
||||||
|
local url="https://github.com/settings/personal-access-tokens/new"
|
||||||
|
|
||||||
# shellcheck disable=SC1090
|
url+="?name=$(url_encode "ai-sbx $name")"
|
||||||
source "$APP_CONFIG_FILE"
|
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:-}" ]] ||
|
local permission
|
||||||
die "APP_ID is missing from $APP_CONFIG_FILE"
|
for permission in "${TOKEN_URL_PERMISSIONS[@]}"; do
|
||||||
|
url+="&$permission"
|
||||||
|
done
|
||||||
|
|
||||||
[[ -r "${APP_PRIVATE_KEY_FILE:-}" ]] ||
|
printf '%s' "$url"
|
||||||
die "GitHub App private key is not readable: ${APP_PRIVATE_KEY_FILE:-unset}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
base64url() {
|
open_browser() {
|
||||||
openssl base64 -A | tr '+/' '-_' | tr -d '='
|
local url="$1" opener
|
||||||
}
|
|
||||||
|
|
||||||
# GitHub caps App JWT lifetime at 10 minutes and rejects future iat values, so
|
for opener in "${BROWSER:-}" xdg-open open; do
|
||||||
# backdate slightly to tolerate clock skew and stay well inside the cap.
|
[[ -n "$opener" ]] || continue
|
||||||
github_app_jwt() {
|
|
||||||
local now header payload signing_input signature
|
|
||||||
|
|
||||||
now="$(date +%s)"
|
if command -v "$opener" >/dev/null 2>&1; then
|
||||||
header='{"alg":"RS256","typ":"JWT"}'
|
"$opener" "$url" >/dev/null 2>&1 &
|
||||||
payload="$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' \
|
return 0
|
||||||
"$((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
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
local message
|
return 1
|
||||||
message="$(jq -r '.message // "unrecognized response"' <<<"$response" 2>/dev/null)" ||
|
|
||||||
message="unparseable response"
|
|
||||||
|
|
||||||
die "$context: $message"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_installation_id() {
|
read_token() {
|
||||||
local jwt response
|
local token
|
||||||
|
|
||||||
jwt="$(github_app_jwt)"
|
# -s keeps the token off the terminal; it never reaches shell history
|
||||||
response="$(github_api GET "/repos/$REPOSITORY/installation" "$jwt")"
|
# 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' \
|
[[ -n "$token" ]] ||
|
||||||
"GitHub App is not installed on $REPOSITORY"
|
die "No token entered."
|
||||||
}
|
|
||||||
|
|
||||||
mint_github_token() {
|
case "$token" in
|
||||||
local jwt response body
|
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)"
|
printf '%s' "$token"
|
||||||
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() {
|
install_github_token() {
|
||||||
require_command openssl
|
local url
|
||||||
require_command curl
|
url="$(token_url)"
|
||||||
require_command jq
|
|
||||||
|
|
||||||
load_app_config
|
cat >&2 <<EOF
|
||||||
|
|
||||||
[[ -n "${CONFIG_INSTALLATION_ID:-}" ]] ||
|
Create a fine-grained token for $REPOSITORY.
|
||||||
die "Installation ID is missing. Re-run: mise run ai:sbx -- setup"
|
|
||||||
|
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,
|
# --force is mandatory: without it a second write prompts for confirmation,
|
||||||
# reads the prompt from the already-consumed stdin, cancels, and still
|
# reads the prompt from the already-consumed stdin, cancels, and still
|
||||||
# exits 0 — leaving the previous, expired token in place.
|
# exits 0 - leaving the previous, expired token in place.
|
||||||
mint_github_token |
|
read_token |
|
||||||
sbx secret set --force "$SANDBOX_NAME" github >/dev/null
|
sbx secret set --force "$SANDBOX_NAME" github >/dev/null
|
||||||
|
|
||||||
printf 'Installed a fresh GitHub App token for %s (expires in 1 hour).\n' \
|
printf 'Stored the token for %s in sandbox %s.\n' "$REPOSITORY" "$SANDBOX_NAME" >&2
|
||||||
"$REPOSITORY"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Terraform and provider blocks reference the account profile name, while the
|
# Terraform and provider blocks reference the account profile name, while the
|
||||||
# host distinguishes the read-only grant with a -readonly suffix. The suffix is
|
# 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.
|
# 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"
|
die "Agent is missing from $REPO_CONFIG_FILE"
|
||||||
|
|
||||||
CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}"
|
CONFIG_BRANCH="${CONFIG_BRANCH:-$DEFAULT_BRANCH}"
|
||||||
CONFIG_INSTALLATION_ID="${CONFIG_INSTALLATION_ID:-}"
|
|
||||||
|
|
||||||
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
|
declare -p CONFIG_AWS_PROFILES >/dev/null 2>&1 ||
|
||||||
CONFIG_AWS_PROFILES=()
|
CONFIG_AWS_PROFILES=()
|
||||||
@@ -305,8 +299,7 @@ save_config() {
|
|||||||
local agent="$1"
|
local agent="$1"
|
||||||
local mode="$2"
|
local mode="$2"
|
||||||
local branch="$3"
|
local branch="$3"
|
||||||
local installation_id="$4"
|
shift 3
|
||||||
shift 4
|
|
||||||
local -a profiles=("$@")
|
local -a profiles=("$@")
|
||||||
|
|
||||||
mkdir -p "$REPO_CONFIG_DIR"
|
mkdir -p "$REPO_CONFIG_DIR"
|
||||||
@@ -318,7 +311,6 @@ save_config() {
|
|||||||
printf 'CONFIG_AGENT=%q\n' "$agent"
|
printf 'CONFIG_AGENT=%q\n' "$agent"
|
||||||
printf 'CONFIG_MODE=%q\n' "$mode"
|
printf 'CONFIG_MODE=%q\n' "$mode"
|
||||||
printf 'CONFIG_BRANCH=%q\n' "$branch"
|
printf 'CONFIG_BRANCH=%q\n' "$branch"
|
||||||
printf 'CONFIG_INSTALLATION_ID=%q\n' "$installation_id"
|
|
||||||
|
|
||||||
printf 'CONFIG_AWS_PROFILES=('
|
printf 'CONFIG_AWS_PROFILES=('
|
||||||
local profile
|
local profile
|
||||||
@@ -589,17 +581,7 @@ setup_command() {
|
|||||||
validate_aws_profile "$profile"
|
validate_aws_profile "$profile"
|
||||||
done
|
done
|
||||||
|
|
||||||
local installation_id=""
|
save_config "$agent" "$mode" "$branch" "${aws_profiles[@]}"
|
||||||
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 sandbox_exists; then
|
||||||
if [[ "$replace" == true ]]; then
|
if [[ "$replace" == true ]]; then
|
||||||
@@ -616,40 +598,7 @@ setup_command() {
|
|||||||
create_sandbox
|
create_sandbox
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if github_app_configured; then
|
|
||||||
install_github_token
|
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
|
install_sandbox_aws_files
|
||||||
|
|
||||||
@@ -669,54 +618,13 @@ Run it with:
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
app_command() {
|
token_command() {
|
||||||
local app_id="" key=""
|
load_config
|
||||||
|
|
||||||
while (($#)); do
|
sandbox_exists ||
|
||||||
case "$1" in
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
||||||
--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]+$ ]] ||
|
install_github_token
|
||||||
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() {
|
refresh_command() {
|
||||||
@@ -725,10 +633,6 @@ refresh_command() {
|
|||||||
sandbox_exists ||
|
sandbox_exists ||
|
||||||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
||||||
|
|
||||||
if github_app_configured; then
|
|
||||||
install_github_token
|
|
||||||
fi
|
|
||||||
|
|
||||||
install_sandbox_aws_files
|
install_sandbox_aws_files
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -738,11 +642,8 @@ run_command() {
|
|||||||
sandbox_exists ||
|
sandbox_exists ||
|
||||||
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
die "Sandbox does not exist. Run: mise run ai:sbx -- setup"
|
||||||
|
|
||||||
# Both credentials are short-lived, so re-mint before every session.
|
# The GitHub token is long-lived and stays in the sbx secret store; only
|
||||||
if github_app_configured; then
|
# the AWS credentials expire between sessions.
|
||||||
install_github_token
|
|
||||||
fi
|
|
||||||
|
|
||||||
install_sandbox_aws_files
|
install_sandbox_aws_files
|
||||||
|
|
||||||
if (($#)) && [[ "$1" == "--" ]]; then
|
if (($#)) && [[ "$1" == "--" ]]; then
|
||||||
@@ -769,14 +670,7 @@ status_command() {
|
|||||||
printf 'Branch: %s\n' "$CONFIG_BRANCH"
|
printf 'Branch: %s\n' "$CONFIG_BRANCH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if github_app_configured; then
|
printf 'Token days: %s\n' "$DEFAULT_TOKEN_DAYS"
|
||||||
# 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'
|
printf 'AWS profiles (host -> sandbox):\n'
|
||||||
if ((${#CONFIG_AWS_PROFILES[@]})); then
|
if ((${#CONFIG_AWS_PROFILES[@]})); then
|
||||||
@@ -823,10 +717,6 @@ main() {
|
|||||||
usage
|
usage
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
app)
|
|
||||||
app_command "$@"
|
|
||||||
return
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
|
|
||||||
require_command git
|
require_command git
|
||||||
@@ -839,6 +729,9 @@ main() {
|
|||||||
setup)
|
setup)
|
||||||
setup_command "$@"
|
setup_command "$@"
|
||||||
;;
|
;;
|
||||||
|
token)
|
||||||
|
token_command "$@"
|
||||||
|
;;
|
||||||
refresh)
|
refresh)
|
||||||
refresh_command "$@"
|
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