trailofbits / trailofbits/coop

Creating a scoped gh token

Open
#73 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

enhancement :sparkles:
Dominant language
Rust
Stars
243
Forks
13
Avg merge
1d 20h
Merged PRs (30d)
30

Description

Tracked work

  • #85 — guided fine-grained PAT wizard (coop github setup-pat). First implementation slice spun out of this design discussion; corresponds to Option B below.

This issue stays open as the parent design discussion.

Problem

Would be helpful if, as part of starting a VM, one could inject a gh credential whose effective reach is limited to the current project. Today's github = "auto" resolves to whatever gh auth login granted — typically every repo the user can touch. An agent that goes wild in the guest can push to any of them, comment on any PR, etc. We want the worst case confined to the one repo the agent is meant to be working on.

Constraints

  • The three existing modes ("auto" / "env" / "off") stay. Default stays "off". Anything new is additive and opt-in.
  • Prefer zero user setup if possible.
  • Don't ship a feature whose security guarantee is weaker than its name implies. If we can't fully enforce "scoped to this repo," don't call it that.

What enforcement actually looks like

Every realistic approach falls into one of three buckets. The first two are client-side and bypassable; the third is the only one GitHub actually enforces.

Approach Token reaches the guest? Scope enforcement Bypass
Today (auto) yes, env-persisted none n/a — no scope claim
Credential helper + URL allowlist on host yes, on demand via socket client-side only: refuse requests for non-allowed URLs agent fetches the token once for the allowed repo via the helper, then uses it against any repo via the GitHub API directly
HTTPS proxy with header injection on host (token never crosses to guest) no client-side only: path-prefix filter on api.github.com/repos/{allowed}/{...} gh api graphql posts to /graphql with arbitrary GraphQL bodies — no URL-level filter can constrain it. gh subcommand allowlists require argv parsing, which is its own attack surface (subcommand variants, --repo, current-dir-derived, env overrides, …).
GitHub App installation token yes (or via proxy) server-enforced: GitHub rejects out-of-scope calls regardless of how the agent calls them. Per-repo, per-permission, ~1 h TTL. n/a — bypass requires breaking GitHub's authn
Fine-grained PAT yes server-enforced if the user scoped it that way as scoped by the user at creation time

The middle two rows are exposure reductions, not scope reductions. They make the token harder to exfiltrate and harder to use sloppily, but a determined agent inside the VM can still authenticate to any repo the user can touch. Shipping them under a name like "scoped" would mislead. The bottom two are the only options that deliver what this issue's title asks for.

Options that don't mislead

Option A: do nothing new

Document the blast-radius behaviour of "auto" clearly, point users at github = "env" with a fine-grained PAT they create themselves when they want scoping. Zero new code, zero misleading claim.

Costs: requires every user who wants scope to mint a PAT manually per repo. Most won't.

Option B: guided fine-grained PAT setup (recommended starting point)

Fine-grained PATs are the simpler half of GitHub's server-enforced scoping story. They give us the same enforcement properties as App installation tokens — repo-scoped, permission-scoped, GitHub rejects out-of-scope calls regardless of how the agent calls the API — without any of the App machinery (no JWT signing, no manifest, no PEM as a long-lived root credential, no HTTP client for App APIs). The trade-off is that the user does the form-filling themselves, and the resulting token doesn't auto-rotate.

For a user running coop against one or two repos at a time — which I'd guess is the common case — this is a better fit than the App path. coop's job here is just to (a) tell the user exactly what to fill in, (b) validate what they pasted back, and (c) store it in a secret store. The runtime side is basically github = "env" with a different label.

Setup flow
$ coop github setup-pat --repo trailofbits/coop
Opening https://github.com/settings/personal-access-tokens/new in your browser…

Configure the form as follows:
  Token name:         coop-trailofbits-coop
  Expiration:         (your choice — 90 days is a reasonable default)
  Resource owner:     trailofbits
  Repository access:  Only select repositories → trailofbits/coop
  Repository permissions:
    Contents:        Read and write
    Pull requests:   Read and write
    Issues:          Read and write
    Metadata:        Read-only (auto-included)

Click "Generate token", then paste it here.
Paste token: ********

Validating token against api.github.com…
  ✓ token format (github_pat_…)
  ✓ /user
  ✓ /repos/trailofbits/coop  (200 OK)
  ✓ contents:write inferred (HEAD on /contents/ returned 200)

Where should I store this token?
  1) macOS Keychain (recommended)
  2) 1Password
  3) Plain file at ~/.coop/state/github-pat/<sha>.txt (mode 0600)
> 1

Wrote ~/.coop/config.toml:
  github = "pat"
  [github.pat]
  token = "cmd:security find-generic-password -s coop-github-pat -a trailofbits-coop -w"
What this gets us
  • Server-enforced scope. GitHub rejects any operation outside the PAT's declared repo + permission set. GraphQL is enforced the same way as REST. Agent argv parsing is not in the trust chain.
  • No long-lived root credential. The PAT itself is the only secret; it expires (up to 1 year, user picks). No App PEM that could mint forever if leaked. A compromised PAT has bounded lifetime even before manual rotation.
  • Storage in the user's existing secret store via the cmd: indirection from #16. Same keychain / 1Password / Secret Service / file-fallback table as Option C.
  • Tiny code surface in coop. A wizard for setup, a stdin-prompt for paste, a few curl-style validation calls, and the existing github = "env" code path with a "pat" label. No JWT signer, no App API client, no manifest handler.
Caveats
  • URL pre-fill is unverified. GitHub supports pre-fill query parameters for classic PATs (/settings/tokens/new?description=…&scopes=…). I have not confirmed fine-grained PATs accept the same parameters. If they do, the wizard deeplinks into a half-filled form and the user clicks roughly four times. If they don't, the user copies the configuration from coop's terminal output and fills the form manually. Worth a 10-min spike to confirm before committing UX copy. Either way it works.
  • Org policy may block. Some orgs disable fine-grained PATs entirely, or require central approval before the PAT can act on org repos (set under Organization → Settings → Personal access tokens). When blocked, the wizard fails at the validation step (/repos/... returns 403); coop tells the user and points at Option C (Apps) or "env" with their own credential.
  • No auto-rotation. When the PAT expires, the next coop start fails with a clear error. User reruns coop github setup-pat or, better, coop github rotate-pat which opens the same form pre-populated with the prior selections (subject to the pre-fill caveat above) so they just re-click "Generate token".
  • One PAT per repo (or one PAT covering multiple repos). For users with many repos, this gets tedious. That's the audience for Option C.
Implementation sketch
  • New command coop github setup-pat [--repo owner/name]:
    • Detect target repo from --git-repo URL, git remote get-url origin in the synced workspace, or --repo flag. Prompt if ambiguous.
    • Build the pre-fill URL (subject to caveat); open the browser; print the canonical instructions on the terminal as a fallback.
    • Read the token from stdin with terminal echo disabled.
    • Validate: prefix check (github_pat_), GET /user, GET /repos/{repo}, probe a few read endpoints to detect granted permissions and warn if any are missing.
    • Pick a secret store via the same prompt as the App path; store; write the [github.pat] config block with the cmd: accessor.
  • New commands coop github rotate-pat, coop github status, coop github forget-pat mirror the App-side commands.
  • Config: github = "pat" selects the mode. [github.pat] table: token (path or cmd:), optional repo (recorded for status output; not used at runtime since the PAT itself carries the scope).
  • Runtime: resolve_github_token (src/backend.rs:1076) gets a Pat branch that just resolves the cmd:-prefixed value and returns the token. No JWT, no minting. Same forward path as "env" from there on.
  • Validation: coop validate confirms the token resolves and starts with github_pat_. With --probe, calls GET /user to confirm it's still live.

No new deps required. ureq is only needed if we want to probe permissions during setup — and even that can be done by shelling out to gh api (gh is already on the host per the earlier constraint) instead of pulling in an HTTP crate.

Option C: GitHub App with a guided one-time setup

GitHub Apps are the only mechanism that gives us server-enforced, per-repo, short-lived tokens. The "burden" objection to App-based flows comes from the operator-grade ceremony — create App, configure permissions, save PEM, find installation ID, write it into config. That is unnecessarily painful.

Whose App is it?

GitHub Apps have three levels of scope, and the answer to "what is the App scoped to?" depends on which level you mean:

  1. App registration — owned by a GitHub account (user or org), holds the private key, declares the permissions it may ever request.
  2. Installation — a grant by some account ("install this App on my account and allow it to touch these repos"). One App can have many installations across many accounts.
  3. Installation token — short-lived ghs_… token minted from a JWT signed by the App, optionally scoped down at mint time to a subset of the installation's repos and permissions.

For coop, the App is per user, owned by the user's GitHub account:

  • Not a shared coop-project App. Anyone who can sign JWTs as the App can mint tokens against any of its installations. A shared App would mean either baking the private key into the binary (every user gets it, catastrophic) or operating a server-side token broker (infrastructure the project doesn't have). Off the table.
  • Not per target repo. A single App can be installed against many repos; per-repo Apps would be absurd ceremony for no benefit.
  • Per user, registered once via the wizard. Each user runs coop github setup-app once, ever. The manifest flow creates an App owned by their GitHub account. The PEM lives only on their machine. They then install it on their own account and/or any orgs they admin, choosing which repos it can reach. coop mints per-VM tokens that scope down further to just the repo the current VM is operating against.

Caveat: installing an App on an org requires admin rights on that org. A user who isn't an org admin can't install their personal coop App on org repos — they ask an admin to install it, fall back to "env" with a fine-grained PAT for those repos, or stick with "auto" accepting the blast radius.

Setup flow

GitHub supports an App manifest creation flow (docs) that compresses the operator-grade ceremony into one click:

  1. User runs coop github setup-app (one time, ever).
  2. coop POSTs an App manifest to https://github.com/settings/apps/new?state=<nonce> with all permissions pre-declared.
  3. Browser opens; user clicks "Create GitHub App for me" once.
  4. GitHub redirects to a coop-local listener with a temporary code; coop exchanges it for app_id + private key + webhook secret in one API call.
  5. coop persists app_id and the PEM under ~/.coop/state/github-app/, file mode 0600.
  6. User clicks "Install" once to grant the App access to whichever repos they want coop to be able to scope tokens against. Can be one repo, all of them, or a list — user's choice, modifiable later via the App's settings page.

From then on, github = "app" works automatically:

  • At coop start, derive the target repo from --git-repo URL (or from git remote get-url origin in the synced workspace).
  • Sign a JWT with the saved PEM, POST to /app/installations/{id}/access_tokens with repositories=[<derived>] and the minimum permission set needed (contents:write, pull_requests:write, issues:write are reasonable defaults; configurable).
  • Forward the resulting ghs_… token to the guest as GITHUB_TOKEN. It is server-enforced repo-scoped with ~1 h TTL.
  • Cache on host under ~/.coop/state/github-tokens/<sha256(inputs)>.json until 5 min before expiry. This is the "don't regenerate if it already exists" half of the original ask: cache key includes (app_id, installation_id, repos, permissions), so identical mint inputs reuse the existing token; anything different mints fresh.

Server-side enforcement means we don't care if the agent is clever about how it calls GitHub. The token only works for the listed repos with the listed permissions. GraphQL is enforced the same way as REST.

Setup cost: two clicks, once. Operating cost: zero.

Code/dep cost in coop: ~250 LOC for the manifest-flow handler + JWT signer + token mint + cache. Two new deps (jsonwebtoken for RS256, ureq for the few HTTPS calls — both blocking, no async).

Security implications of operating an App

The App introduces a new long-lived root credential, which is qualitatively different from a gh auth login OAuth token. Worth being explicit about the trade:

  • The PEM is more sensitive than the OAuth token it replaces. An OAuth token is a single capability. The App's private key is the ability to mint capabilities at will against every account where the App is installed. Anyone with the PEM can sign JWTs as the App, mint installation tokens, and act against the granted repos. Storing it correctly matters more than storing today's gh token correctly.
  • Smaller everyday blast radius, more sensitive root secret. Minted tokens are 1 h, single-repo, server-enforced. That's a real reduction vs. today. But the PEM, if leaked, is worse than a leaked OAuth token. The trade is favourable if the PEM is well-stored; an own-goal if it sits in a world-readable file. Hence the storage question below.
  • Manifest defines the permissions ceiling. Whatever is declared at App-creation time becomes the maximum any installation can grant. Wizard declares the minimum useful set (contents:write, pull_requests:write, issues:write, metadata:read is the working assumption) and nothing more. Expanding later requires user re-approval per installation.
  • Manifest sets the App to private, no webhooks, no events. Public Apps can be installed by anyone; we don't want that. coop has no webhook endpoint. Both reduce attack surface.
  • GitHub audit log captures every token mint. Visible under the App owner's account. This is a recovery aid the current auto flow does not provide — if the PEM does leak, you can see what tokens were minted and when.
  • Compromise model: a compromised guest can exfiltrate the minted token (1 h, one repo, declared perms). It cannot exfiltrate the PEM unless the PEM is also reachable from the guest (it isn't — PEM stays on the host).
Where the private key lives

coop already has cmd: indirection on config values (resolve_cmd_value, src/config.rs:2676, landed in #16). It shells out to any command and uses stdout as the value. That's the right hook for keychain integration — no new mechanism needed.

Wizard offers a choice with a platform-appropriate default:

Option Storage command Resolved by
macOS Keychain (default on macOS) security add-generic-password -s coop-github-app -a <app_id> -w <pem> -U cmd:security find-generic-password -s coop-github-app -a <app_id> -w
1Password (offered if op is on PATH) manual store, wizard prints the item path to use cmd:op read op://Personal/coop-github-app/private-key
Linux Secret Service (default on Linux when secret-tool is present) secret-tool store --label=coop-github-app service coop-github-app account <app_id> cmd:secret-tool lookup service coop-github-app account <app_id>
File fallback (default when nothing better is detected) write to ~/.coop/state/github-app/<app_id>.pem, mode 0600 direct path in config

In the file-fallback case, coop logs a one-line notice at every start: PEM stored on filesystem at <path>; consider moving to Keychain/1Password with 'coop github relocate-key <store>'.

The short-lived installation tokens (~1 h ghs_…) cached on disk are lower-stakes — file cache with 0600 is fine. Keychain churn at every cache miss is overkill.

Lifecycle: rotation, teardown, multiple machines

Four levels of credential, independently manageable:

Level Lifetime Management
App registration Permanent until user deletes it on GitHub coop github tear-down opens the App settings page where user clicks "Delete App"; locally, wipes PEM + cache + config record
Private key (PEM) Lives until rotated; GitHub allows multiple active keys per App so rotation can be zero-downtime coop github rotate-key opens the App settings page → "Generate a private key" → user downloads / pastes, wizard ingests, stores in the chosen secret store. Old key remains valid on other machines until user revokes it from the same page
Installations Until uninstalled from the granting account coop github status shows which accounts the App is installed on and which repos each installation covers. Add/remove repos via GitHub UI. coop falls back gracefully (logs and continues unauthenticated) when an installation is missing for a requested repo
Installation tokens ~1 h Host cache auto-expires; nothing to manage

New machine setup is an explicit branch in the wizard:

$ coop github setup-app
Found existing App "Coop CLI (<user>)" (app_id 12345) on your GitHub account.
What would you like to do?
  1) Restore: paste/import the existing PEM (recommended if you have it backed up)
  2) New key: generate a new private key for this App (old key keeps working elsewhere)
  3) Fresh App: create a separate "Coop CLI (<user>-<device>)" App
  4) Cancel
> _
  • --restore verifies the pasted PEM by signing a JWT and calling GET /app. No new App created.
  • --new-key opens the App settings page, ingests the downloaded PEM. Old key remains valid; user can revoke it from the same page when ready.
  • --name <slug> creates a separately-named App (Coop CLI (laptop), Coop CLI (desktop)) — useful for users who prefer compartmentalisation over key-sharing.

Lost PEM, no backup. App still exists on GitHub. User goes to the App's settings page, clicks "Generate a private key", runs coop github setup-app --new-key. No data loss — the App, its installations, and their repo selections all live server-side.

coop github status is the inspect command: prints app_id, where the PEM is stored (e.g., keychain: coop-github-app/12345), each installation with its account + repo list + granted permissions, and the cache state (how many cached tokens, oldest expiry). Lets users verify scope without clicking through GitHub UI.

Option D: ship B + C

Recommended shape. The PAT wizard (B) covers single-repo and few-repo workflows with minimal code; the App wizard (C) covers users who manage many repos or want auto-rotation. Power users who want to bring their own credential (org-issued installation token from CI, pre-minted fine-grained PAT, whatever) keep using "env" with their own value — no code change required for that path.

Suggested order of work:

  1. Ship the secret-store plumbing (a small abstraction over Keychain / 1Password / Secret Service via cmd: indirection) — both wizards depend on it.
  2. Ship Option B (PAT wizard). Smallest code, biggest immediate user value.
  3. Ship Option C (App wizard) once B is settled and there's demand for auto-rotation / multi-repo simplification.

What I'm dropping from earlier drafts

  • The credential-helper / SSH-proxied socket proposal: dropped as a scope-enforcement claim. It would reduce exposure (token off the guest env) but not scope, and the value gap vs. just "set GITHUB_TOKEN and accept the blast radius" is too thin to justify the implementation cost given that it'd need to be documented as "exposure reduction, not scope."
  • The gh subcommand wrapper / argv allowlist: dropped — parsing attack surface and incomplete coverage make this a misfeature.
  • An HTTPS-proxy variant: same fate, plus the GraphQL gap.

These could come back as exposure-reduction layers on top of an App-minted token (the token is already scoped, and we additionally avoid letting it sit in the guest env). But ordering matters: ship real scope first, layer exposure reductions on later if there's demand.

Implementation sketch (Option C — App wizard)

The PAT wizard's implementation is sketched inline above under Option B. This section covers the App-wizard side.

New commands under a coop github subcommand group:

  • coop github setup-app [--name <slug>] [--restore] [--new-key]: the wizard.
    • Default path: detect existing coop-tagged Apps via gh api /user/installations cross-referenced with gh api /user/apps. If one exists, branch into the restore / new-key / fresh-App prompt described above. If none, run the manifest flow.
    • Manifest flow: bind a local HTTP listener on 127.0.0.1:<random-free-port> for the OAuth-style callback. Build manifest with the declared permission set, public = false, no webhooks, no events. POST/redirect to https://github.com/settings/apps/new?state=<nonce>, open the browser (open / xdg-open). On callback, exchange the code for App credentials via POST /app-manifests/{code}/conversions.
    • Secret store selection: detect available stores (Keychain on macOS, secret-tool on Linux, op if present), prompt with a platform-appropriate default, persist PEM via the chosen mechanism, write the cmd:-prefixed accessor (or direct path for the file fallback) into ~/.coop/config.toml under [github.app].
    • Print the install URL; tell the user to click "Install" and select repos.
  • coop github rotate-key: opens the App settings page, prompts the user to paste/import the new PEM, writes it to the configured secret store. Old key remains valid until the user revokes it via GitHub UI.
  • coop github relocate-key <keychain|1password|file>: moves the PEM between stores. Verifies the new location works (signs a JWT, calls GET /app) before removing the old copy.
  • coop github status: prints app_id, PEM storage location, installations + repo lists + permissions, cache state. No secret material in output.
  • coop github tear-down: opens the App settings page (so the user can click "Delete App" — GitHub does not expose an API to delete an App), wipes local PEM, cache, and config record after confirmation.

Library side:

  • Config: github = "app" selects the new mode. [github.app] table: private_key (path or cmd:), optional app_id, optional repositories override, optional permissions override.
  • New module src/github_app.rs: JWT signing, installation-token mint, host-side cache, secret-store helpers. ~250 LOC.
  • src/backend.rs:1076 (resolve_github_token) gains an App branch that calls into github_app::mint_or_reuse(...).
  • Repo inference: extract owner/repo from --git-repo HTTPS URL (extend is_github_https_url at src/backend.rs:1581), fall back to git -C /workspace/repo remote get-url origin over ssh if --git-repo wasn't passed.
  • Cache invalidation: keyed on (app_id, installation_id, repos, permissions) hash; entry includes expires_at. Reuse if expires_at - now > 5 min. File mode 0600.
  • Manifest declares the minimum permission set; do not declare permissions we don't currently use. Adding permissions later is a manifest update + per-installation re-approval — acceptable cost.
  • Logging: log app_id, repo list, TTL — never the token, never the PEM. Pairs with #79.
  • Validation: coop validate confirms the PEM resolves (via cmd: or file) and parses as RSA private key. With --probe, signs a JWT and calls GET /app to confirm GitHub accepts it. No token minting in validate.

Deps: jsonwebtoken for RS256 signing, ureq for the few HTTPS calls (blocking, small, already aligned with coop's no-async stance). No keychain crate needed — everything goes through cmd: indirection to platform-native binaries.

Open questions

  1. FGPAT URL pre-fill (Option B). Does /settings/personal-access-tokens/new accept query-string pre-fill the way the classic-PAT endpoint does? Worth a 10-min spike — affects UX copy, not whether to ship. If it does, the wizard becomes ~4 clicks; if not, the wizard prints the configuration and the user fills it in by hand.
  2. Manifest flow viability (Option C). Does GitHub's manifest endpoint cover everything we need (permissions, callback URL, public=false, zero events)? I believe yes, but worth a 30-min spike before committing.
  3. Refresh during long sessions (Option C). Installation tokens TTL out at ~1 h. For a VM running all day: (a) refresh only on next coop start/coop shell (simple, agent has to retry once); (b) ship a tiny in-guest refresher that talks to a host-side socket (re-introduces the proxy infrastructure we dropped — but as a complement to a properly-scoped token, not a replacement for scoping). (a) is fine for v1.
  4. Permission defaults. Working set for both wizards: contents:write, pull_requests:write, issues:write, metadata:read. Anything else commonly needed by agent workflows? workflows:write for editing .github/workflows/* is a candidate but high-blast — leave off the default, document the override.
  5. Combination with "env". "env" continues to accept any token (including a pre-minted installation token from the user's own CI / Terraform setup) — strict improvement, no code change. Worth documenting that "env" + a manually-minted fine-grained PAT delivers the same enforcement as Option B without the wizard, for users who'd rather manage the token themselves.
  6. Org PAT policies (Option B). Some orgs require central approval before a fine-grained PAT can act on org repos; the wizard's validation step will return 403 in that case. Detect that specifically and tell the user exactly what to ask their org admin for (vs. a generic 403), or just fail with the HTTP status and let them figure it out? Detecting and explaining is friendlier and ~10 LOC.
  7. Tear-down side of App tear-down (Option C). GitHub has no "delete App" API; the wizard can only open the browser and wipe local state. Is that acceptable, or should we provide a "delete locally only" mode (coop github forget) that leaves the App alive on GitHub for later restoration? Probably both, with tear-down as the destructive default and forget as the soft variant.

Related

  • #62 (Claude Code OAuth forwarding) — similar shape (host credential → guest), benefits from the same caching primitives.
  • #79 (generic credential redaction in Debug) — applies to any new struct that carries the PEM or a minted token.
  • #76 / PR #78 (--git-repo clone auth) — the existing one-shot stdin pattern is fine and doesn't need to change; it composes with github = "app" (App-minted token replaces the user OAuth token at the host-resolution step).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

This issue remains a parent design discussion; start with the linked implementation slice in #85, the guided fine-grained PAT wizard. Read the proposed runtime path at src/backend.rs:1076 and the setup, validation, storage, and configuration requirements here; the work is complete only when the chosen design is implemented with its stated security guarantees.

Written by the indexing model from the issue text.

Assessment

Tech stack
github, rust
Domain
authentication, cli, security
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
20/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.