ADR-0013: Constrain the GitHub Token Per-Sandbox to the Mounted Repositories
August 26, 2026 · View on GitHub
Context and Problem Statement
The quickstart's "Configure secrets and policy" step (README Step 3,
docs/QUICKSTART_SBX.md) tells the user to store their GitHub credential once,
globally:
gh auth token | sbx secret set -g github --force
gh auth token emits the user's gh CLI OAuth token, which carries broad
account-wide scopes (typically repo, workflow, delete_repo, gist,
admin:ssh_signing_key, read:org, …). Stored -g (global), the sbx proxy
injects it into every sandbox for every project. The result: an agent
working on project A can act as the user on all of the user's GitHub
repositories — read private code, push, delete repos — none of which is in the
sandbox's mounted workspace.
This violates least privilege (AC-6). The sandbox's filesystem is already constrained to the mounted paths (ADR-0001); its GitHub authority should be similarly constrained to the repositories those paths actually contain.
We want each sandbox to hold a GitHub credential scoped to only the repos in its
mounted workspace, derived from the .git remotes found there.
Decision
acq detects the GitHub repositories in the mounted workspace (by parsing
remote.origin.url of each .git directory, reusing the capped, symlink-safe
scan already in warn_if_no_git_identity) and, on acq run and acq create,
guides the user to mint a GitHub fine-grained personal access token (PAT)
scoped to exactly those repositories, stored sandbox-scoped
(acq.<sandbox>.github) rather than globally.
Concretely:
- A new
github_scope_sandboxflow builds a pre-filled fine-grained-PAT creation URL (https://github.com/settings/personal-access-tokens/new?…) withtarget_name=<owner>, a name derived from the sandbox,expires_in=30, and the minimal default permissionscontents=write+pull_requests=write+issues=write+actions=read(metadata:readand the read levels are implied).actions=readlets the agent read the Actions workflow-run status that surfaces most PR checks (fine-grained PATs cannot call the Checks API — a GitHub limitation, see Consequences). The default is deliberately held to least privilege:actionsis read-only (write additionally grants cancel-runs and delete-logs/artifacts, which cut against the AU-2 audit consequence below), and noworkflowsscope is requested by default (workflows=writegrants create/edit of.github/workflows/*— a CI privilege-escalation vector: a workflow the agent can author runs with the repo'sGITHUB_TOKENand secrets). Users who need agent-driven re-runs or workflow edits widen the scope in the GitHub form (the notice tells them how). The user clicks it, selects only the named repositories, generates the token, and pastes it back. - The token is read from the TTY (never argv), stored in the acq-neutral secret
store keyed
acq.<sandbox>.github, and fed to the sbx proxy as thegithubbuilt-in for that sandbox — the same injection path as a global github secret, the agent never sees the value. - On
acq runandacq create, when the workspace has GitHub repos and no sandbox-scoped github secret exists (whether or not a broad global one exists),acqprints an advisory and, on an interactive TTY, offers[continue / scope now](default: continue). This follows the repo's warn-not-block convention (matchingwarn_if_no_ssh_signing_key/warn_if_no_git_identity) — it never blocks a run and is a no-op in CI / non-TTY. On a fresh create, this advisory runs beforeacq_backend_provision, not after. A backend that binds secrets only at create time (msb, via--secret ENV@HOST) can only pick up a token that is already stored when the sandbox is created; a token scoped after create would never bind to that sandbox. Ordering the advisory before provision — the same reason the USAi key gate precedes provision — lets a supplied token bind at create. It stays warn-not-block: declining proceeds with the create (unlike the USAi gate, the GitHub token is optional). On a re-attach to an existing sandbox the advisory still runs (after the heal), but there it drives the live re-feed path (msb modify/ sbx proxy) rather than a create-time binding. - The global
sbx secret set -g githubpath is deprecated in the docs (kept working for back-compat), and the per-sandbox scoped flow becomes the documented default.
Multiple distinct owners in one workspace are handled by guiding one token per owner (fine-grained PATs are single-owner by design).
Considered Alternatives (rejected)
Investigation (docs/explorations/downscoping-github-credentials-for-local-agents.md)
established that most "automatic downscoping" paths are not available to a local
wrapper that only holds the user's gh token:
-
POST /applications/{client_id}/token/scoped("create a scoped access token") — can downscope a user-to-server OAuth token to specific repos/permissions, but requires OAuth-App HTTP Basic auth (client_id:client_secret). A bareghuser token as bearer returns404(verified).acqdoes not holdgh's client secret, so this is not usable. Rejected. -
Downscope the
ghtoken directly — there is noghsubcommand or supported API to narrow an existing user token. Rejected. -
Programmatically mint a fine-grained PAT — there is no API and no
ghcommand to create a fine-grained (or classic) PAT; creation is web-UI only. The org endpoints only approve/deny/list/revoke. We therefore guide the web-UI creation (with a pre-filled URL) rather than automate it. This is the accepted approach; the "no API" limitation is why it is guided, not silent. -
GitHub App installation token (
POST /app/installations/{id}/access_tokenswithrepositories/permissions) — the only fully-automatable per-repo path (JWT auth, 1-hour TTL, no client secret, no web UI). Mature local tooling exists (Link-/gh-token,AmadeusITGroup/gh-app-auth,bdellegrazie/git-credential-github-app). Deferred, not rejected: it requires a registered GitHub App, the App installed on the GSA-TTS account, andacqholding the App's private key — an org-admin dependency that would block shipping. This is the recommended future evolution once such an App exists; at that point the samegithub_scope_sandboxseam can mint an installation token automatically instead of guiding a PAT. -
Jentic One credential broker — brokers REST/HTTP calls with per-operation
allow/denybelow the token's own scopes, and the agent never holds the upstream token. But it cannot broker git-over-HTTPS (git clone/fetch/pushuse the smart-HTTP protocol, which is not OpenAPI-describable and not registrable), must run outside the agent's sandbox to preserve its trust boundary, and is Public Beta ("not recommended for production"). It would only constraingh api-style REST actions, not the core git loop. Out of scope. -
Network egress firewall (Anthropic Claude Code's
init-firewall.shpattern; and this repo's existingsbx policy allow network) — constrains where traffic goes (allowlist GitHub + USAi, drop the rest), which is a valuable exfiltration control, but cannot constrain which repository an authenticated GitHub request touches. It does not solve this problem. Out of scope for this ADR (the SBX/egress boundary remains the complementary control per ADR-0001).
Consequences
- Least privilege (AC-6): a compromised or prompt-injected agent in one sandbox can only reach the repositories that sandbox mounts, with the permissions the user granted — not the user's entire GitHub account.
- One manual step per new sandbox: minting a fine-grained PAT is web-UI only, so scoping a new sandbox requires a browser round-trip. The pre-filled URL and named repo list minimize the friction; the flow is skippable (warn-not-block).
- Fine-grained PAT limitations apply: fine-grained PATs cannot contribute to public repos where the user is not a member, cannot be used by outside collaborators, cannot access multiple orgs at once, and cannot call the Checks API. The docs note these so users know when to fall back to the (broader) global token.
- msb backend:
msbbinds thegithubsecret to the REST API and git-transport hosts (msb.sh; see ADR-0011). A static re-verification against msb 0.6.9 found the substitution engine rewrites theAuthorization: Basicheader git smart-HTTP uses, and a livescripts/verify-git-https-secret-msbrun against a private repo confirmedgit ls-remotesucceeds using only the guest placeholder. A scoped token is therefore injected for both REST and HTTPS git transport without the real value entering the guest, so the same least-privilege scoping this ADR describes applies unchanged. - Deprecation, not removal: the global path keeps working, so existing setups are not broken; new guidance steers to per-sandbox scoping.
- Audit (AU-2): scoping is per-sandbox and named, so which credential a sandbox holds is discoverable via the acq secret store keys.
Validation
- Offline unit coverage in
scripts/test-acq: remote-URL →owner/repoparsing (https + ssh +.gitsuffix), pre-filled-URL construction, multi-owner handling, and that the advisory fires exactly when (workspace has repos) ∧ (no sandbox-scoped github secret) — independent of whether a global secret exists. - Live end-to-end (minting a real PAT, injecting it, and confirming a scoped push succeeds while an out-of-scope repo is denied) is a manual verification step recorded in the PR, since it requires a real GitHub account and browser.
Links
- Exploration:
docs/explorations/downscoping-github-credentials-for-local-agents.md - Related: ADR-0001 (SBX isolation is the complementary boundary), ADR-0005 (github token needed for the private playbook clone), ADR-0011 (msb github-secret binding + git-HTTPS substitution eligibility), ADR-0012 (backend-neutral secret handling)
- GitHub docs: "Managing your personal access tokens" (fine-grained PAT URL pre-fill parameters), "Create a scoped access token" (requires client secret), "Create an installation access token for an app".