From 6fae2c10ffc68a63cd2ea1aa59462f0847b93e26 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 12 Aug 2026 08:19:44 -0700 Subject: [PATCH] =?UTF-8?q?v0.45.3.0=20fix(bootstrap):=20never=20offer=20M?= =?UTF-8?q?CP=20project=20scope=20on=20Codex=20=E2=80=94=20consent-flow=20?= =?UTF-8?q?honesty=20(#4029)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bootstrap): codex scope-note guard + scope-aware wire hint runHooks: on --harness codex, print a stderr note when an explicit, non-skipped MCP_SCOPE=project answer is persisted (raw readInterviewState read, not the project-defaulting consentAnswer resolver) — reachable via attach from a Claude Code machine or a pre-fix install. consentAnswer is now shape-tolerant: a hand-edited non-string answer value falls through to the bank default instead of throwing at a toLowerCase call site. status.ts: the wire-phase resume_hint states the scope rule (Claude Code consent, phase 3; Codex always user-global — no scope flag). Tests: 7-case branch matrix on the note guard + wire-hint pin. * fix(bootstrap): harness-scope the MCP consent prose (never offer project scope on Codex) Runbook: the scope consent moves to phase 3 (Claude Code only, recorded with interview --set BEFORE the read-back so the confirmation covers it — a wire-time set clears the A8 confirm and regresses status); phase 6's Codex bullet gains the counter-signal: Do NOT offer an MCP scope choice — codex mcp add has no scope flag, registrations are always user-global. Bank: MCP_SCOPE.question gets a read-aloud-safe "(Claude Code only. ...)" prefix and phase moves wire -> interview to match. Templates: ACCESS_POLICY's scope section becomes two static harness paragraphs; CLAUDE.md stops conflating directory-based identity loading with MCP scope. Vendored template-repo regenerated. * chore(ci): pin the harness-scoping counter-signals (check-bootstrap-templates §e) Three SKIP-GRACEFUL tripwires: the runbook must carry "Do NOT offer an MCP scope choice" and "Claude Code only"; questions.json's MCP_SCOPE.question must start with "(Claude Code only". Guard-test fixtures gain a compliant MCP_SCOPE entry + pass/fail cases for each pin. * docs: Codex user-global scope caveats + A8 consent-semantics follow-up TODO Install table and KEY_FILES hooks entry now state the split: Claude Code takes --scope (project default); Codex has no scope flag. TODOS gains the deferred structural question (consent keys vs the A8 confirm gate). * fix: pre-landing + red-team review hardening Guard §(e): a valid-JSON bank missing its questions object now FAILS (it silently passed both §a and §e); the pin also asserts MCP_SCOPE.phase is 'interview' so the schema half of the fix can't silently revert. Fixtures for both + entry-vanished. consentAnswer says so on stderr when it discards a malformed answer value (a silent fall-through could flip a damaged opt-out to the permissive default) — pinned by a claude-code matrix case asserting the receipt. Off-ramp texts gain the narrow `codex mcp remove gbrain` alongside full uninstall (note, runbook, ACCESS_POLICY + vendored copy). Cross-refs de-ordinaled (phase names, not numbers — master renumbers steps). TODOS A8 entry gains the healing half (status can't distinguish consent-key invalidation from tampering). * fix: adversarial-review hardening — fail-closed consents + honest scope prose consentAnswer: a present-but-unusable answer (non-string, empty, bare {}) now fails CLOSED to 'no' with a stderr note — a bank-default fall-through could flip a damaged opt-out into consent (cross-model finding); 'no' is every consent key's safe reading (no hooks, no cron, project scope). Pinned by a malformed-HOOKS_CONSENT matrix case asserting hooks are declined. Codex scope note: names the safe remediation (--skip MCP_SCOPE + reconfirm; never --set user — the answer git-syncs to paired Claude Code machines and would widen their scope) [codex review P2]. Prose states the user-global registration's read+write reach honestly (runbook, ACCESS_POLICY + vendored). Interview resume_hint carries the consent-recording step (the CLI channel a resumed install actually reads). Guard §(e): unparseable questions.json now FAILS instead of passing silently. TODOS: P1 filed for stdio dispatch scope parity (pre-existing, surfaced by the adversarial pass). * chore: bump version and changelog (v0.45.3.0) Runbook stamp + vendored template-repo regenerated to match (the two version gates check:bootstrap-tag and check:bootstrap-templates enforce). Co-Authored-By: Claude Fable 5 * docs: update project documentation for v0.45.3.0 KEY_FILES.md: bootstrap.ts entry carries the fail-closed consent resolution + Codex stale-scope-answer note; CI-guards entry adds the check-bootstrap-templates §(e) harness-scoping counter-signal pins. bootstrap.md: degradation matrix Codex row states the no-scope-flag reality (registrations are user-global). llms bundles regenerated (byte-identical — these docs are linked, not inlined). Co-Authored-By: Claude Fable 5 * docs: cross-model doc-review fixes for v0.45.3.0 CHANGELOG: Codex off-ramps are removal commands, not narrowing; the refresh recipe now covers both harnesses and names the re-confirm step for pre-fix installs whose confirmation was invalidated. Runbook: the user-scope tradeoff says read and write, matching ACCESS_POLICY. bootstrap.md: degradation-matrix Codex row rephrased (lose the ability to confine reach, not the reach itself). KEY_FILES: the section-(e) description no longer implies placement pinning. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- BOOTSTRAP_FOR_AGENTS.md | 22 ++- CHANGELOG.md | 18 ++ TODOS.md | 29 +++- VERSION | 2 +- docs/architecture/KEY_FILES.md | 6 +- docs/guides/bootstrap.md | 4 +- package.json | 2 +- scripts/check-bootstrap-templates.sh | 45 ++++- src/commands/bootstrap.ts | 40 ++++- src/core/bootstrap/status.ts | 11 +- templates/bootstrap/ACCESS_POLICY.md.template | 16 +- templates/bootstrap/CLAUDE.md.template | 3 +- templates/bootstrap/questions.json | 4 +- .../bootstrap/template-repo/ACCESS_POLICY.md | 16 +- templates/bootstrap/template-repo/CLAUDE.md | 3 +- templates/bootstrap/template-repo/README.md | 2 +- test/bootstrap-dispatcher.serial.test.ts | 164 ++++++++++++++++++ test/bootstrap-status.serial.test.ts | 6 + test/check-bootstrap-guards.test.ts | 161 ++++++++++++++++- 19 files changed, 520 insertions(+), 34 deletions(-) diff --git a/BOOTSTRAP_FOR_AGENTS.md b/BOOTSTRAP_FOR_AGENTS.md index b94c2897d..64c46c4b3 100644 --- a/BOOTSTRAP_FOR_AGENTS.md +++ b/BOOTSTRAP_FOR_AGENTS.md @@ -1,4 +1,4 @@ - + @@ -83,6 +83,15 @@ you needed; report the count at the end (it feeds the install-time measurement). 3. **Interview.** `gbrain bootstrap interview --init`, then ask the questions from the bank (the CLI prints them) in three batches, recording each answer verbatim with `--set KEY "value"`. Push once on vague answers to the required questions. + Claude Code only: with the final batch, also ask the ONE operational consent — + MCP scope. It is not one of the 12 interview questions; consents ride alongside + the bank. The choice: project (recommended — any other repo you open cannot + read your brain) vs user (your agent everywhere, but any repo you open can + reach it — read and write — and two open sessions contend for the database). + Record it with + `gbrain bootstrap interview --set MCP_SCOPE ` BEFORE the + read-back, so the confirmation covers it. On Codex, skip this question + entirely — the wiring step states the Codex reality instead. After the last batch: read ALL answers back in one compact block, ask "Is this the thing you want in the room?", and only then run `gbrain bootstrap interview --confirm ` with the hash `--status` printed @@ -96,12 +105,15 @@ you needed; report the count at the end (it feeds the install-time measurement). - Claude Code: installs per-turn hooks ON by default — do NOT ask; loading the brain every turn is the whole point of installing gbrain for your agent. Tell the human it is on and how to turn it off (`GBRAIN_HOOKS=0`, or re-run with - `--no-hooks`, or `gbrain bootstrap uninstall`). The ONE consent to actually - ask in this phase is MCP scope: project (recommended — any other repo you open - cannot read your brain) vs user (your agent everywhere, but any repo you open - can query it, and two open sessions contend for the database). + `--no-hooks`, or `gbrain bootstrap uninstall`). MCP scope is NOT asked here — + `hooks` consumes the MCP_SCOPE answer recorded during the interview. - Codex: registers MCP (`codex mcp add`) and relies on the AGENTS.md protocol — say plainly that Codex gets pull-based context, not per-turn push. + Do NOT offer an MCP scope choice: `codex mcp add` has no scope flag, so + the registration is always user-global. State it as fact — any repo opened + on this machine can reach the brain (read and write) through its MCP + tools; the off-ramps are `codex mcp remove gbrain` (registration only) or + `gbrain bootstrap uninstall` (full teardown). 7. **Private repo.** `gbrain bootstrap repo` — creates a PRIVATE GitHub repo from the workspace, verifies the privacy bit through the API, pushes. If the human started from a repo they created themselves (create-repo-first: an EMPTY private diff --git a/CHANGELOG.md b/CHANGELOG.md index 373a69866..877c3b271 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to GBrain will be documented in this file. +## [0.45.3.0] - 2026-08-12 + +**Codex installs stop asking a question Codex can't honor.** The bootstrap used to offer every install a choice of MCP scope — this folder only, or the whole machine — but Codex has no per-folder registrations, so picking "this folder" led to a confusing round-trip where the agent asked permission to keep what it had already done. Now each harness gets the honest version: Claude Code records your scope choice during the interview (where it actually sticks), and Codex simply tells you the truth — its registration reaches the whole machine, read and write — along with the exact commands to remove it (just the registration, or the whole install). + +### Added +- CI tripwires pin the harness-scoping language in the runbook and question bank, so the dead question can't quietly come back in a future edit. +- `gbrain bootstrap status` hints now carry the scope rule on both the interview and wiring phases, so an agent resuming from a stale runbook still sees it. + +### Changed +- The workspace's ACCESS_POLICY.md now describes MCP reach per harness — the project/user tradeoff on Claude Code, the always-machine-wide reality on Codex — and states plainly that reach means read and write, not just queries. +- The Claude Code scope consent is asked and recorded during the interview, before the answer read-back, so your confirmation covers it and the wiring step just consumes it. + +### Fixed +- A hand-damaged answer file no longer crashes `gbrain bootstrap hooks` — and no longer silently grants anything: an unreadable consent answer is treated as declined, with a note telling you how to re-record it. +- A leftover "this folder only" answer on a Codex machine (for example, carried over from a paired Claude Code machine) now gets a clear explanation and a safe way to clear it, instead of a silent mismatch between what you chose and what got registered. + +To take advantage of v0.45.3.0: upgrade with `bun install -g github:garrytan/gbrain#latest-stable`. New installs pick everything up automatically. Existing workspaces (either harness) keep their previously rendered policy files; refresh the two that changed with `gbrain bootstrap render --force --only ACCESS_POLICY.md --only CLAUDE.md` (originals are backed up first). If render refuses because the answers show as not confirmed — a pre-fix install that recorded the scope answer after the read-back — run `gbrain bootstrap interview --show`, then `--confirm `, and retry. Or leave the files as they are — the CLI's own notes and status hints carry the correction either way. + ## [0.45.2.0] - 2026-08-11 **Make your agent's repo yourself, then let it move in.** If you'd rather own the GitHub repo up front, create a new empty private repo under your own account, clone it, open it in Claude Code or Codex, and paste the bootstrap block — bootstrap now detects your empty repo and adopts it instead of creating one, verifying it is private before anything is pushed. The default (open an empty folder and let bootstrap make the repo) is unchanged and now stated plainly in the docs. Either way, the folder you open becomes your agent's durable, private body. diff --git a/TODOS.md b/TODOS.md index 6e4c6ddfc..82ffb1be1 100644 --- a/TODOS.md +++ b/TODOS.md @@ -5056,6 +5056,34 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts` ## Agent-bootstrap wave follow-ups (filed at build time) +- [ ] **P1 — enforce op scope/localOnly on the stdio MCP dispatch when no auth + context is present, and consider a narrower default surface for pull-mode + harness registrations.** HTTP dispatch enforces `scope`/`localOnly` before + handlers run; the stdio surface should reach parity so a registration that is + user-global by host design (no per-project scoping available) does not expose + more authority than the session needs. Surfaced by the v0.45.x ship + adversarial pass (cross-model); pre-existing behavior, not introduced by the + Codex scope-consent fix — that fix's prose now states the read+write reality + honestly. Needs its own design pass (interaction with `--surface` pinning, + MEMORY_VERBS, and the trust-boundary invariant in CLAUDE.md). +- [ ] **P2 — consent-key answers vs the A8 confirm gate.** Decide whether + `consent: true` bank keys should be exempt from `setAnswer`'s confirmation + invalidation (`src/core/bootstrap/interview.ts:308-309` `[A8]` deletes + `state.confirmed` on ANY set) so operational consents can be recorded at their + designed phase-contextual moment post-confirm without regressing + `bootstrap status` to "answers complete but not confirmed" (status.ts + interview detector). Deferred from the Codex MCP-scope fix (eng review option + 3B chose prose realignment instead: the runbook now records `MCP_SCOPE` in + phase 3, pre-confirm, so the confirm hash covers it). An exemption touches a + tamper-tripwire — a post-confirm flip of `PERSIST_CRON` (background-push + consent) would no longer invalidate anything — so it needs its own + adversarial review before landing. Also cover the healing half: pre-fix + installs that recorded `MCP_SCOPE` at the old wire-phase moment have a + permanently-invalidated confirm, and `bootstrap status` can't distinguish a + consent-key invalidation from a tampered answer set — a status detail for + that case would stop resumed installs being steered into a redundant + re-confirm loop (ship-review data-migration finding). Context: eng review + + codex consult of the Codex scope fix, 2026-08-11. - [ ] **P2 — bootstrap first-push secret scan reads the working tree, not the index blobs; fail-open on binary/large files.** `secretScanOrThrow` / `scanFiles` (src/core/bootstrap/repo.ts + src/core/secret-scan.ts) read @@ -5068,7 +5096,6 @@ respective shapes. Small, mechanical; pinned by `test/init-embed-check.test.ts` v0.45.2.0 /ship Codex adversarial pass (P0 there; scoped to P2 here as a shared-scanner hardening that needs its own tests, deliberately out of the create-repo-first change). - - [x] **P2 — compiled `gbrain` binary can now `serve` a PGLite brain.** FIXED: `src/core/pglite-embedded-assets.ts` embeds PGLite's runtime payload (`pglite.wasm`, `initdb.wasm`, `pglite.data`, `vector.tar.gz`, diff --git a/VERSION b/VERSION index 5f1db1c23..b07440c08 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.45.2.0 +0.45.3.0 diff --git a/docs/architecture/KEY_FILES.md b/docs/architecture/KEY_FILES.md index 86e961a59..657a2e39d 100644 --- a/docs/architecture/KEY_FILES.md +++ b/docs/architecture/KEY_FILES.md @@ -493,7 +493,7 @@ Normative docs: `docs/designs/AGENT_BOOTSTRAP_DESIGN.md` (scope) + User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetches: `BOOTSTRAP_FOR_AGENTS.md` (root; carries a version stamp CI pins to VERSION). -- `src/commands/bootstrap.ts` — the `gbrain bootstrap {status,interview,render,repo,hooks,verify,uninstall,attach}` dispatcher. Engine-free everywhere except `verify` (which opens/closes its own engine — safe because verify runs with no live serve, before host registration). Mutating subcommands run under the workspace bootstrap lock; render is gated on interview `complete && confirmed` and hard-refuses when the workspace origin is a PUBLIC remote (identity files must never land in a public repo — the same template-door gate `status` enforces; unverifiable visibility warns and proceeds, treating the origin as public); the provider key routes to the 0600 config sink and never touches interview state; every subcommand appends a line to `/bootstrap/install.jsonl`. `GBRAIN_BOOTSTRAP_ABORT_AFTER` is the deterministic kill-mid-phase test seam. +- `src/commands/bootstrap.ts` — the `gbrain bootstrap {status,interview,render,repo,hooks,verify,uninstall,attach}` dispatcher. Engine-free everywhere except `verify` (which opens/closes its own engine — safe because verify runs with no live serve, before host registration). Mutating subcommands run under the workspace bootstrap lock; render is gated on interview `complete && confirmed` and hard-refuses when the workspace origin is a PUBLIC remote (identity files must never land in a public repo — the same template-door gate `status` enforces; unverifiable visibility warns and proceeds, treating the origin as public); the provider key routes to the 0600 config sink and never touches interview state; every subcommand appends a line to `/bootstrap/install.jsonl`. Consent answers resolve FAIL-CLOSED: `consentAnswer` treats a hand-edited/unusable interview value (non-string, empty) as declined — loudly, with a re-record note — never falling through to a permissive bank default; `hooks` on Codex prints a corrective note when a persisted `project` MCP_SCOPE answer is found (raw state read, not the resolver — `codex mcp add` has no scope flag, registrations are always user-global) with safe clear instructions. `GBRAIN_BOOTSTRAP_ABORT_AFTER` is the deterministic kill-mid-phase test seam. - `src/core/bootstrap/format.ts` — `agent.json` manifest (format_version 1, provisional; `initialized` sentinel distinguishes a template clone from a bootstrapped workspace) + the machine-local install receipt (`/bootstrap/receipt.json`) that proves THIS machine ran bootstrap; uninstall is keyed to the receipt, never the repo manifest. Atomic writes; `readManifest` never throws (typed states incl. conflict markers). - `src/core/bootstrap/assets.ts` — every template + the question bank embedded via Bun `with { type: 'file' }` imports (the chunkers/code.ts pattern) so the compiled binary renders with no repo checkout; `DERIVED_TOKENS` (GITHUB_REPO_URL, CORPUS_RETENTION_DAYS) is the non-bank half of the template token set the CI bijection guard checks. - `templates/bootstrap/` — the ten `{{TOKEN}}` identity templates (AGENTS/CLAUDE/SOUL/USER/MEMORY/HEARTBEAT/ACCESS_POLICY/GITHUB/memory-README/gitignore), `questions.json` (12 asked / 6 required; consent keys; `persist:false` sink keys), and `template-repo/` — the VENDORED deterministic render the release job diffs against before publishing the public template repo. Generic placeholder content only (privacy iron rule; CI-asserted). @@ -501,7 +501,7 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche - `src/core/bootstrap/render.ts` — token substitution with interview values treated as data (line-leading `#`/` + This repository is the **"Use this template"** distribution artifact for a [gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same diff --git a/test/bootstrap-dispatcher.serial.test.ts b/test/bootstrap-dispatcher.serial.test.ts index ba5b493e0..30c6822a0 100644 --- a/test/bootstrap-dispatcher.serial.test.ts +++ b/test/bootstrap-dispatcher.serial.test.ts @@ -201,6 +201,170 @@ describe('per-turn hooks are ON by default (v0.45 flip); --no-hooks opts out', ( }, 30_000); }); +describe('codex scope-note guard — Codex has no scope flag; stale MCP_SCOPE answers', () => { + // Full branch matrix on the runHooks codex note: it must fire ONLY for an + // explicit, non-skipped, string-valued 'project' answer (raw state read — the + // consentAnswer resolver would default unset → 'project' and fire the note on + // every Codex install where no one was ever asked). + const NOTE = 'no effect on Codex'; + const scratch: string[] = []; + afterAll(() => { + for (const d of scratch) rmSync(d, { recursive: true, force: true }); + }); + + function scopeWorkspace(mcpScope: 'project' | 'user' | 'skip' | 'unset'): { + fws: string; + fhome: string; + fparent: string; + } { + const fparent = mkdtempSync(join(tmpdir(), 'gb-scope-')); + const fhome = join(fparent, '.gbrain'); + mkdirSync(fhome, { recursive: true }); + const fws = mkdtempSync(join(tmpdir(), 'gb-scope-ws-')); + scratch.push(fparent, fws); + const prev = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = fparent; + try { + expect(initState(fws).ok).toBe(true); + for (const [key, value] of Object.entries(REQUIRED_ANSWERS)) { + const r = setAnswer(fws, key, value); + if (!r.ok) throw new Error(r.message); + } + if (mcpScope === 'skip') expect(skipAnswer(fws, 'MCP_SCOPE').ok).toBe(true); + else if (mcpScope !== 'unset') expect(setAnswer(fws, 'MCP_SCOPE', mcpScope).ok).toBe(true); + const h = readBackHash(fws); + if (!h.ok) throw new Error(h.message); + expect(confirm(fws, h.hash).ok).toBe(true); + } finally { + if (prev === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = prev; + } + return { fws, fhome, fparent }; + } + + async function withScopeHome(parent: string, fn: () => Promise): Promise { + const prev = process.env.GBRAIN_HOME; + process.env.GBRAIN_HOME = parent; + try { + return await fn(); + } finally { + if (prev === undefined) delete process.env.GBRAIN_HOME; + else process.env.GBRAIN_HOME = prev; + } + } + + async function renderThenHooks( + fws: string, + fparent: string, + harness: 'codex' | 'claude-code', + mutateAfterRender?: () => void, + ) { + return withScopeHome(fparent, async () => { + expect((await capture(() => runBootstrap(['render', '--workspace', fws]))).result).toBe(0); + mutateAfterRender?.(); + const { runner } = makeRunner(); + return capture(() => + runBootstrap(['hooks', '--workspace', fws, '--harness', harness, '--gbrain-bin', process.execPath], { + runner, + }), + ); + }); + } + + test('codex + explicit project → note fires; hooks skipped; receipt records user scope', async () => { + const { fws, fhome, fparent } = scopeWorkspace('project'); + const r = await renderThenHooks(fws, fparent, 'codex'); + expect(r.result).toBe(0); + expect(r.err).toContain(NOTE); + expect(r.err).toContain('user-global'); + expect(existsSync(join(fws, '.claude', 'settings.local.json'))).toBe(false); + expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'codex', scope: 'user', detail: 'mcp' }]); + }, 30_000); + + test('codex + unset → NO note (raw read, not the project-defaulting resolver)', async () => { + const { fws, fparent } = scopeWorkspace('unset'); + const r = await renderThenHooks(fws, fparent, 'codex'); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + }, 30_000); + + test('codex + explicitly skipped → NO note (skipped is not an answer)', async () => { + const { fws, fparent } = scopeWorkspace('skip'); + const r = await renderThenHooks(fws, fparent, 'codex'); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + }, 30_000); + + test('codex + explicit user → NO note (nothing to correct)', async () => { + const { fws, fparent } = scopeWorkspace('user'); + const r = await renderThenHooks(fws, fparent, 'codex'); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + }, 30_000); + + test('claude-code + explicit project → NO note (harness guard); hooks flow unchanged', async () => { + const { fws, fhome, fparent } = scopeWorkspace('project'); + const r = await renderThenHooks(fws, fparent, 'claude-code'); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp+hooks' }]); + }, 30_000); + + test('codex + corrupt interview.json → NO note, exit 0 (fail-open read.ok route)', async () => { + const { fws, fparent } = scopeWorkspace('project'); + const r = await renderThenHooks(fws, fparent, 'codex', () => { + writeFileSync(join(fws, 'state', 'interview.json'), '{ not json', 'utf8'); + }); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + }, 30_000); + + test('codex + malformed answer shape (value: 3) → NO note, no crash (typeof guard)', async () => { + const { fws, fparent } = scopeWorkspace('unset'); + const r = await renderThenHooks(fws, fparent, 'codex', () => { + const p = join(fws, 'state', 'interview.json'); + const state = JSON.parse(readFileSync(p, 'utf8')) as { answers: Record }; + state.answers['MCP_SCOPE'] = { value: 3 }; + writeFileSync(p, JSON.stringify(state), 'utf8'); + }); + expect(r.result).toBe(0); + expect(r.err).not.toContain(NOTE); + }, 30_000); + + test('claude-code + malformed answer shape → consentAnswer falls to bank default; hooks flow completes', async () => { + const { fws, fhome, fparent } = scopeWorkspace('unset'); + const r = await renderThenHooks(fws, fparent, 'claude-code', () => { + const p = join(fws, 'state', 'interview.json'); + const state = JSON.parse(readFileSync(p, 'utf8')) as { answers: Record }; + state.answers['MCP_SCOPE'] = { value: 3 }; + writeFileSync(p, JSON.stringify(state), 'utf8'); + }); + expect(r.result).toBe(0); + // Pre-fix this crashed at mcpScope's .toLowerCase(); now the unusable + // value fails CLOSED ('no' → project scope) — LOUDLY (a silent fall-through + // could flip a damaged opt-out to consent). + expect(r.err).toContain('invalid shape'); + expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp+hooks' }]); + }, 30_000); + + test('claude-code + malformed HOOKS_CONSENT → fail-closed: hooks DECLINED, note printed', async () => { + const { fws, fhome, fparent } = scopeWorkspace('unset'); + const r = await renderThenHooks(fws, fparent, 'claude-code', () => { + const p = join(fws, 'state', 'interview.json'); + const state = JSON.parse(readFileSync(p, 'utf8')) as { answers: Record }; + // A merge-damaged boolean: previously crashed; a bank-default fall-through + // would silently flip a possible opt-out to consent-granted. Fail closed. + state.answers['HOOKS_CONSENT'] = { value: true }; + writeFileSync(p, JSON.stringify(state), 'utf8'); + }); + expect(r.result).toBe(0); + expect(r.err).toContain('invalid shape'); + expect(r.out).toContain('hooks declined'); + expect(existsSync(join(fws, '.claude', 'settings.local.json'))).toBe(false); + expect(readReceipt(fhome)?.registrations).toEqual([{ host: 'claude-code', scope: 'project', detail: 'mcp' }]); + }, 30_000); +}); + describe('MCP registration verification [FIX7]', () => { const gbrainBin = process.execPath; const OURS = `gbrain:\n command: ${gbrainBin} serve --surface full\n env: GBRAIN_SOURCE=workspace`; diff --git a/test/bootstrap-status.serial.test.ts b/test/bootstrap-status.serial.test.ts index 90503e35d..554dfe1fb 100644 --- a/test/bootstrap-status.serial.test.ts +++ b/test/bootstrap-status.serial.test.ts @@ -63,6 +63,12 @@ describe('phase list is the single TS source of truth [D5]', () => { expect(typeof p.detect).toBe('function'); } }); + + test('wire hint carries the harness scope rule (Claude Code consent vs Codex user-global)', () => { + const wire = PHASES.find((p) => p.id === 'wire'); + expect(wire?.resume_hint).toContain('MCP scope consent is Claude Code only'); + expect(wire?.resume_hint).toContain('Codex registrations are always user-global (no scope flag)'); + }); }); describe('CLI reachability membership (#2035 shape, ENG-2)', () => { diff --git a/test/check-bootstrap-guards.test.ts b/test/check-bootstrap-guards.test.ts index 72d22647b..b910296d1 100644 --- a/test/check-bootstrap-guards.test.ts +++ b/test/check-bootstrap-guards.test.ts @@ -56,14 +56,27 @@ const BANK_JSON = JSON.stringify({ version: 1, maxQuestions: 12, interviewKeys: ['AGENT_NAME'], - consentKeys: ['HOOKS_CONSENT'], + consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'], questions: { AGENT_NAME: { maxLength: 64 }, HOOKS_CONSENT: { consent: true, maxLength: 8 }, + // Section (e) pins the harness-scoping prefix + interview phase on this + // question — every "clean" fixture must carry a compliant MCP_SCOPE entry. + MCP_SCOPE: { + consent: true, + phase: 'interview', + question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?', + maxLength: 8, + }, UNUSED_OPTIONAL: { maxLength: 8 }, }, }); +// Minimal runbook that satisfies the section (e) counter-signal pins; fixtures +// exercising OTHER failure modes include it so they fail only for their own +// reason. +const RUNBOOK_PINS = 'Claude Code only\nDo NOT offer an MCP scope choice\n'; + describe('check-bootstrap-tag.sh', () => { test('exists and is executable', () => { expect(existsSync(TAG_GUARD)).toBe(true); @@ -261,7 +274,7 @@ describe('check-bootstrap-templates.sh', () => { 'templates/bootstrap/questions.json': BANK_JSON, 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', 'BOOTSTRAP_FOR_AGENTS.md': - '\nPhase: preflight\nPhase: not_a_phase\n', + `\n${RUNBOOK_PINS}Phase: preflight\nPhase: not_a_phase\n`, 'src/core/bootstrap/status.ts': "export const PHASES = ['preflight', 'interview'] as const;\n", }, (dir) => { @@ -272,6 +285,150 @@ describe('check-bootstrap-templates.sh', () => { }, ); }); + + test('(e) clean: runbook counter-signals + compliant MCP_SCOPE prefix pass', () => { + withFixture( + { + 'templates/bootstrap/questions.json': BANK_JSON, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': `\n${RUNBOOK_PINS}`, + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(0); + expect(r.out).toContain('check-bootstrap-templates: ok'); + }, + ); + }); + + test('(e) fails when the runbook loses the Codex do-not-offer counter-signal', () => { + withFixture( + { + 'templates/bootstrap/questions.json': BANK_JSON, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': + '\nClaude Code only\n(counter-signal deleted)\n', + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain('Codex counter-signal'); + }, + ); + }); + + test("(e) fails when the runbook loses the 'Claude Code only' consent scoping", () => { + withFixture( + { + 'templates/bootstrap/questions.json': BANK_JSON, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': + '\nDo NOT offer an MCP scope choice\n', + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain("'Claude Code only'"); + }, + ); + }); + + test('(e) fails when the questions object vanishes (valid JSON that silently passes §a)', () => { + withFixture( + { + 'templates/bootstrap/questions.json': '{"version":1,"maxQuestions":12,"interviewKeys":[],"consentKeys":[]}', + 'templates/bootstrap/SOUL.md.template': '# plain\n', + 'BOOTSTRAP_FOR_AGENTS.md': `\n${RUNBOOK_PINS}`, + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain("must start with '(Claude Code only'"); + }, + ); + }); + + test('(e) fails when the MCP_SCOPE entry vanishes from the bank', () => { + const bankNoEntry = JSON.stringify({ + version: 1, + maxQuestions: 12, + interviewKeys: ['AGENT_NAME'], + consentKeys: ['HOOKS_CONSENT'], + questions: { + AGENT_NAME: { maxLength: 64 }, + HOOKS_CONSENT: { consent: true, maxLength: 8 }, + }, + }); + withFixture( + { + 'templates/bootstrap/questions.json': bankNoEntry, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': `\n${RUNBOOK_PINS}`, + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain("must start with '(Claude Code only'"); + }, + ); + }); + + test('(e) fails when MCP_SCOPE.phase reverts to wire (schema-vs-runbook contradiction)', () => { + const bankWirePhase = JSON.stringify({ + version: 1, + maxQuestions: 12, + interviewKeys: ['AGENT_NAME'], + consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'], + questions: { + AGENT_NAME: { maxLength: 64 }, + HOOKS_CONSENT: { consent: true, maxLength: 8 }, + MCP_SCOPE: { + consent: true, + phase: 'wire', + question: '(Claude Code only. Codex has no scope flag.) Register for this folder or the whole machine?', + maxLength: 8, + }, + }, + }); + withFixture( + { + 'templates/bootstrap/questions.json': bankWirePhase, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': `\n${RUNBOOK_PINS}`, + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain("MCP_SCOPE.phase must be 'interview'"); + }, + ); + }); + + test('(e) fails when MCP_SCOPE.question loses its harness prefix (or the entry vanishes)', () => { + const bankNoPrefix = JSON.stringify({ + version: 1, + maxQuestions: 12, + interviewKeys: ['AGENT_NAME'], + consentKeys: ['HOOKS_CONSENT', 'MCP_SCOPE'], + questions: { + AGENT_NAME: { maxLength: 64 }, + HOOKS_CONSENT: { consent: true, maxLength: 8 }, + MCP_SCOPE: { consent: true, phase: 'interview', question: 'Register for this folder or the whole machine?', maxLength: 8 }, + }, + }); + withFixture( + { + 'templates/bootstrap/questions.json': bankNoPrefix, + 'templates/bootstrap/SOUL.md.template': '# {{AGENT_NAME}}\n', + 'BOOTSTRAP_FOR_AGENTS.md': `\n${RUNBOOK_PINS}`, + }, + (dir) => { + const r = runGuard(TPL_GUARD, dir); + expect(r.status).toBe(1); + expect(r.out).toContain("must start with '(Claude Code only'"); + }, + ); + }); }); describe('verify + workflow wiring', () => {