From e4f3a076e42ad0593f57544097d9bd384c13b5db Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 11:16:04 -0400 Subject: [PATCH 1/3] docs(product): design server-side forge-write scope enforcement (RIG-2679 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred A8 forge-write scope control: a server-side per-account allowlist (account_forge_scopes table) checked by requireForgeScope in every write arm of ExecuteForgeCallAsAccount — after coordinate resolution, on create arms after the F3 idempotency-memo check, before any provider call — rejecting out-of-scope targets as the in-band ForgeCallError{code:"not_found"}, byte-identical to the 403/404 flatten (no probe oracle). Gated by ForgeConfig.EnforceScopes: off for Dogfood, mandatory for Beta. design-critic pass folded (6 findings). OQ-1 (fail-open vs fail-closed default) deferred to Matt at freeze. Ledger row proposed (Ledger-impact declared; applied at freeze), next free id DL-242. Status: Draft. Ledger-impact: deferred to freeze (adds one Comms & tools row; amends DL-200's inherited no-scope-rejection A8 clause without superseding it). --- .../compass-forge-scope-enforcement/design.md | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 docs/designs/product/compass-forge-scope-enforcement/design.md diff --git a/docs/designs/product/compass-forge-scope-enforcement/design.md b/docs/designs/product/compass-forge-scope-enforcement/design.md new file mode 100644 index 00000000..4c411817 --- /dev/null +++ b/docs/designs/product/compass-forge-scope-enforcement/design.md @@ -0,0 +1,491 @@ +# Design: Server-side forge-write scope enforcement (A8, Beta tier) + +Status: Draft +Owner lane: compass-server. Refs: RIG-2679 (this record), RIG-2672 +(multi-forge widened the blast radius), RIG-2682 (account model — this record +is deliberately independent of its outcome). + +## Problem / Intent + +The forge-write chokepoint ships no server-side scope rejection. The trust +model at the seam says so explicitly: + +> Per Resolved decision 2 (MVP, single-trust-domain) the caller is recorded +> for attribution but NO scope rejection ships (A8). +> — `go/server/forge.go:16-18` + +The frozen forge-write-path record pinned the same posture: + +> Authz posture (A8): inherited from the board path — "MVP scope ships no +> scope rejection (single-trust-domain, Resolved decision 2)" +> (relay_board.go:37-38); no per-op scope check in v1. +> — `docs/designs/product/compass-forge-write-path/design.md:697-699` + +Meanwhile the credential key deliberately excludes `repo`: + +> forgeCoordinate is the registry key: the wire forge enum + host. A repo does +> NOT enter the key — one credential pair serves every repo on a coordinate +> (DL-091 multi-forge disambiguation is provider+host). +> — `go/server/forge.go:46-48` + +So one shared credential pair serves **every** repo the token can reach, and +for Linear `repo` is a **team key** — "`repo` is the Linear TEAM KEY (e.g. +"SEA"), not owner/name" (`go/internal/forge/linear.go:11`) — so the RIG-2672 +multi-forge coordinate (`buildForgeWriteService` registers a Linear coordinate +beside GitHub whenever `LINEAR_FORGE_TOKEN` is declared, +`go/server/serve.go:965-970`) doubled the blast radius: a hallucinated or +prompt-injected `repo` string in a `ForgeCallRequest` writes into **any GitHub +repo and any Linear team the shared credential reaches**, attributed but never +rejected. + +Matt ruled server-side scope enforcement **MANDATORY and UNCONDITIONAL for the +Beta tier**, regardless of the RIG-2682 account-model outcome. The Dogfood +tier still defers it (single trust domain — one operator owns every agent and +every credential). This record designs the Beta gate and its Dogfood off +switch. It is server-authz work only: the TS tool leg already sends `repo` and +is not reworked. + +## Global Constraints + +- Go, `go/` module; the chokepoint is `package server` (`go/server/forge.go`). +- Rejection is **in-band**, never a Connect error: a tool-level refusal rides + the `ForgeCallResult_Error` arm the agent renders — "ONLY a malformed + request (an unset oneof arm) or a missing caller resolution is a Connect + error" (`go/server/forge.go:22-24`). The helpers exist: + `forgeErr(code connect.Code, msg string)` (`go/server/forge.go:655-657`) and + `forgeErrorResult(fe)` (`go/server/forge.go:662-664`). +- The not-found/forbidden **merge** is house style: an unauthorized target is + indistinguishable from a nonexistent one, "so a probe enumerates nothing" + (`go/internal/store/authz.go:13-15`); the forge error mapper already + flattens provider 403 ≡ 404 to a byte-identical `not_found` + (`go/server/forge.go:602-604`). +- Store access from the chokepoint goes through the **narrow `forgeStore` + interface** (`go/server/forge.go:140-144`) so the ordering is provable + against `fakeForgeStore` in the default test lane + (`go/server/forge_test.go:48-51`), with pgtest proving the real backend + (DL-174 differential-oracle pyramid). +- Migrations: additive SQL in `go/internal/store/migrations/`; text ids, FK + `ON DELETE RESTRICT`, coordinate columns aligned to the 0013 convention + (SMALLINT provider CHECK `IN (1,2,3,4)` + `forge_host` in every key, + `0001_init.sql:604-608`). +- Ledger: this record proposes its DL row below; the driver assembles the + final id into `DECISIONS.md` at PR-assembly time. Do not edit `DECISIONS.md` + from this record. +- Red → green: every task lands its failing test first. + +## Approach + +One sentence: a per-account **forge-scope allowlist table** consulted by a new +`requireForgeScope` step in every **write** arm of +`ExecuteForgeCallAsAccount`, after coordinate resolution (and, on the create +arms, after the F3 idempotency-memo check — a memo hit writes nothing) and +before any provider call, rejecting an out-of-scope `(provider, host, repo)` +as an in-band `ForgeCallError{code:"not_found"}` — the exact mirror of comms +channel-membership write authz — gated on by a `ForgeConfig` enforcement +flag Beta deployments set (the flag's default direction is OQ-1) and Dogfood +leaves off. + +### The mirror pattern (comms channel membership) + +Comms authorizes every channel write through one store-side primitive: + +> requireChannelMember is the D9 write-authorization primitive: it verifies +> the actor is a member of channelID and returns ErrNotFound if not. +> — `go/internal/store/authz.go:8-10` + +```go +if err := requireChannelMember(ctx, tx, m.AuthorAccountID, ChannelID(channelID)); err != nil { + return Message{}, false, err +} +``` + +— `go/internal/store/messages.go:57-59`. The refusal is `ErrNotFound` +("channel %q", `authz.go:32`), never a distinct forbidden. Forge scope +enforcement is the same shape with the membership row replaced by a scope row +and the tx-querier replaced by the pool (the forge chokepoint holds no store +tx; its writes are single statements). + +One more comms precedent this design leans on for the grant model: + +> the actor is authorized when it owns the group, when it is an agent whose +> owning user owns the group (an agent acts within its owner's space — Matt's +> ruling) … +> — `go/internal/store/authz.go:80-82` + +### A1 — storage: a new `account_forge_scopes` table + +Neither existing table fits. `forge_repo_subscriptions` is the **board poll +target set**, deployment-global with no account column +(`0001_init.sql:616-624`) — reusing it would conflate "what the board ingests" +with "what an account may write", and disabling a poll target would silently +revoke write scope. `agent_forge_subscriptions` is per-**artifact** +notification state (`0001_init.sql:629-641`), not a repo grant. So: a new +table, coordinate-aligned to the 0013 convention: + +```sql +-- RIG-2679 (A8): per-account forge write scope. A row grants account_id the +-- right to write into (forge_provider, forge_host, repo); repo is the Linear +-- team KEY on LINEAR rows. repo = '*' grants the whole coordinate. Grants +-- attach to the OWNING USER account: the chokepoint checks agent-or-owner, +-- so one grant covers a user's whole agent fleet (an agent acts within its +-- owner's space — the requireGroupCreateAuthz precedent, authz.go:80-82); +-- keying on account_id (not user_accounts) keeps a future per-agent narrow +-- additive. GITHUB repo lowercased at the store door (the +-- forge_repo_subscriptions convention, 0001_init.sql:612-614). +CREATE TABLE account_forge_scopes ( + account_id TEXT NOT NULL REFERENCES accounts (id) ON DELETE RESTRICT, + forge_provider SMALLINT NOT NULL CHECK (forge_provider IN (1, 2, 3, 4)), + forge_host TEXT NOT NULL, + repo TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, forge_provider, forge_host, repo) +); +``` + +The check is one EXISTS over `(agent OR its owner) × (exact repo OR '*')`: + +```sql +SELECT EXISTS ( + SELECT 1 FROM account_forge_scopes s + WHERE s.forge_provider = $2 AND s.forge_host = $3 + AND s.repo IN ($4, '*') + AND s.account_id IN ( + $1, + (SELECT owner_user_id FROM agent_accounts WHERE account_id = $1))) +``` + +`agent_accounts.owner_user_id` is NOT NULL (`0001_init.sql:76`), and the +chokepoint already resolves the same edge for attribution +(`resolveIdentity`, `go/server/forge.go:237`). + +Grant and check MUST agree on case. The grant door lowercases GITHUB repos +(the `forge_repo_subscriptions` convention: "For GITHUB the repo string is +lowercased at the seed/upsert boundary", `0001_init.sql:612-614`) and +preserves the Linear team key verbatim (`repo` is the Linear TEAM KEY, e.g. +"SEA" — `go/internal/forge/linear.go:11` — never case-folded anywhere in the +store). `HasForgeScope` therefore applies the IDENTICAL provider-aware fold +to the incoming query `repo` before the EXISTS — GITHUB lowercased, LINEAR +preserved — so a mixed-case injected `repo` can neither slip past a +lowercased grant (fail-open) nor a correctly-granted caller miss its own +grant (inconsistently fail-closed). + +### A2 — population: declarative seed + owner grant, no console clicks + +Two paths, both agent/IaC-friendly (rule no-human-clicks): + +1. **Boot seed (MVP, required):** `ForgeConfig` grows + `ScopeGrants []string` of `handle=provider:host/repo` entries (repo `*` + allowed), reconciled at serve assembly exactly as `SeedRepos` reconciles + into `forge_repo_subscriptions` — "bootstrap-only insert, ON CONFLICT DO + NOTHING" (`go/server/serve.go:1006-1008`, + `go/internal/store/forge_cursors.go:136-148` is the pattern). The + deployment's scope set lives in config, deployed by merge to main. +2. **Owner grant RPC (same slice, small):** `GrantForgeScope` / + `RevokeForgeScope` store methods, exposed later on the admin surface; in + this slice they exist for the seed reconciler, SQL-parity operators, and + tests (the `SetForgeRepoSubscriptionEnabled` posture, + `forge_cursors.go:193-196`). Agents never self-grant — a self-declarable + allowlist is no allowlist; the granting principal is the owning user (or + deployment config), which is what bounds the injected-`repo` blast radius. + +### A3 — enforcement point and rejection shape + +`ExecuteForgeCallAsAccount` dispatches ten arms (`go/server/forge.go:188-215`). +The five **write** arms (`createIssue`, `createPullRequest`, `commentOnIssue`, +`commentOnPullRequest`, `submitReview`) each begin with +`resolveTarget(call, repo)` — "It is the first step of every arm" +(`go/server/forge.go:248-250`) — which validates the repo and resolves the +coordinate. The gate's slot differs between the two arm shapes, because only +the create arms carry the F3 idempotency memo: + +- **Create arms** (`createIssue`, `createPullRequest`): `resolveTarget` → + `dedup` → *(memo hit returns the recorded coordinate, zero provider calls, + zero scope check)* → `requireForgeScope` → identity/stamp/write. The F3 + memo lookup (`go/server/forge.go:262-274`; wired at `forge.go:310-313`) + returns an already-created artifact "with ZERO provider calls" + (`forge.go:302-304`) — it performs no write, so it needs no write scope. + Gating BEFORE dedup would break the F3 retry contract: a create committed + while enforcement was off (Dogfood), retried after a Dogfood→Beta flip + whose grants never seeded that repo, would reject even though the artifact + already exists and the memo hit would have returned it writing nothing. +- **Comment/review arms** (`commentOnIssue`, `commentOnPullRequest`, + `submitReview`): `resolveTarget` → `requireForgeScope` → + identity/stamp/write. These arms have no dedup step to order against — + "the comment/review arms have no coordinate to record, so they never reach + here (F3 is create-only per the frozen ruling)" + (`go/server/forge.go:276-282`) — so the gate sits immediately after + `resolveTarget`. + +Either way the gate runs before identity resolution, stamping, or any +provider touch: + +```go +// requireForgeScope is the RIG-2679 (A8) write gate: the caller (or its +// owning user) must hold an account_forge_scopes row for the resolved +// coordinate+repo. Out of scope renders as the byte-fixed in-band not_found +// (the authz.go:13-15 merge; byte-identical to the provider-403/404 flatten +// text, forge.go:627-628), so a probe enumerates nothing. Create arms call +// it AFTER the F3 dedup memo check (a memo hit writes nothing, needs no +// scope); comment/review arms directly after resolveTarget. A nil check on +// s.enforceScopes is the Dogfood defer. +func (s *forgeService) requireForgeScope(ctx context.Context, caller store.AccountID, rf resolvedForge, repo string) *compassv1internal.ForgeCallError +``` + +- **In scope / enforcement off** → nil, arm proceeds unchanged. +- **Out of scope** → + `forgeErr(connect.CodeNotFound, "forge: artifact not found")` — + **byte-identical**, as a requirement not a preference, to the text the + provider 403 ≡ 404 flatten already emits (`go/server/forge.go:627-628`; + the flatten contract at `forge.go:602-604`). A prompt-injected probe gets + the SAME bytes for out-of-scope, nonexistent, and forbidden, so message + text is no oracle to distinguish them. (This resolves the draft's former + rejection-text open question in-design. The unconfigured-coordinate + refusal keeps its distinct text, `forge.go:257`: it varies only with + deployment config, never with the probed repo, so it leaks nothing about + targets.) +- **Store fault** → `storeForgeError(err)` (`go/server/forge.go:638-640`), + like every other store touch on the path — fail closed (an error is not a + pass). + +Read arms (`getIssue`, `getPullRequest`, `listIssues`) are NOT gated in this +slice: they carry no `caller` parameter today (`go/server/forge.go:472-485` +`getIssue(ctx, call, req)`), Matt's ruling targets **writes**, and the read +surface leaks only content the shared read credential already exposes to every +agent. Extending the gate to reads is OQ-4. + +`Subscribe`/`Unsubscribe` stay unimplemented in this slice +(`go/server/forge.go:205-212`), and per-arm hand wiring is exactly how a +FUTURE write arm ships ungated: the sixth arm lands and nobody remembers the +gate. The slice therefore adds a **write-arm exhaustiveness test** (default +lane, beside the per-arm cases): it walks the `ForgeCallRequest` `call` +oneof's field descriptors — the same ten arms the dispatch switches over +(`go/server/forge.go:188-215`) — against an explicit in-test classification +map (write / read / unimplemented). An arm missing from the map fails the +test, so a NEW arm cannot land unclassified; and every write-classified arm +is driven with enforcement-on + zero grants, asserting the byte-fixed +`not_found` with zero provider-fake calls, so an UNGATED write arm cannot +land green. A fused resolve-and-gate helper was considered and rejected for +this job — see Alternatives. + +### A4 — the Dogfood/Beta tier switch + +Enforcement is a serve-config bit, not a build variant: + +- `ForgeConfig.EnforceScopes bool` (beside `SeedRepos`/`Poll`, + `go/server/serve.go:108-121`), default **false** = today's Dogfood posture, + zero behavior change for existing deployments — the same all-optional + posture ForgeConfig already documents (`serve.go:105-107`). + Whether default-false survives freeze is **OQ-1 (load-bearing, deferred to + Matt)**: on a Beta deployment an unset flag fails OPEN — enforcement + silently off on the exact tier where it is mandatory. +- `buildForgeWriteService` (`go/server/serve.go:929-972`) threads it into + `forgeService` (a new `enforceScopes bool` field beside `now`, + `go/server/forge.go:151-156`). +- When `EnforceScopes` is true and `ScopeGrants` is empty and the table is + empty, startup logs a Warn (the `warnPartialForgeWriteSecrets` posture, + `serve.go:899`): enforcement-on with zero grants means every write rejects, + which is fail-closed and legal but probably an operator mistake. +- The Beta deployment profile sets `EnforceScopes: true`; there is no code + fork between tiers, only config. + +### Alternatives considered + +- **Prompt-level-only (status quo A8).** Rejected for Beta by ruling: the + tool prompt's capability matrix is advice to a model, not authz; a + hallucinated/injected `repo` sails through (`forge.go:16-18` records + attribution only). +- **Repo in the credential key** (per-repo credentials in + `forgeProviderRegistry`). Rejected: reverses DL-091's provider+host key + (`forge.go:46-48`), multiplies secrets per repo, and still needs an + account→credential map — strictly more moving parts than a scope row. +- **Reuse `forge_repo_subscriptions` as the allowlist.** Rejected: it is the + board's poll target set, per-deployment not per-account + (`0001_init.sql:610-624`); coupling ingestion targets to write authz makes + "stop polling a repo" silently mean "revoke writes", and gives every + account identical scope — no blast-radius reduction between agents of + different owners. +- **Per-agent-only grants (no owner inheritance).** Deferred, not rejected: + the schema (keyed on bare `account_id`) admits it additively; MVP checks + agent-or-owner because grants-per-owner match the standing "an agent acts + within its owner's space" ruling (`authz.go:80-82`) and keep the grant set + administrable. OQ-3. +- **A fused `resolveWriteTarget` helper (resolveTarget + scope gate as one + call every write arm must use).** Rejected as the anti-bypass choke: the + create arms gate AFTER the F3 dedup while the comment/review arms gate + right after `resolveTarget` (§A3), so one fused call cannot sit in one + place — it would need two shapes or a mode flag, which is the per-arm + wiring problem wearing a helper's name. The file also already prefers the + explicit per-arm parallel over extracted helpers on these very arms ("a + closure-extracted helper reads worse than the explicit parallel", + `go/server/forge.go:378`). The bypass risk is carried by the §A3 write-arm + exhaustiveness test instead, which catches an ungated or unclassified new + arm at the oneof-descriptor level. +- **Connect `PermissionDenied` instead of in-band.** Rejected: violates the + frozen in-band/Connect split (`forge.go:20-27`) and un-merges + forbidden-from-not-found, giving an injected prompt a probe oracle. + +## Plan + +### T1 `[compass-server]` — store: `account_forge_scopes` table + scope check + +Migration (new `000N_account_forge_scopes.sql`, next free number) with the A1 +DDL. Store surface in a new `go/internal/store/forge_scopes.go`: + +Interfaces: + +```go +// ForgeScope is one write-scope grant row. +type ForgeScope struct { + AccountID AccountID + Provider ForgeProvider + Host string + Repo string // "*" grants the whole coordinate +} + +// GrantForgeScope inserts idempotently (ON CONFLICT DO NOTHING); GITHUB repo +// lowercased; zero/empty fields -> ErrInvalidArgument. +func (s *Store) GrantForgeScope(ctx context.Context, g ForgeScope) error + +// RevokeForgeScope deletes one grant; unknown row -> ErrNotFound. +func (s *Store) RevokeForgeScope(ctx context.Context, g ForgeScope) error + +// HasForgeScope reports whether account (or, for an agent, its owning user) +// holds a grant for (provider, host, repo) — exact repo or '*'. repo is +// normalized with the SAME provider-aware fold GrantForgeScope applies +// (GITHUB lowercased, LINEAR team key preserved) before comparison, so +// grant and check always agree on case (§A1). +func (s *Store) HasForgeScope(ctx context.Context, account AccountID, provider ForgeProvider, host, repo string) (bool, error) +``` + +Tests: pgtest suite (grant/revoke idempotency, agent-inherits-owner, `'*'` +wildcard, case fold on BOTH sides — a mixed-case GITHUB query repo matches a +lowercased grant, a LINEAR team key matches verbatim — FK RESTRICT) mirroring +`forge_cursors_pgtest_test.go`'s shape. + +### T2 `[compass-server]` — chokepoint: `requireForgeScope` in the write arms + +Interfaces: + +```go +// forgeStore (go/server/forge.go:140-144) gains: +HasForgeScope(ctx context.Context, account store.AccountID, provider store.ForgeProvider, host, repo string) (bool, error) + +// forgeService (forge.go:151-156) gains: enforceScopes bool +// newForgeService (forge.go:163-165) gains the flag: +func newForgeService(st *store.Store, issueBrd *board.IssueProjection, providers *forgeProviderRegistry, enforceScopes bool) *forgeService + +func (s *forgeService) requireForgeScope(ctx context.Context, caller store.AccountID, rf resolvedForge, repo string) *compassv1internal.ForgeCallError +``` + +Wire `requireForgeScope` per the §A3 asymmetry: in the create arms AFTER the +F3 dedup memo check (`createIssue` `forge.go:302-334`, dedup at `:310-313`; +`createPullRequest` `:336-373`, dedup at `:343-347`) so a memo hit still +returns the recorded coordinate writing nothing; in the comment/review arms +directly after `resolveTarget` (`commentOnIssue` `:375-398`, +`commentOnPullRequest` `:400-421`, `submitReview` `:423-467` — no dedup to +order against, F3 is create-only, `forge.go:276-282`). Update the +`forge.go:13-18` header comment: the A8 posture line becomes "scope +enforcement per RIG-2679, gated by enforceScopes". + +Tests (default lane, red first): extend `fakeForgeStore` +(`forge_test.go:48-51`) with a scope set; per write arm assert (a) +enforcement-off passes with zero scope rows, (b) enforcement-on + +out-of-scope rejects with the byte-fixed in-band `not_found` (byte-identical +to the 403 ≡ 404 flatten text, §A3) and the provider fake records **zero +calls** and no DL-055 row lands, (c) enforcement-on + exact-repo and `'*'` +grants pass, (d) store fault maps via `storeForgeError`, (e) read arms +unaffected, (f) a create whose `client_request_id` has a memo hit returns +the recorded coordinate with enforcement ON and ZERO grants (the F3 retry +contract, §A3). Plus the §A3 write-arm exhaustiveness test over the +`ForgeCallRequest` oneof descriptors (an unclassified or ungated new arm +turns it red). E2E: one whole-wire case in `forge_e2e_pgtest_test.go` over +the `newForgeE2EWire` scaffold (`forge_e2e_pgtest_test.go:84-115`) proving +the rejection shape end to end against real Postgres. + +### T3 `[compass-server]` — serve assembly: flag, seed, warn + +Interfaces: + +```go +// ForgeConfig (serve.go:108) gains: +// EnforceScopes bool // Beta: true; absent/false = Dogfood defer +// ScopeGrants []string // "handle=provider:host/repo", repo may be "*" +// buildForgeWriteService (serve.go:929) passes cfg.Forge.EnforceScopes to +// newForgeService and reconciles ScopeGrants before returning: +func reconcileForgeScopeSeed(ctx context.Context, st *store.Store, grants []string) error +``` + +Seed semantics mirror `reconcileForgeSeed` (`serve.go:1006-1018`): +bootstrap-only `GrantForgeScope` per entry, handle resolved to `account_id` +via the store, bad entry fails startup. Warn on enforcement-on + empty grant +set (A4). CLI flags/env plumbed wherever `SeedRepos`/`Poll` already are. + +Tests: config-parse + seed-reconcile unit tests beside `serve_forge_test.go`; +a pgtest reconcile case beside `serve_forge_pgtest_test.go:436-459`'s pattern. + +### Tasks + +- [ ] T1 — `account_forge_scopes` migration + `Grant/Revoke/HasForgeScope` + store methods + pgtest suite. +- [ ] T2 — `requireForgeScope` gate in the five write arms (post-dedup on + creates) + `forgeStore` extension + fake + default-lane, exhaustiveness, + and e2e tests + header-comment update. +- [ ] T3 — `ForgeConfig.EnforceScopes`/`ScopeGrants` + seed reconcile + warn + + assembly wiring + tests. + +## Ledger delta + +Proposed row (id assigned by the driver at PR assembly; true max observed on +this base is DL-241, `docs/designs/DECISIONS.md`, so this takes +the next free id ≥ DL-242), Comms & tools section: + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-24x | Forge-write scope enforcement (the deferred A8) is a server-side per-account allowlist: a new `account_forge_scopes` table (agent-or-owning-user grant, exact repo or `'*'` per coordinate, provider-aware case fold applied identically at grant and check, seeded declaratively via `ForgeConfig.ScopeGrants` + owner grant methods, never agent-self-granted) checked by `requireForgeScope` in every write arm of `ExecuteForgeCallAsAccount` — after coordinate resolution, and on the create arms after the F3 idempotency-memo check so a memo-hit retry (which writes nothing) is never rejected — before any provider call, rejecting out-of-scope targets as the in-band `ForgeCallError{code:"not_found"}` byte-identical to the provider-403/404 flatten text (the comms not-found/forbidden merge; never a Connect error), guarded against future ungated arms by a write-arm exhaustiveness test over the oneof descriptors, gated by `ForgeConfig.EnforceScopes` — false for Dogfood (single trust domain, today's posture preserved), MANDATORY true for Beta regardless of the RIG-2682 account model; the flag's DEFAULT direction (fail-open vs fail-closed) is OQ-1, deferred to Matt | Proposed | [forge scope enforcement §Approach](product/compass-forge-scope-enforcement/design.md#approach) | + +Ledger-impact: adds one row (Comms & tools); amends DL-200's inherited +no-scope-rejection A8 clause without superseding DL-200 (the ForgeCaller seam +shape stands); edits no existing row. + +## Open Questions + +- **OQ-1 (load-bearing, DEFERRED TO MATT): enforcement default — fail open + or fail closed?** §A4 drafts `EnforceScopes` default **false**, so on a + Beta deployment a misconfiguration (the flag simply unset) fails OPEN: + scope enforcement silently off on the exact tier where Matt ruled it + mandatory and unconditional. (a) Keep default-false: zero behavior change + for every existing deployment (the all-optional ForgeConfig posture, + `go/server/serve.go:105-107`), but Beta safety hangs on one remembered + config bit. (b) Default fail-CLOSED with an explicit + `DisableScopeEnforcement` Dogfood opt-out: Beta-safe by default, but every + existing deployment must set the opt-out at upgrade or every forge write + starts rejecting. **Author's lean, explicitly NOT a decision:** (b) — a + security control whose zero value means "off" invites exactly the + silent-open misconfig the ruling exists to prevent, and the cost is one + config line per Dogfood deployment versus a silent authz hole on Beta. + Matt rules at freeze; this record does not resolve it. +- **OQ-2 (load-bearing): grant surface for Beta operators.** MVP ships the + declarative config seed + store methods only — no public RPC. Is that + enough for Beta, or does Beta need a `GrantForgeScope` admin RPC/tool at + launch? **Recommendation:** config-seed-only for this slice (no-human-clicks + is satisfied by config-as-code; an RPC is additive later); file the RPC as a + follow-up issue. +- **OQ-3 (load-bearing): grant granularity.** Designed: grants attach to the + owning user and cover the whole fleet (agent-or-owner check), per the + standing "an agent acts within its owner's space" ruling + (`go/internal/store/authz.go:80-82`); schema admits per-agent rows + additively. Confirm Matt wants owner-level MVP rather than + per-agent-required. **Recommendation:** owner-level MVP. +- **OQ-4 (non-load-bearing): read arms.** Reads stay ungated this slice + (their signatures carry no caller, `go/server/forge.go:472-485`, and the + ruling targets writes). **Recommendation:** accept; file a follow-up for + read-side scope parity when tracked-read privacy matters (multi-tenant). +- **OQ-5 (non-load-bearing): wildcard grammar.** `repo = '*'` grants a whole + coordinate; no owner-prefix wildcards (`owner/*`) in MVP. + **Recommendation:** accept — prefix wildcards are additive + (`repo LIKE` variant) and unneeded at Beta's grant volume. + +The draft's former rejection-text question (whether the refusal message may +differ from existing not_found texts) is resolved in-design, not open: the +out-of-scope refusal is byte-identical to the provider-403/404 flatten text +`"forge: artifact not found"` (`go/server/forge.go:627-628`) — see §A3. From 7fb2897456ebdf32e801660df32e223437c78d18 Mon Sep 17 00:00:00 2001 From: mintaka Date: Tue, 25 Aug 2026 11:47:17 -0400 Subject: [PATCH 2/3] docs(product): fold ReviewPR601 lows into forge-scope record (RIG-2679 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review PR #601 (2 low, advisory on a design record): - L1: reword the Ledger-impact amend attribution — the A8 no-scope posture lives in DL-200's implementing comment (forge.go:16-18) tracing to the board 'Resolved decision 2' ruling, not in the DL-200 row text. - L2: name the exhaustiveness test's residual (a mis-classified write arm) and add the T2 signature cross-check (read/unimplemented sets hold only no-caller handlers) that closes it structurally, in §A3 and T2. Ledger-impact: deferred to freeze --- .../compass-forge-scope-enforcement/design.md | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/docs/designs/product/compass-forge-scope-enforcement/design.md b/docs/designs/product/compass-forge-scope-enforcement/design.md index 4c411817..8d6f58cd 100644 --- a/docs/designs/product/compass-forge-scope-enforcement/design.md +++ b/docs/designs/product/compass-forge-scope-enforcement/design.md @@ -267,6 +267,16 @@ is driven with enforcement-on + zero grants, asserting the byte-fixed land green. A fused resolve-and-gate helper was considered and rejected for this job — see Alternatives. +The descriptor walk closes the *unclassified*-arm gap, not the +*mis*-classified one: a future genuinely-write arm added AND deliberately +entered in the `read`/`unimplemented` set slips the driven-enforcement leg. T2 +hardens this structurally rather than by convention — it asserts the +`read`/`unimplemented` sets contain only handlers whose signature takes no +caller (the read arms carry no caller param, `go/server/forge.go:472-485`), so +a write handler (which does) mis-filed as a read reddens the signature +cross-check. That reduces the residual to a write arm that both takes no caller +AND writes — a shape the codebase does not have. + ### A4 — the Dogfood/Beta tier switch Enforcement is a serve-config bit, not a build variant: @@ -398,7 +408,10 @@ unaffected, (f) a create whose `client_request_id` has a memo hit returns the recorded coordinate with enforcement ON and ZERO grants (the F3 retry contract, §A3). Plus the §A3 write-arm exhaustiveness test over the `ForgeCallRequest` oneof descriptors (an unclassified or ungated new arm -turns it red). E2E: one whole-wire case in `forge_e2e_pgtest_test.go` over +turns it red), paired with the §A3 signature cross-check that the +`read`/`unimplemented` classification sets hold only no-caller handlers, so a +write handler mis-filed as a read reddens it too. E2E: one whole-wire case in +`forge_e2e_pgtest_test.go` over the `newForgeE2EWire` scaffold (`forge_e2e_pgtest_test.go:84-115`) proving the rejection shape end to end against real Postgres. @@ -443,9 +456,11 @@ the next free id ≥ DL-242), Comms & tools section: | --- | --- | --- | --- | | DL-24x | Forge-write scope enforcement (the deferred A8) is a server-side per-account allowlist: a new `account_forge_scopes` table (agent-or-owning-user grant, exact repo or `'*'` per coordinate, provider-aware case fold applied identically at grant and check, seeded declaratively via `ForgeConfig.ScopeGrants` + owner grant methods, never agent-self-granted) checked by `requireForgeScope` in every write arm of `ExecuteForgeCallAsAccount` — after coordinate resolution, and on the create arms after the F3 idempotency-memo check so a memo-hit retry (which writes nothing) is never rejected — before any provider call, rejecting out-of-scope targets as the in-band `ForgeCallError{code:"not_found"}` byte-identical to the provider-403/404 flatten text (the comms not-found/forbidden merge; never a Connect error), guarded against future ungated arms by a write-arm exhaustiveness test over the oneof descriptors, gated by `ForgeConfig.EnforceScopes` — false for Dogfood (single trust domain, today's posture preserved), MANDATORY true for Beta regardless of the RIG-2682 account model; the flag's DEFAULT direction (fail-open vs fail-closed) is OQ-1, deferred to Matt | Proposed | [forge scope enforcement §Approach](product/compass-forge-scope-enforcement/design.md#approach) | -Ledger-impact: adds one row (Comms & tools); amends DL-200's inherited -no-scope-rejection A8 clause without superseding DL-200 (the ForgeCaller seam -shape stands); edits no existing row. +Ledger-impact: adds one row (Comms & tools); refines the A8 no-scope posture +DL-200 inherited (`go/server/forge.go:16-18`, tracing to the board-path +"Resolved decision 2" single-trust-domain ruling — the posture lives in the +implementing comment, not the DL-200 row text) without superseding DL-200 (the +ForgeCaller seam shape stands); edits no existing row. ## Open Questions From 40cd38d1fe973d9a2f67158db56734b8fe439d15 Mon Sep 17 00:00:00 2001 From: mintaka Date: Wed, 26 Aug 2026 01:41:50 -0400 Subject: [PATCH 3/3] docs(product): fold git-op scope + critic findings into forge-scope record (RIG-2679 B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds Matt's 2026-08-26 scope expansion (scope the repos an agent can clone/push/pull, not just the forge-API write path) plus a design-critic red-team into the forge-scope-enforcement record. ### Git-operation scope (new A5 + Plan T4/T4.5/T5) Scope the credential, not the git call: mint a GitHub App installation token narrowed via the `repositories` field to the workstream repo + the account's `account_forge_scopes` set, so an out-of-scope `git push`/`clone` fails at GitHub itself — zero enforcement code inside the agent-controlled container. - **Self-clone invariant**: the agent's own workstream repo is always clonable/pullable (else provisioning succeeds but the agent is dead on arrival); push stays write-gated; extra repos need grants; zero/insufficient-grant provision fails LOUD. - **T4.5 (new, proto)**: the minted token must cross the Server→Runner process boundary — `ProvisionAgentWorkspaceRequest` carries no credential today; adds a server-authoritative `WorkspaceCredential` field + a server-driven refresh push on the hub control channel. - **T5 hardened refresh**: pre-expiry margin, retry-with-backoff, keep-old-token-on-failure, atomic tmp+mv rewrite (the `GHHostsScript` shape). ### Critic dispositions folded Loud PAT-fallback Warn (classic vs fine-grained PAT distinction corrected), half-landed cross-check Warn, x-access-token username, deploy-key + fine-grained-PAT Alternatives dismissals. ### New load-bearing forks for the freeze gate OQ-6 gains a fine-grained-PAT option (c) + a #634-sequencing sub-fork (iii, recommend contingent split); OQ-7 added (git-op allowlist = write set + implicit workstream read, vs a distinct read superset). Spec-impact: none (design record only; Ledger-impact declared in-record, applied at freeze). Refs RIG-2679 Co-authored-by: Matt Wilkinson --- .../compass-forge-scope-enforcement/design.md | 589 +++++++++++++++++- 1 file changed, 579 insertions(+), 10 deletions(-) diff --git a/docs/designs/product/compass-forge-scope-enforcement/design.md b/docs/designs/product/compass-forge-scope-enforcement/design.md index 8d6f58cd..19da1b8b 100644 --- a/docs/designs/product/compass-forge-scope-enforcement/design.md +++ b/docs/designs/product/compass-forge-scope-enforcement/design.md @@ -1,9 +1,12 @@ -# Design: Server-side forge-write scope enforcement (A8, Beta tier) +# Design: Forge scope enforcement — API writes + git operations (A8, Beta tier) Status: Draft -Owner lane: compass-server. Refs: RIG-2679 (this record), RIG-2672 -(multi-forge widened the blast radius), RIG-2682 (account model — this record -is deliberately independent of its outcome). +Owner lane: compass-server (the A5 git-operation leg crosses into +compass-runner at the credential-provision seam — flagged per task). Refs: +RIG-2679 (this record), RIG-2672 (multi-forge widened the blast radius), +RIG-2682 (account model — this record is deliberately independent of its +outcome), RIG-2732 / PR #634 (GitHub App as THE credential — the A5 leg +composes with it). ## Problem / Intent @@ -42,8 +45,19 @@ Matt ruled server-side scope enforcement **MANDATORY and UNCONDITIONAL for the Beta tier**, regardless of the RIG-2682 account-model outcome. The Dogfood tier still defers it (single trust domain — one operator owns every agent and every credential). This record designs the Beta gate and its Dogfood off -switch. It is server-authz work only: the TS tool leg already sends `repo` and -is not reworked. +switch. Scope of the record: the **forge-API write chokepoint** (A1-A4) plus +the **git clone/push/pull +surface** (A5) — folding in Matt's 2026-08-26 ruling that scope enforcement +"needs to scope the repos the agent can clone/push/pull too". The +forge-API leg is server-authz work only: the TS tool leg already sends `repo` +and is not reworked. The git-op leg scopes the **credential** the agent's +container is provisioned with, not the git calls themselves — there is no +server-side chokepoint on the git path to gate (the Runner deliberately never +clones for the agent; it self-clones post-launch, +`go/internal/runtime/agent_test.go:162-166`). Git-op scoping is +**GitHub-only**: Linear has no git surface at all — its `repo` is the team +key (`go/internal/forge/linear.go:10-12`) and there is nothing to clone, +push, or pull. ## Global Constraints @@ -72,6 +86,11 @@ is not reworked. final id into `DECISIONS.md` at PR-assembly time. Do not edit `DECISIONS.md` from this record. - Red → green: every task lands its failing test first. +- Cross-lane seam (A5 only): the narrowed-token mint consumes the + `account_forge_scopes` allowlist (compass-server) but the credential is + provisioned by the Runner (compass-runner, + `go/internal/runtime/workspace.go:72-110`); T4/T4.5/T5 name the owner of + each half explicitly so neither lane assumes the other ships it. ## Approach @@ -295,9 +314,202 @@ Enforcement is a serve-config bit, not a build variant: empty, startup logs a Warn (the `warnPartialForgeWriteSecrets` posture, `serve.go:899`): enforcement-on with zero grants means every write rejects, which is fail-closed and legal but probably an operator mistake. +- When `EnforceScopes` is true and NO GitHub App config is present (the + PAT-only posture), startup logs a second loud Warn (the same + `warnPartialForgeWriteSecrets` posture, `go/server/serve.go:899`, the + helper at `serve.go:1048-1055`): the forge-API leg enforces but the + git-op leg (A5) is UNENFORCED — the container credential is a PAT that + reaches whatever the PAT reaches. Matt ruled scope mandatory and + unconditional for Beta, so Beta-on-PAT must never be silent. The Warn is + the drafted behavior; whether it should hard-fail startup instead is + Matt's call at the OQ-6(ii) fork. +- Half-landed cross-check (the inverse window): when `EnforceScopes` is + true AND App config IS present, a provision that goes out WITHOUT a + narrowed credential (a static or absent `CredentialSource`, T5) logs a + Warn naming the unenforced git leg — the same silent-half-landed shape + OQ-1 guards on the flag itself. T4 carries the check. - The Beta deployment profile sets `EnforceScopes: true`; there is no code fork between tiers, only config. +### A5 — git-operation scope: scope the credential, not the git call + +**The gap.** A1-A4 gate the forge-API write path only. Agent git +clone/push/pull runs INSIDE the agent's rootless-podman container, +authenticated by a git `store` credential helper the Runner seeds at +provision time: `Workspace.CredentialSetupScript()` writes +`credential.helper "store --file=$h/.git-credentials"` into the agent's +`$HOME/.gitconfig` and a 0600 `$HOME/.git-credentials` line of the shape +`https://:@` (`go/internal/runtime/workspace.go:72-110`; +the `Credentials{Host, Username, Token}` struct at `workspace.go:20-31`). +That credential is host-wide: one token line serves **every repo on the +host** the token itself can reach. So an agent whose forge-API writes to +repo X reject under A3 can still `git push` to repo X — the exact gap Matt +flagged. There is no server chokepoint to extend: the Runner never clones +for the agent ("launch must not run a git clone", +`go/internal/runtime/agent_test.go:164-165`), and routing git traffic +through one would be a new proxy (rejected — see Alternatives). + +**The mechanism.** Make the credential itself carry the scope: when GitHub +App config is present (the RIG-2732 / PR #634 posture — the App is THE +credential for read+write, per Matt's W1 ruling, canonical record RIG-2732 +Linear comment f5b5b05c), the token seeded into `$HOME/.git-credentials` is +a **GitHub App installation access token minted narrowed to exactly the +agent's workstream repo plus the account's `account_forge_scopes` GitHub +repo set** (the self-clone invariant, next paragraph). One allowlist plus +one invariant, two enforcement points: the A3 server chokepoint rejects +out-of-scope forge-API writes; the scope-narrowed credential makes an +out-of-scope `git push`/`git clone` fail at GitHub itself (404 for a +private repo outside the token's repo set, the desirable not-found shape +that aligns with the A3 not_found merge; 403 for an in-scope repo the +token's permissions do not cover) — no +in-container enforcement code, nothing the agent can tamper with from +inside its own container. + +**The self-clone invariant (read vs write).** `account_forge_scopes` is a +WRITE allowlist, but a git credential also gates CLONE — a read. The +load-bearing correctness constraint: the agent MUST be able to clone/pull +its own workstream (spawn-target) repo, or provisioning succeeds and the +agent is dead on arrival — the Runner never clones for it ("launch must not +run a git clone", `go/internal/runtime/agent_test.go:164-165`); the +provision contract gives the container a git credential and lets "the agent +self-clone whatever it needs after launch". A5 therefore REQUIRES: the +agent's own workstream repo is always in git-op scope (clonable/pullable), +independent of the write allowlist; push stays write-gated — by the +installation token's `permissions` narrowing object and, on the forge-API +path, the A3 chokepoint; any repo beyond the workstream repo enters the +narrowed credential only via an `account_forge_scopes` grant. + +**Unmet precondition — the server does not know the workstream repo.** +Minting a token narrowed to "the workstream repo" needs that repo as a +server-side input, and it is not one today: repo carriage was deliberately +REMOVED from provision (SEA-1527, Matt 2026-07-29 — "spawn/provision no +longer clone a repo for the agent ... the agent self-clones whatever it +needs after launch", `proto/compass/v1/compass.proto:568-571`). +`ProvisionAgentWorkspaceRequest` carries no repo (only `agent_account_id`, +`client_request_id`, `persona`, `role`, same lines), `agent_accounts` has +no repo column (`go/internal/store/migrations/0001_init.sql:74-80`), and +neither `StartAgentSessionRequest` nor `SpawnAgentRequest` carries one. So +the whole invariant — and the T4 mint's `workstreamRepo` argument — rests +on an input that must first be re-introduced (a provision/spawn field, an +`agent_accounts` column, or a store-side spawn-target record), which +REVERSES SEA-1527 and is Matt's call: **OQ-8**. Until OQ-8 resolves, +T4/T4.5/T5 are blocked on it, not only on #634's App landing. Zero or +insufficient grants at provision — the workstream repo cannot be put in +scope, e.g. it sits outside the App installation's own repo grant — FAILS +THE PROVISION LOUD: reject at provision time, never silently provision a +credential-less or wrong-scoped container (T4's mint error and T5's +fail-on-source-error agree on fail-loud). + +**Read/write asymmetry — two consequences, both Matt's.** (1) Whether the +git-op read set is exactly "workstream repo + write set" or a distinct read +column is OQ-7. Narrowing clone to that set is a real REGRESSION against +SEA-1527's "self-clones whatever it needs": under the host-wide token today +an agent can clone any read-only dependency repo (a sibling library, a +reference repo) it will never write; under the narrowed token a clone of +any repo that is neither the workstream repo nor write-granted fails at +GitHub. OQ-7 carries that tradeoff as first-class. (2) The asymmetry with +OQ-4, stated honestly: forge-API reads stay ungated this slice while git +clone is scoped — because the git credential is one token gating both +directions, where the API path can gate writes alone. + +**Load-bearing premise (verified against the GitHub REST docs, "Create an +installation access token for an app", 2026-08-26):** +`POST /app/installations/{installation_id}/access_tokens` accepts optional +`repositories` (names) / `repository_ids` body parameters — "the +installation access token cannot be granted access to repositories that the +installation was not granted access to", up to 500 repositories — plus a +`permissions` narrowing object. If this capability did not hold, the +recommendation here would change (one of the heavier Alternatives would be +back on the table); it does hold, and the whole A5 mechanism stands on it. +Two consequences the design must absorb: + +- **Expiry:** installation tokens expire **one hour** after mint (same + docs). The credential file is written once at provision + (`installCredentials`, `go/internal/runtime/agent.go:313-317`, feeds the + script over the container exec channel), so the token MUST be re-minted + and re-applied before expiry. The server owns `expiresAt` (it minted the + token), so refresh is a **server-driven push**: the server re-mints on + margin and pushes the new token to the Runner over the T4.5 wire, and the + Runner re-applies it via the `installCredentials` exec path — with + retry-with-backoff, keep-old-token-on-failure, and an atomic file rewrite + (T5, refresh hardening). A ~1h token is a NEW liveness dependency today's + static PAT does not have; T5 states the availability posture explicitly. + A long-lived agent session sees a rotating token, which is a security + improvement over today's static PAT line, not a regression. The + server-driven re-mint needs per-live-container state the board projection + does not provide (it retains only `{state, account}` per live session with + lifecycle GC deferred, `go/internal/board/projection.go:44-58` — no + `expiresAt`/host/workstreamRepo, so it cannot source credential liveness): + a durable record of `(container_name, account, host, + workstreamRepo, expiresAt)` per live container plus a margin-driven + scheduler that reads it, torn down on container stop. T4.5 owns that + registry (its refresh push has nowhere to key from otherwise). +- **Wildcard grants:** a `repo = '*'` row (A1) means the whole coordinate — + the mint then OMITS the `repositories` field, yielding a token with the + installation's full repo access. Exact-repo grants list exactly those + repos. Repos granted in `account_forge_scopes` but outside the App + installation's own grant simply don't widen the token (GitHub clamps to + the installation), which is fail-closed in the right direction. + +**Seam and lane ownership.** The mint is a **server-side** concern: the App +private key and the `account_forge_scopes` table both live with the server +(the store check is A1's `HasForgeScope` table; the mint needs a sibling +list method, T4). The **credential delivery** is a Runner concern: the +production `configSpecBuilder` builds `runtime.Workspace` with `Credentials` +deliberately unset today, and its package comment reserves exactly this +seam — "the per-agent-account credential and egress derivation that later +tiers add plugs into the same SpecBuilder seam without changing Provision" +(`go/internal/runner/spec.go:6-7`, the builder at `spec.go:86-99`). The +provision flow already routes Client → Server → RunnerHub → Runner +(`proto/compass/v1/compass.proto:53-54`) — but the Server and the Runner +are separate processes, and `ProvisionAgentWorkspaceRequest` carries NO +credential field today (`agent_account_id`, `client_request_id`, `persona`, +`role` — `proto/compass/v1/compass.proto:563-601`; the production spec +builder fills `Workspace` from Runner-local defaults, `spec.go:86-99`). The +minted token therefore needs an explicit WIRE DELTA to cross the process +boundary: T4.5 names it. T4 (server: mint) → T4.5 (server + proto: +transport + refresh push) → T5 (runner: install + refresh application) +split on precisely these lines; all three must land for the leg to +enforce — flagged to compass-server and compass-runner. + +**Sibling credential surface.** The gh-CLI credential +(`GHCredentials{Host, Token}` routed to `~/.config/gh/hosts.yml`, +`go/internal/runtime/secrets_materialize.go:92-97`, populated from +`SecretGH` secrets at `secrets_materialize.go:378-379`) is the same +credential class on a second file. A deployment that seeds a broad PAT +there re-opens the gap A5 closes on the git path. The hosts.yml surface +takes the same narrowed token when the App path is active, and the +PAT-fallback posture (OQ-6) governs it identically — but note it is a +SEPARATE write path (`GHHostsScript` driven from `SecretGH` at +`secrets_materialize.go:378-379`, script at `:209-238`), NOT reachable from +the `installCredentials` `.git-credentials` path. Because they are separate +paths, T5 materializes hosts.yml with the narrowed token at BOTH provision +and refresh (not refresh alone): wiring only `.git-credentials` at provision +would leave hosts.yml holding the static PAT until the first refresh, and a +refresh that rotated only `.git-credentials` would leave it holding an +expired token within the hour — either way breaking every gh-CLI op. T5 +owns both surfaces at both moments. +(The hosts.yml surface needs no username posture line of its own: it +carries a bare `oauth_token` with no username field, +`go/internal/runtime/secrets_materialize.go:219-220`, so an installation +token drops in as-is; the git-credentials line is the surface that needs +the `x-access-token` username — T5.) + +**PAT fallback.** When NO GitHub App is configured (the static-PAT path), +per-account mint-time narrowing is unavailable. Be precise about which PAT: +a **classic** PAT is not repo-narrowable at all; a **fine-grained** PAT IS +repo-narrowable, but only statically at creation time — one fixed repo set +per token, not per-account, not re-derivable from `account_forge_scopes` at +mint. Under PAT-only config the git-op scope is therefore (a) unenforced — +today's posture, credential reaches whatever the PAT reaches — (b) enforced +via one of the heavier rejected mechanisms, or (c) coarsely bounded by a +fine-grained PAT statically scoped at creation to the deployment's +granted-repo union (enforceable without an App, partially honoring +"unconditional", but deployment-wide, not per-account). This record designs +against (a) with a LOUD startup Warn (§A4) so Beta-on-PAT is never +silent — but that is Matt's freeze-gate call, surfaced as **OQ-6(ii)** +together with the mechanism fork itself. + ### Alternatives considered - **Prompt-level-only (status quo A8).** Rejected for Beta by ruling: the @@ -333,6 +545,39 @@ Enforcement is a serve-config bit, not a build variant: - **Connect `PermissionDenied` instead of in-band.** Rejected: violates the frozen in-band/Connect split (`forge.go:20-27`) and un-merges forbidden-from-not-found, giving an injected prompt a probe oracle. +- **A custom git credential-helper binary in the container (git-op leg).** + A helper that consults the allowlist per-repo on every git operation and + refuses out-of-scope remotes. Rejected: it enforces from INSIDE the + container the agent controls — the agent can bypass its own `.gitconfig` + (`git -c credential.helper=…`, or read the raw token if the helper caches + one), so it is advice, not authz, unless the helper also holds the only + credential — at which point it needs a callback channel to the server to + fetch per-repo tokens, i.e. it converges on the A5 narrowed-token mint + with a new binary, a new in-container protocol, and a new attack surface + added on top. Strictly heavier for the same result. +- **A server-side git proxy (git-op leg).** Route all agent git traffic + through a scope-enforcing endpoint (a smart-HTTP proxy fronting the + forge). Rejected: it is a new always-on network service in the data path + of every clone/push/pull — availability, TLS, streaming pack-protocol + passthrough, and egress-policy surgery (agent egress currently allows the + forge host directly, `go/internal/runtime/agent_test.go:129`) — to + re-derive a rejection GitHub already produces natively when the credential + is narrowed. The proxy also still needs the credential downstream, so it + adds a hop without removing the token. Heaviest option, no additional + enforcement over A5. +- **Per-repo SSH deploy keys (git-op leg).** One key pair per repo, seeded + per grant. Rejected: per-repo key sprawl (a key minted, stored, and + rotated per grant row), the agent credential path is HTTPS + (`https://:@`, `go/internal/runtime/workspace.go:92-96`) + not SSH, and deploy keys are per-REPO not per-ACCOUNT — two agents with + different grant sets on one repo would share a key, so the blast-radius + boundary lands in the wrong place. +- **Fine-grained static PAT (git-op leg).** A fine-grained PAT statically + repo-scoped at creation to the deployment's granted-repo union. Not a + full alternative to the installation-token mint — it is deployment-wide + and fixed at creation, so it cannot track per-account grants — but it is + the honest middle option when no App is configured; carried as + OQ-6(ii)(c) rather than dismissed. ## Plan @@ -436,6 +681,236 @@ set (A4). CLI flags/env plumbed wherever `SeedRepos`/`Poll` already are. Tests: config-parse + seed-reconcile unit tests beside `serve_forge_test.go`; a pgtest reconcile case beside `serve_forge_pgtest_test.go:436-459`'s pattern. +### T4 `[compass-server]` — narrowed installation-token mint from the allowlist + +Depends on the RIG-2732 / PR #634 App landing (App id, private key, +installation id in server config — that record owns their shape). Gated on +App config presence: no App, no mint (the PAT fallback posture, OQ-6). + +Interfaces: + +```go +// ListForgeScopeRepos returns the exact-repo grants held by account (or, +// for an agent, its owning user) on (provider, host), and whether a '*' +// wildcard grant exists. Repos come back in the stored (GITHUB-lowercased) +// fold. Empty + no wildcard means zero grants. +func (s *Store) ListForgeScopeRepos(ctx context.Context, account AccountID, provider ForgeProvider, host string) (repos []string, wildcard bool, err error) + +// MintScopedInstallationToken mints a GitHub App installation access token +// narrowed to the union of the agent's workstream repo and the account's +// grant set on host: wildcard -> the repositories field is omitted +// (installation-wide token); otherwise repositories lists exactly +// workstreamRepo plus the granted repos. GitHub's `repositories` field +// takes bare NAMES resolved relative to the installation owner, which is +// only sound when the grant's owner equals the installation owner; a grant +// like `otherorg/thing` would alias to `installationOwner/thing`. The mint +// therefore either restricts App-mint grants to the installation owner or +// uses `repository_ids` (the API also accepts ids) resolved from the stored +// owner/name grants. workstreamRepo is the §A5 self-clone invariant repo — +// ALWAYS in scope so the agent can clone/pull its own workstream repo; push +// stays write-gated by the token's permissions object + the A3 chokepoint. +// Its SOURCE is unresolved (SEA-1527 removed repo carriage from provision): +// this argument is blocked on OQ-8. Zero grants therefore still mint — a +// token narrowed to exactly the workstream repo. The workstream repo +// unreachable (outside the App installation's own grant) or the mint +// failing -> error, and the caller (T5's server-backed CredentialSource) +// FAILS THE PROVISION LOUD — never a silent credential-less or +// wrong-scoped container. +// Returns the token and its GitHub-side expiry (~1h). +func (m *ForgeAppTokenMinter) MintScopedInstallationToken(ctx context.Context, account store.AccountID, host, workstreamRepo string) (token string, expiresAt time.Time, err error) +``` + +Half-landed cross-check (§A4): the server provision path asserts that when +`EnforceScopes` is true and App config is present, every provision carries +a narrowed credential; a provision going out on a static or absent +`CredentialSource` logs a Warn naming the unenforced git leg. + +Tests (red first): unit tests against a fake GitHub token endpoint — (a) +exact grants produce a `repositories` body listing exactly those names plus +the workstream repo, (b) a `'*'` grant omits the field, (c) zero grants +produce a `repositories` body of exactly the workstream repo (the +self-clone floor), (d) the GITHUB-lowercase fold from A1 is what reaches +the request body, (e) a workstream repo the installation cannot grant +errors (fail-loud), (f) the half-landed cross-check Warn fires on a +static-source provision under enforcement-on + App-present, (g) a grant +whose owner differs from the installation owner resolves by id (or is +rejected), never aliased to a same-name repo under the installation owner; +pgtest for +`ListForgeScopeRepos` (agent-inherits-owner, wildcard flag, fold) beside +the T1 suite. + +### T4.5 `[compass-server, proto]` — credential wire delta: provision carry + refresh push + +Server and Runner are separate processes (Client → Server → RunnerHub → +Runner, `proto/compass/v1/compass.proto:53-54`) and +`ProvisionAgentWorkspaceRequest` carries no credential field today +(`agent_account_id`, `client_request_id`, `persona`, `role` — +`proto/compass/v1/compass.proto:563-601`); the production spec builder +fills `Workspace` from Runner-local defaults +(`go/internal/runner/spec.go:86-99`). The minted token needs an explicit +wire path across the boundary: this task owns it. + +Interfaces: + +```proto +// ProvisionAgentWorkspaceRequest gains the minted credential. +// SERVER-AUTHORITATIVE like persona/role (compass.proto:579-588): the +// Server populates it on the provision path and overwrites any +// client-supplied value. token is debug_redact per the IssueToken +// redaction convention (the plaintext "is never logged (debug_redact)", +// compass.proto:126-127; the field convention at compass.proto:718 and +// :191). +message WorkspaceCredential { + string host = 1; + string username = 2; // "x-access-token" for installation tokens (T5) + string token = 3 [debug_redact = true]; + int64 expires_at_unix = 4; // 0 = never (static PAT) +} +// ProvisionAgentWorkspaceRequest: WorkspaceCredential credential = 5; + +// Refresh: a dedicated Server→Runner push on the hub control channel — a +// new RunnerHub-relayed RefreshWorkspaceCredential message keyed by +// container_name, carrying the same WorkspaceCredential. +``` + +Refresh is **server-driven push**, not Runner-pull, and the choice is +load-bearing: the server owns `expiresAt` (it minted the token, T4), owns +the re-mint (the App private key and the `account_forge_scopes` allowlist +are both server-side, §A5), and already holds the Server → RunnerHub → +Runner control path — a push adds one message on an existing channel, +where a Runner-pull would add a new Runner→Server RPC surface plus +per-Runner refresh scheduling against an expiry the Runner only knows +second-hand. The Runner applies a pushed credential via the T5 +`installCredentials` re-exec. + +**Refresh-scheduler state (server-side).** The push needs a source to key +from. T4.5 adds a durable per-live-container registry — +`(container_name, account, host, workstreamRepo, expiresAt)`, written at +provision when the token is minted, evicted on container stop — and a +margin-driven loop (~T-10min before `expiresAt`) that re-mints via T4 and +pushes. It cannot fall out of the board projection +(`go/internal/board/projection.go:44-58`), which retains only +`{state, account}` per live session (lifecycle GC deferred) and carries no +`expiresAt`/host/workstreamRepo; credential liveness needs its own durable +store, owned by compass-server. + +Tests (red first): a server provision-path unit test (the credential field +is populated server-side and overwrites a client-supplied value; redaction +asserted the same way the IssueToken token field's is); a hub-relay test +that a refresh push reaches the Runner keyed by `container_name`; a +scheduler test that a live-container record fires a re-mint+push on margin +and is evicted on container stop. + +### T5 `[compass-runner]` — credential-provision wiring + hardened refresh + +Thread the minted token into the reserved SpecBuilder credential seam +(`go/internal/runner/spec.go:6-7`; `Workspace.Credentials` today unset in +the production builder, `spec.go:86-99`) so the provisioned container's +`$HOME/.git-credentials` line (`workspace.go:92-96`) carries the narrowed +token instead of a static PAT. The credential arrives on the T4.5 wire (the +provision field at provision; the refresh push thereafter). Both the token's +credential surfaces MUST carry the narrowed token from t=0, not only after +the first refresh: at **provision** the Runner materializes BOTH the +`.git-credentials` file (via the `installCredentials` exec path, +`go/internal/runtime/agent.go:313-317`) AND the gh-CLI +`~/.config/gh/hosts.yml` `oauth_token` (via the `GHHostsScript` write, +`go/internal/runtime/secrets_materialize.go:209-238`, :378-379) from the +T4.5 `WorkspaceCredential` — constructing `GHCredentials{Host, Token}` from +it and running `GHHostsScript` alongside `CredentialSetupScript`. Wiring +only `.git-credentials` at provision would leave hosts.yml holding the +static `SecretGH` PAT (or absent) until the first refresh at ~T-10min — a +~50-minute window reopening the exact broad-token gap A5 closes, on the +gh-CLI surface. **Refresh** re-materializes the same two surfaces +atomically with the re-minted token: they are separate write paths +(`installCredentials` never touches hosts.yml), so a refresh that rotated +only `.git-credentials` would leave hosts.yml holding an expired token and +break every gh-CLI op within the hour. Both the provision and refresh +`GHHostsScript` calls MUST pass the FULL current gh-host credential set, not +the single rotated GitHub credential: `GHHostsScript` rewrites the entire +`hosts.yml` in one whole-file `mv` (`secrets_materialize.go:209-238`), so +passing only the rotated credential would clobber any co-resident host block +(e.g. a GitHub Enterprise host also seeded via `SecretGH`). When the set +carries two entries for the SAME host — a static `SecretGH` PAT and the +narrowed installation token for `github.com` under App-active — the merge +MUST let the narrowed token win: `GHHostsScript` collapses same-host +duplicates last-wins (`secrets_materialize.go:200-215`), so the narrowed +token is written last, or the static PAT is dropped from the set before the +write. Otherwise hosts.yml silently re-holds the broad PAT — the exact gap +this wiring closes. + +**Username.** git-over-HTTPS with an installation token authenticates as +username **`x-access-token`**; the seeded line is +`https://:@` built from `Credentials.Username` +(`go/internal/runtime/workspace.go:92-96`), so the server-backed source +returns `Credentials{Username: "x-access-token", …}`. The gh hosts.yml +surface needs no username line: it carries a bare `oauth_token` with no +username field (`go/internal/runtime/secrets_materialize.go:219-220`), so +the same token drops in as-is (§A5 sibling-surface note). + +**Refresh liveness (availability).** A ~1h token is a NEW liveness +dependency: the mint or GitHub's token endpoint down at refresh time kills +every live agent's git within the hour, where today's static PAT never +expires. Posture: (1) refresh with MARGIN — the server re-mints at ~T-10min +before `expiresAt`, never at expiry; (2) retry-with-backoff on mint +failure; (3) the old still-valid token STAYS IN PLACE until a new one +lands — a failed refresh never truncates or clears the credential file. +Accepted residual, stated explicitly: a multi-request git operation +straddling the actual expiry of a token whose refresh is still failing sees +mid-operation 401s — a bounded in-flight-expiry window this design accepts. + +**Refresh atomicity (race).** `CredentialSetupScript` rewrites +`.git-credentials` by truncate-then-write (`cat > "$h/.git-credentials"`, +`go/internal/runtime/workspace.go:106-108`) — a git process reading +mid-rewrite sees a truncated credential and fails auth transiently. The +sibling `GHHostsScript` already solves this shape with tmp-file + +`chmod 600` + atomic `mv` +(`go/internal/runtime/secrets_materialize.go:231-234`). T5 makes the +refresh rewrite atomic the same way — amend `CredentialSetupScript` (write +`$f.tmp.$$`, chmod 600, `mv`), which also hardens the first seed for free. + +Interfaces: + +```go +// CredentialSource resolves the per-account workspace credential at +// provision. The server-backed implementation is fed by the T4.5 wire +// (the minted token + expiry carried on the provision message and pushed +// on refresh); the static implementation wraps a configured PAT (today's +// posture, PAT fallback). A source error FAILS THE PROVISION LOUD — never +// a silent credential-less container (§A5; the mint side agrees, T4). +type CredentialSource interface { + // WorkspaceCredentials returns the credential to seed plus when it + // must be refreshed (zero time = never, the static-PAT case). + WorkspaceCredentials(ctx context.Context, account string) (creds *runtime.Credentials, refreshAt time.Time, err error) +} + +// configSpecBuilder gains the source; BuildSpec populates +// Workspace.Credentials from it. Refresh application: on a T4.5 refresh +// push the Runner re-invokes the installCredentials exec path on the live +// container with the new token; a refresh that fails or never arrives +// past refreshAt retries with backoff and leaves the previous credential +// file untouched. +func NewConfigSpecBuilder(defaults SpecDefaults, creds CredentialSource) (SpecBuilder, error) +``` + +Tests (red first): spec-builder unit tests (credential populated from the +source; static source keeps today's behavior byte-for-byte; source error +fails provision LOUD, never provisions credential-less silently when a +source is configured); a provision test asserting BOTH surfaces carry the +narrowed token at t=0 — the `.git-credentials` line AND the hosts.yml +`oauth_token` — so an App-active provision never leaves hosts.yml on the +static PAT; a refresh test on the fake runtime asserting a second +credential-install exec lands before the deadline and the rewritten +`.git-credentials` carries the new token (the `fakeRuntime` calls-snapshot +pattern, `go/internal/runtime/agent_test.go:154-166`); a failed-refresh +test asserting the old credential file survives byte-for-byte; a script +test asserting the credential rewrite goes through tmp + `chmod 600` + +`mv` (the `GHHostsScript` shape, `secrets_materialize.go:231-234`); a +refresh test asserting the hosts.yml `oauth_token` rotates to the new token +alongside `.git-credentials` (both surfaces, one refresh); and a +multi-gh-host test asserting a refresh that rotates the GitHub host +PRESERVES a co-resident host block (`GHHostsScript`'s whole-file rewrite is +fed the full host set, not the single rotated credential). + ### Tasks - [ ] T1 — `account_forge_scopes` migration + `Grant/Revoke/HasForgeScope` @@ -445,16 +920,35 @@ a pgtest reconcile case beside `serve_forge_pgtest_test.go:436-459`'s pattern. and e2e tests + header-comment update. - [ ] T3 — `ForgeConfig.EnforceScopes`/`ScopeGrants` + seed reconcile + warn + assembly wiring + tests. +- [ ] T4 — `ListForgeScopeRepos` store method + `MintScopedInstallationToken` + narrowed-token mint (App-config-gated, wildcard-aware, workstream repo + always in scope, fail-loud on an unreachable workstream repo, grant/ + installation owner reconciled by id not bare-name alias) + + half-landed cross-check Warn + fake-endpoint and pgtest suites. Depends + on RIG-2732 / #634 App config AND on OQ-8 (the `workstreamRepo` input + source SEA-1527 removed). +- [ ] T4.5 — credential wire delta: `WorkspaceCredential` on + `ProvisionAgentWorkspaceRequest` (server-authoritative, `debug_redact`) + + server-driven refresh push on the hub control path + the per-live-container + refresh-scheduler registry `(container_name, account, host, workstreamRepo, + expiresAt)` + tests. Depends on RIG-2732 / #634 App config AND on OQ-8. +- [ ] T5 — `CredentialSource` seam in the Runner spec builder + provision + wiring (fail-loud) + `x-access-token` username + hardened pre-expiry + refresh (margin, backoff, keep-old-token-on-failure, atomic tmp+`mv` + rewrite) re-materializing BOTH the `.git-credentials` and hosts.yml + surfaces via the `installCredentials` / `GHHostsScript` paths + tests. + Depends on OQ-8. ## Ledger delta -Proposed row (id assigned by the driver at PR assembly; true max observed on -this base is DL-241, `docs/designs/DECISIONS.md`, so this takes -the next free id ≥ DL-242), Comms & tools section: +Proposed row (id assigned by the driver at freeze — the record must not +hardcode it: current main's `docs/designs/DECISIONS.md` max is DL-263 +post-ledger-heal, so this takes the next free id ≥ DL-264), Comms & tools +section: | ID | Decision | Status | Record | | --- | --- | --- | --- | -| DL-24x | Forge-write scope enforcement (the deferred A8) is a server-side per-account allowlist: a new `account_forge_scopes` table (agent-or-owning-user grant, exact repo or `'*'` per coordinate, provider-aware case fold applied identically at grant and check, seeded declaratively via `ForgeConfig.ScopeGrants` + owner grant methods, never agent-self-granted) checked by `requireForgeScope` in every write arm of `ExecuteForgeCallAsAccount` — after coordinate resolution, and on the create arms after the F3 idempotency-memo check so a memo-hit retry (which writes nothing) is never rejected — before any provider call, rejecting out-of-scope targets as the in-band `ForgeCallError{code:"not_found"}` byte-identical to the provider-403/404 flatten text (the comms not-found/forbidden merge; never a Connect error), guarded against future ungated arms by a write-arm exhaustiveness test over the oneof descriptors, gated by `ForgeConfig.EnforceScopes` — false for Dogfood (single trust domain, today's posture preserved), MANDATORY true for Beta regardless of the RIG-2682 account model; the flag's DEFAULT direction (fail-open vs fail-closed) is OQ-1, deferred to Matt | Proposed | [forge scope enforcement §Approach](product/compass-forge-scope-enforcement/design.md#approach) | +| DL-26x | Forge scope enforcement (the deferred A8) is ONE per-account allowlist with TWO enforcement points. (1) Server chokepoint: a new `account_forge_scopes` table (agent-or-owning-user grant, exact repo or `'*'` per coordinate, provider-aware case fold applied identically at grant and check, seeded declaratively via `ForgeConfig.ScopeGrants` + owner grant methods, never agent-self-granted) checked by `requireForgeScope` in every write arm of `ExecuteForgeCallAsAccount` — after coordinate resolution, and on the create arms after the F3 idempotency-memo check so a memo-hit retry (which writes nothing) is never rejected — before any provider call, rejecting out-of-scope targets as the in-band `ForgeCallError{code:"not_found"}` byte-identical to the provider-403/404 flatten text (the comms not-found/forbidden merge; never a Connect error), guarded against future ungated arms by a write-arm exhaustiveness test over the oneof descriptors, gated by `ForgeConfig.EnforceScopes` — false for Dogfood (single trust domain, today's posture preserved), MANDATORY true for Beta regardless of the RIG-2682 account model; the flag's DEFAULT direction (fail-open vs fail-closed) is OQ-1, deferred to Matt. (2) Git-op leg (GitHub-only — Linear has no git): when GitHub App config is present (RIG-2732), the agent container's `~/.git-credentials` token is a GitHub App installation access token minted narrowed via the API's `repositories` field to exactly the account's `account_forge_scopes` GitHub repo set, so an out-of-scope clone/push/pull fails at GitHub itself; PAT-only deployments keep today's unenforced git-op posture (the mechanism + PAT-fallback fork is OQ-6, deferred to Matt) | Proposed | [forge scope enforcement §Approach](product/compass-forge-scope-enforcement/design.md#approach) | Ledger-impact: adds one row (Comms & tools); refines the A8 no-scope posture DL-200 inherited (`go/server/forge.go:16-18`, tracing to the board-path @@ -499,6 +993,81 @@ ForgeCaller seam shape stands); edits no existing row. coordinate; no owner-prefix wildcards (`owner/*`) in MVP. **Recommendation:** accept — prefix wildcards are additive (`repo LIKE` variant) and unneeded at Beta's grant volume. +- **OQ-6 (load-bearing, DEFERRED TO MATT): git-op scope mechanism, the PAT + fallback, and #634 sequencing.** §A5 designs the git clone/push/pull leg + as **credential-narrowing**: when GitHub App config is present (RIG-2732 + / #634 — this leg DEPENDS on that record's App landing), the container + credential is an installation token minted narrowed (the `repositories` + body field, verified against the GitHub REST docs — the A5 load-bearing + premise) to the workstream repo plus the account's `account_forge_scopes` + repo set (the §A5 self-clone invariant). Three forks for freeze: + (i) **mechanism** — (a) credential-narrowing (recommended: zero + in-container enforcement code, GitHub rejects natively, nothing the agent + can tamper with from inside), vs (b) a custom in-container credential + helper, vs (c) a server-side git proxy — (b) and (c) rejected in + Alternatives as strictly heavier for the same result. + (ii) **PAT fallback** — when NO App is configured, per-account mint-time + narrowing is unavailable (a classic PAT is not repo-narrowable at all; a + fine-grained PAT is narrowable only statically at creation, §A5), so + PAT-only git-op scope is (a) unenforced with a loud startup Warn (the + design's drafted posture, §A4 — whether the Warn should be a hard startup + fail instead is part of this fork), (b) mechanism (b)/(c) after all, or + (c) a fine-grained PAT statically repo-scoped at creation to the + deployment's granted-repo union — coarser than per-account installation + tokens but enforceable without an App, partially honoring "unconditional" + for a PAT-only Beta. **Recommendation:** (a)-with-loud-Warn; (c) is the + honest middle if Matt wants a PAT-only Beta bounded. + (iii) **sequencing** — does #601 freeze WHOLE, or do A1-A4 freeze now + with A5/T4/T4.5/T5 CONTINGENT on #634's App landing (re-ratified if + #634's App shape moves)? T1-T3 have zero dependency on #634. + **Recommendation:** the contingent split — freeze A1-A4 unconditionally, + mark A5/T4/T4.5/T5 contingent on #634, and do not hold T1-T3 behind an + App that has not landed. (Folding the git-op leg into this record was + Matt-directed; the split is sequencing only, never a record split.) + **Designed against:** credential-narrowing when App present; PAT-only + git-op scope unenforced-but-loud. Matt rules at freeze; this record does + not resolve it. +- **OQ-7 (load-bearing, DEFERRED TO MATT): is the git-op allowlist the + write set, or a distinct read set?** The narrowed credential is minted + from `account_forge_scopes` — a WRITE allowlist — but a git credential + also gates CLONE (read), and the §A5 self-clone invariant already forces + one read-shaped exception (the workstream repo is always clonable). Fork: + (a) the git-op scope is exactly the write set with the workstream repo + implicitly granted for read/clone — one column, one table, no new grant + surface; or (b) a distinct READ set that is a superset of the write set — + a second column or separate read-grant rows, letting an agent clone repos + it may not write. **The regression to weigh:** narrowing clone to + {workstream repo + write set} removes what the host-wide token allows + today — cloning a read-only dependency repo (a sibling library, a + reference repo) the agent will never write — so (a) trades that + multi-repo-read capability for the scoping. **Recommendation:** (a) — + workstream-repo-implicitly-in-scope plus the write set governing + everything else; no separate read column at MVP, additive later (a read + column widens the schema without breaking (a)'s semantics) — accepting + the multi-repo-clone loss as a Beta tradeoff. If "self-clone whatever it + needs" must be preserved, (b) is the path. Note the honest asymmetry with + OQ-4: forge-API reads stay ungated this slice while git clone is scoped — + because the git credential is a single token gating both directions. +- **OQ-8 (load-bearing, DEFERRED TO MATT): where does the workstream-repo + input come from?** The A5 self-clone invariant, the T4 mint's + `workstreamRepo` argument, and OQ-7 all need the agent's own spawn-target + repo as a server-side input at mint time — and it is not one. SEA-1527 + (Matt, 2026-07-29) deliberately removed repo carriage from provision: + `ProvisionAgentWorkspaceRequest` carries no repo ("the agent self-clones + whatever it needs after launch", `proto/compass/v1/compass.proto:568-571`), + `agent_accounts` has no repo column + (`go/internal/store/migrations/0001_init.sql:74-80`), and neither + `StartAgentSessionRequest` nor `SpawnAgentRequest` carries one. The git-op + leg therefore requires RE-INTRODUCING a per-agent/per-provision + workstream-repo association — via a new provision/spawn field, an + `agent_accounts` column, or a store-side spawn-target record — which + reverses SEA-1527. **This is the blocking precondition for T4/T4.5/T5** + (they cannot be built without the input) and it reverses a prior Matt + ruling, so it is Matt's call, not the author's. **Recommendation:** the + narrowest reversal — a store-side spawn-target record keyed by + `container_name` written at provision, read by the T4.5 refresh-scheduler + registry — rather than re-adding a wire field, since only the server needs + it and the Runner still self-clones. Matt rules at freeze. The draft's former rejection-text question (whether the refusal message may differ from existing not_found texts) is resolved in-design, not open: the