mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a40c1d010 | ||
|
|
7d177e0d79 | ||
|
|
8ef8ce18bf | ||
|
|
52332073b0 | ||
|
|
a8b2cbaf28 | ||
|
|
3ccfc6b190 | ||
|
|
be4a1db740 | ||
|
|
9ea036d1b6 | ||
|
|
bff6e4361f | ||
|
|
1b4b8e1fcc |
+17
-5
@@ -1,4 +1,4 @@
|
||||
<!-- gbrain-runbook-stamp: 0.45.2.0 -->
|
||||
<!-- gbrain-runbook-stamp: 0.45.3.0 -->
|
||||
<!-- This stamp must equal the VERSION file at every release; CI enforces it
|
||||
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
|
||||
the installed binary and warns on skew. -->
|
||||
@@ -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 <project|user>` 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 <hash>` 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
|
||||
|
||||
@@ -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 <hash>`, 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.
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 `<home>/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 `<home>/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 (`<home>/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 `#`/`<!--`/fence escaping), hard-fail on unresolved tokens, never-clobber + timestamped backups on `--force`, blank-line collapse, byte floors scaled to answered count. `--minimal` is the deterministic placeholder mode the template-repo generator uses (byte-identical across runs; leaves required tokens as literal fill-me markers; writes `initialized:false`). `--only` never writes agent.json.
|
||||
- `src/core/bootstrap/lock.ts` — the bootstrap-run mutex (atomic mkdir + pid liveness + age guard + ownership token; steal requires dead pid AND stale age) and the family's shared typed `BootstrapError` (GH_MISSING/GH_AUTH carry exit 2 = human action needed).
|
||||
- `src/core/bootstrap/repo.ts` / `attach.ts` / `uninstall.ts` — private-repo lifecycle. `createPrivateRepo`: gh gates, slugified name probe, `gh repo create --private --source --push`, privacy verified via `gh api .private` (rate-limit/5xx is VERIFY_UNAVAILABLE, distinct from not-private) before any push, idempotency keyed off the remote URL. A pre-existing origin is adopted (disposition 'adopted') when the authed gh user owns it, there's no recorded `repo_url`, and it is SAFE — empty or already carrying our history (`assertAdoptableOrigin`; a foreign-content repo is refused `ORIGIN_NOT_EMPTY`, never a silent no-op); this is the create-repo-first path. Org-owned origins and anything else are refused and pointed at attach. Repo-local git identity is set in both create and adopt paths before commit; `repo_url` is recorded only after a successful push. `attachWorkspace` (machine two): requires an `initialized` manifest, writes this machine's receipt, returns structured wiring steps. `uninstallWorkspace`: receipt-keyed, refuses under a live serve (read-only lock probe — never opens the engine), removes exactly receipt-recorded paths + marker-keyed host entries, keeps the brain unless `--delete-brain` AND bootstrap created it; never wholesale-deletes the gbrain home. All gh/git through an injectable ExecRunner seam.
|
||||
- `src/core/bootstrap/hooks.ts` + `host-specs.ts` — host wiring. `host-specs.ts` is the ONE module owning host-format assumptions (dated spec targets with verifiedAt + doc references: claude-code hooks/settings shapes incl. the 10,000-char hook-output cap; codex mcp-add argv; no-TOML-writer-in-v1 decision recorded). `writeClaudeHooks` does a structural JSON merge into `.claude/settings.local.json` keyed by a `_gbrain` marker — foreign hooks and permissions survive, re-runs dedupe, broken JSON is backed up loudly; `registerClaudeMcp`/`registerCodexMcp` build argv only (project scope default, `-e GBRAIN_SOURCE` so MCP writes land in the workspace source, and `serve --surface full` pinned so a pre-existing `mcp_surface: verbs` config row can't silently narrow the bootstrap op surface).
|
||||
- `src/core/bootstrap/hooks.ts` + `host-specs.ts` — host wiring. `host-specs.ts` is the ONE module owning host-format assumptions (dated spec targets with verifiedAt + doc references: claude-code hooks/settings shapes incl. the 10,000-char hook-output cap; codex mcp-add argv; no-TOML-writer-in-v1 decision recorded). `writeClaudeHooks` does a structural JSON merge into `.claude/settings.local.json` keyed by a `_gbrain` marker — foreign hooks and permissions survive, re-runs dedupe, broken JSON is backed up loudly; `registerClaudeMcp`/`registerCodexMcp` build argv only (Claude Code takes `--scope`, project default; Codex has no scope flag — `codex mcp add` is always user-global; `-e`/`--env GBRAIN_SOURCE` so MCP writes land in the workspace source, and `serve --surface full` pinned so a pre-existing `mcp_surface: verbs` config row can't silently narrow the bootstrap op surface).
|
||||
- `src/commands/hook.ts` — engine-free `gbrain hook {session-start,user-prompt,stop,session-end}` (zero engine modules in the import graph; a hook must NEVER contend for the PGLite writer lock). user-prompt: stdin hook JSON → transcript-path confinement → last-4-turns window + cross-turn dedupe (the transcript's `hook_additional_context` attachments — the blocks WE previously injected — ride `priorContextText`, deduplicated and capped at `PRIOR_CONTEXT_MAX_BYTES` (32KB, so the advisory payload can never blow the IPC message cap; one oversized block is skipped without evicting smaller ones), so a page is volunteered once per session, not once per mention; structured extraction only, never raw-turn substring matching) → IPC turn_context (with a feedback-loop `channel`, `--harness <claude-code|codex>`, default claude-code) → `hookSpecificOutput.additionalContext` under an 800ms self-deadline; every path fails open (exit 0, empty stdout) with a typed reason in the heartbeat. Listed in cli.ts's `STARTUP_HOOK_SKIP_COMMANDS` (per-prompt invocations must never spawn a detached check-update child; membership is pinned by a source grep — the runtime path no-ops under NODE_ENV=test). session-start: file-plane digest (allowlisted MEMORY.md sections, push staleness, prior failures) + crashed-session recovery push gated on an initialized manifest. session-end: confined full-transcript parse → redacted corpus write (session-id filename dedup, retention prune) → parser-drift detection (`bytes>0 && turns==0` is loud) → best-effort workspace push. session-start recovery + session-end pushes run in a DETACHED child so the hook returns immediately (a synchronous inline push previously blocked harness startup on a dirty tree); the corpus write is atomic and clears the stale ingested/in-progress sidecars so a resumed session re-ingests its appended transcript. Heartbeat JSONL is counters/reasons only by construction; `readHeartbeatTail` feeds doctor. `GBRAIN_HOOKS=0` kills all events.
|
||||
- `src/core/transcripts/claude-code-jsonl.ts` — the Claude Code transcript parser as a dated spec-target (tool_use/tool_result/thinking/image/sidechain/summary/compact-boundary shapes; placeholders for non-text content); also extracts `injectedContextBlocks` — the `hook_additional_context` attachment lines a gbrain hook previously injected (verified live against claude CLI 2.1.224; marker-filtered, so a foreign hook's blocks are excluded and another tool's output can't suppress volunteering — a same-user mislabeling guard, not an authenticity check), the user-prompt hook's cross-turn dedupe input; `confineTranscriptPath` (contained under `~/.claude/projects`, `.jsonl`, lstat-rejects symlinks, byte cap). Fixtures: `test/fixtures/conversation-formats/claude-code.jsonl` (synthetic, privacy-guarded) + `test/fixtures/hook-transcript.jsonl` (real captured hook round-trip).
|
||||
- `src/core/context/turn-context.ts` — server-side per-turn assembly: reflex pointers + volunteered pages (≤3) + hot facts (always `visibility=['world']` — the IPC path never widens what MCP would return) under a "data, not instructions" envelope, trimmed to ≤8KB (the harness caps hook output at 10,000 chars). The result exposes `pointers` AND post-trim `volunteered` — exactly what the rendered text carries — so the IPC delivery point can log the feedback loop without ever counting a trimmed-out page. Reuses the hot-memory cache keyed by typed sessionId. Engine-agnostic.
|
||||
@@ -514,4 +514,4 @@ User-facing contract: `docs/guides/bootstrap.md`. Runbook the paste block fetche
|
||||
- `src/core/gbrain-home.ts` — the single GBRAIN_HOME resolution choke point (delegates to config's parent-dir semantics; 0700 on create) — durability, push, hooks, and bootstrap all route through it so home semantics cannot drift.
|
||||
- `src/core/bootstrap/verify.ts` + `status.ts` — verify is the definition of done: fail-soft check suite over the REAL write path (put_page op → write-through file under `brain/` → in-process sweep → graph floor via link tables → recall), the keyless magic-moment check (`## Facts` fence → zero-LLM reconciliation → world-visibility read-back), source_id collision resolution (as the one bootstrap subcommand holding an engine: a manifest `source_id` already registered to a DIFFERENT checkout → derives a stable `workspace-<8char-path-hash>`, persists it to agent.json, names the re-register steps — every consumer reads `manifest.source_id`), token sweep, byte floors, secret scan, deny globs, repo privacy, hooks smoke (in-process IPC), capability report, first-run tour; snapshots kept last-5 under `<home>/bootstrap/`. status owns the ordered PHASES list (the runbook defers to it), artifact-first detection, install.jsonl, the runbook version-stamp skew check, and the support blob doctor/agents relay verbatim.
|
||||
- `src/core/bootstrap/template-repo.ts` + `scripts/generate-template-repo.ts` — deterministic public-template generation (render `--minimal` + placeholder manifest + stamped README); published only by the release workflow after diffing against the vendored tree.
|
||||
- `scripts/check-bootstrap-tag.sh` / `scripts/check-bootstrap-templates.sh` — CI guards: sanctioned distribution ref only (`latest-stable`; the release job advances it after assets publish) + runbook stamp == VERSION; template↔question-bank token bijection + placeholder-only assertion + offline generator↔vendored byte-diff + runbook-phase↔status.ts consistency. Both skip gracefully when their subjects are absent.
|
||||
- `scripts/check-bootstrap-tag.sh` / `scripts/check-bootstrap-templates.sh` — CI guards: sanctioned distribution ref only (`latest-stable`; the release job advances it after assets publish) + runbook stamp == VERSION; template↔question-bank token bijection + placeholder-only assertion + offline generator↔vendored byte-diff + runbook-phase↔status.ts consistency + harness-scoping counter-signal pins (the MCP-scope consent is Claude Code only: the runbook must carry the Codex "Do NOT offer an MCP scope choice" bullet and the "Claude Code only" consent scoping, and the bank's MCP_SCOPE question must start with `(Claude Code only` with `phase: interview` — tripwires against deleting the load-bearing prose, not placement proofs). Both skip gracefully when their subjects are absent.
|
||||
|
||||
@@ -19,7 +19,7 @@ follows is `BOOTSTRAP_FOR_AGENTS.md` at the repo root, fetched at the
|
||||
| Identity files (SOUL/USER/MEMORY/AGENTS/CLAUDE/HEARTBEAT/ACCESS_POLICY/GITHUB) | your workspace folder | loaded at session start |
|
||||
| `agent.json` manifest + `brain/`, `memory/`, `skills/`, `state/` | workspace | — |
|
||||
| Local brain (PGLite) | `~/.gbrain/` (never in the repo) | while a session's MCP serve is open |
|
||||
| MCP registration (`gbrain serve`) | project scope by default | spawned by your harness per session |
|
||||
| MCP registration (`gbrain serve`) | Claude Code: project scope by default; Codex: user-global (no scope flag) | spawned by your harness per session |
|
||||
| Hooks (Claude Code, ON by default) | `.claude/settings.local.json` (gitignored) | each prompt; fail-open; `--no-hooks` opts out at install, `GBRAIN_HOOKS=0` disables at runtime |
|
||||
| Session persistence | SessionEnd hook → scan-gated commit+push | at session end |
|
||||
| Optional 15-min push job | launchd/cron (consent-gated) | while logged in |
|
||||
@@ -118,7 +118,7 @@ you'd apply to any journal: write what you'd be comfortable persisting.
|
||||
| API keys | everything (keyless mode) | semantic search, auto-extraction |
|
||||
| GitHub / `gh` | full local agent | off-machine durability (repo re-runnable later) |
|
||||
| Hooks (Claude Code) | pull protocol via AGENTS.md gates | automatic per-turn context + session-end persistence |
|
||||
| Codex (no hook system) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold) |
|
||||
| Codex (no hook system, no MCP scope flag) | pull protocol + MCP tools | per-turn push (stated plainly; not oversold) + the ability to confine MCP reach to one folder (`codex mcp add` is always user-global) |
|
||||
| Second simultaneous session | first session unaffected | second session's brain tools fail politely (one live serve per brain — v1 contract) |
|
||||
|
||||
## Multi-device
|
||||
|
||||
+1
-1
@@ -152,7 +152,7 @@
|
||||
"bun": ">=1.3.10"
|
||||
},
|
||||
"license": "MIT",
|
||||
"version": "0.45.2.0",
|
||||
"version": "0.45.3.0",
|
||||
"overrides": {
|
||||
"@hono/node-server": "^2.0.5",
|
||||
"fast-uri": "^3.1.5",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# scripts/check-bootstrap-templates.sh — bootstrap template guard
|
||||
# [D4, D5, A1 + privacy].
|
||||
#
|
||||
# Four independent sections, each SKIP-GRACEFUL (a section whose inputs don't
|
||||
# Five independent sections, each SKIP-GRACEFUL (a section whose inputs don't
|
||||
# exist yet prints SKIP and moves on, so CI stays green while the parallel
|
||||
# bootstrap tasks land):
|
||||
#
|
||||
@@ -25,6 +25,14 @@
|
||||
# (d) Phase-list check [D5]: every `Phase: <name>` in BOOTSTRAP_FOR_AGENTS.md
|
||||
# must appear in src/core/bootstrap/status.ts (the TS phase list is the
|
||||
# single source; the runbook defers to it). Skips while either is absent.
|
||||
# (e) Harness-scoping counter-signal pins: the MCP-scope consent is Claude
|
||||
# Code only (Codex has no scope flag — `codex mcp add` is user-global).
|
||||
# Tripwires against accidental deletion of the load-bearing prose, not
|
||||
# proofs of placement: the runbook must carry the Codex bullet's
|
||||
# "Do NOT offer an MCP scope choice" and the phase-3 "Claude Code only"
|
||||
# scoping; questions.json's MCP_SCOPE.question must START WITH
|
||||
# "(Claude Code only". Intentional rewording updates these pins in the
|
||||
# same commit. Skips while the runbook/bank are absent.
|
||||
#
|
||||
# BSD/GNU grep portable (no \t escapes). Uses `bun` for JSON parsing — the
|
||||
# check runs via `bun run verify`, so bun is always present.
|
||||
@@ -187,6 +195,41 @@ else
|
||||
echo "SKIP: phase-list check (runbook or src/core/bootstrap/status.ts absent)"
|
||||
fi
|
||||
|
||||
# ── (e) harness-scoping counter-signal pins (MCP scope is Claude Code only) ─
|
||||
if [ -f "$RUNBOOK" ]; then
|
||||
if ! grep -qF 'Do NOT offer an MCP scope choice' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the Codex counter-signal" >&2
|
||||
echo " ('Do NOT offer an MCP scope choice'). Codex has no scope flag —" >&2
|
||||
echo " without this line, Codex-door agents re-ask a dead question." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
if ! grep -qF 'Claude Code only' "$RUNBOOK"; then
|
||||
fail=1
|
||||
echo "FAIL: BOOTSTRAP_FOR_AGENTS.md lost the 'Claude Code only' scoping on the" >&2
|
||||
echo " MCP-scope consent (phase 3). Without it the consent reads as" >&2
|
||||
echo " harness-blind and Codex-door agents ask it." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
else
|
||||
echo "SKIP: harness-scoping pins (runbook absent)"
|
||||
fi
|
||||
if [ -f "$QUESTIONS" ] && command -v bun >/dev/null 2>&1; then
|
||||
if ! GBRAIN_QJSON="$QUESTIONS" bun -e \
|
||||
'const fs=require("fs");let b;try{b=JSON.parse(fs.readFileSync(process.env.GBRAIN_QJSON,"utf8"));}catch(e){process.exit(1);}if(!b.questions){process.exit(1);}const e=b.questions.MCP_SCOPE;const q=(e&&e.question)||"";process.exit(q.startsWith("(Claude Code only")&&e.phase==="interview"?0:1);'; then
|
||||
fail=1
|
||||
echo "FAIL: questions.json MCP_SCOPE.question must start with '(Claude Code only'" >&2
|
||||
echo " AND MCP_SCOPE.phase must be 'interview' (the consent is recorded" >&2
|
||||
echo " pre-confirm during the interview; a 'wire' phase re-creates the" >&2
|
||||
echo " bank-vs-runbook contradiction). Also fails when the questions" >&2
|
||||
echo " object or the MCP_SCOPE entry is missing, or questions.json fails" >&2
|
||||
echo " to parse — a bank without them silently passes section (a) too." >&2
|
||||
echo " Rewording intentionally? Update this pin in the same commit." >&2
|
||||
fi
|
||||
else
|
||||
echo "SKIP: MCP_SCOPE bank pin (questions.json or bun absent)"
|
||||
fi
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -242,7 +242,22 @@ function consentAnswer(ws: string, key: string): string | undefined {
|
||||
if (read.ok) {
|
||||
const a = read.state.answers[key];
|
||||
if (a?.skipped === true) return 'no';
|
||||
if (a && a.value) return a.value;
|
||||
// Shape-tolerant: interview.json is user-editable and readInterviewState
|
||||
// validates only that `answers` is an object — a hand-edited unusable
|
||||
// value (non-string, empty, or a bare {}) must never throw at a
|
||||
// `.toLowerCase()` call site. FAIL CLOSED, loudly: falling through to a
|
||||
// permissive bank default could flip a damaged opt-out into consent
|
||||
// (cross-model adversarial finding); 'no' resolves every consent key to
|
||||
// its safe reading (no hooks, no cron, project scope).
|
||||
if (a && typeof a.value === 'string' && a.value) return a.value;
|
||||
if (a) {
|
||||
console.error(
|
||||
`note: the recorded ${key} answer in state/interview.json is unusable (invalid shape) — ` +
|
||||
'treating it as declined (fail-closed). Re-record it with `gbrain bootstrap interview` ' +
|
||||
'if that is not what you want.',
|
||||
);
|
||||
return 'no';
|
||||
}
|
||||
}
|
||||
return bank.questions[key]?.default;
|
||||
}
|
||||
@@ -665,6 +680,29 @@ async function runHooks(ws: string, rest: string[], home: string, runner: ExecRu
|
||||
}
|
||||
|
||||
const mcpScope = ((consentAnswer(ws, 'MCP_SCOPE') ?? 'project').toLowerCase() === 'user' ? 'user' : 'project') as 'project' | 'user';
|
||||
// A persisted 'project' answer is meaningless on Codex (`codex mcp add` has no
|
||||
// scope flag) — reachable via attach from a Claude Code machine or a pre-fix
|
||||
// install. Fires on each hooks/repair run while the stale answer persists.
|
||||
// Raw read, NOT consentAnswer: the bank default is 'project', so the resolved
|
||||
// value would fire this note on every Codex install where no one was asked.
|
||||
if (harness === 'codex') {
|
||||
const read = readInterviewState(ws);
|
||||
const raw = read.ok ? read.state.answers['MCP_SCOPE'] : undefined;
|
||||
// typeof guard: readInterviewState validates `answers` is an object but not
|
||||
// per-answer shapes — a hand-edited value of 3 must not throw.
|
||||
if (raw?.skipped !== true && typeof raw?.value === 'string' && raw.value.toLowerCase() === 'project') {
|
||||
console.error(
|
||||
"note: the recorded MCP_SCOPE answer 'project' has no effect on Codex — " +
|
||||
'`codex mcp add` has no scope flag; the registration is user-global (any repo ' +
|
||||
'opened on this machine can reach the brain, read and write, through its MCP tools). ' +
|
||||
'To clear this note safely, drop the answer with `gbrain bootstrap interview --skip MCP_SCOPE`, ' +
|
||||
'then re-confirm the read-back (`--show`, then `--confirm <hash>` — any answer change ' +
|
||||
'invalidates the prior confirmation). Do NOT flip it to user: the answer syncs to paired ' +
|
||||
'Claude Code machines and would widen their scope. Registration off-ramps: ' +
|
||||
'`codex mcp remove gbrain`, or `gbrain bootstrap uninstall` (full teardown).',
|
||||
);
|
||||
}
|
||||
}
|
||||
// HOOKS_CONSENT is a silent default (bank: 'yes') — hooks install unless the
|
||||
// human explicitly opts out with --no-hooks (or a persisted 'no' answer).
|
||||
const hooksConsent = !noHooks && (consentAnswer(ws, 'HOOKS_CONSENT') ?? 'yes').toLowerCase() === 'yes';
|
||||
|
||||
@@ -181,7 +181,9 @@ export const PHASES: PhaseSpec[] = [
|
||||
{
|
||||
id: 'interview',
|
||||
title: 'Identity interview (confirmed read-back)',
|
||||
resume_hint: 'gbrain bootstrap interview --init, then --set each answer, then --confirm <hash>',
|
||||
resume_hint:
|
||||
'gbrain bootstrap interview --init, then --set each answer, then --confirm <hash>. ' +
|
||||
'Claude Code only: also record the MCP scope consent (--set MCP_SCOPE <project|user>) BEFORE --confirm',
|
||||
detect: (ws) => {
|
||||
const exists = existsSync(interviewStatePath(ws));
|
||||
const st = interviewStatus(ws);
|
||||
@@ -233,7 +235,12 @@ export const PHASES: PhaseSpec[] = [
|
||||
{
|
||||
id: 'wire',
|
||||
title: 'Harness wiring (MCP + hooks)',
|
||||
resume_hint: 'gbrain bootstrap hooks --harness <claude-code|codex>',
|
||||
// Static, both-harness hint (no detectHarness branching — status may run
|
||||
// outside the harness being wired). Advisory prose; the grep pins in
|
||||
// scripts/check-bootstrap-templates.sh §(e) are the enforcement.
|
||||
resume_hint:
|
||||
'gbrain bootstrap hooks --harness <claude-code|codex> — MCP scope consent is ' +
|
||||
'Claude Code only (recorded during the interview, pre-confirm); Codex registrations are always user-global (no scope flag)',
|
||||
detect: (ws, ctx) => {
|
||||
const regs = ctx.receipt?.registrations ?? [];
|
||||
if (regs.length > 0) {
|
||||
|
||||
@@ -36,11 +36,17 @@ enter a session (and should not be filed where retrieval can inject it).
|
||||
|
||||
## MCP registration scope
|
||||
|
||||
A `project`-scoped MCP registration exposes this brain only to sessions opened
|
||||
in this folder. A `user`-scoped registration makes the brain queryable from ANY
|
||||
repository opened on this machine — including someone else's checked-out code
|
||||
whose files may carry hostile instructions that try to read it. Prefer project
|
||||
scope; choose user scope only after accepting that tradeoff.
|
||||
Claude Code: a `project`-scoped MCP registration exposes this brain only to
|
||||
sessions opened in this folder. A `user`-scoped registration makes the brain
|
||||
reachable — read and write — from ANY repository opened on this machine,
|
||||
including someone else's checked-out code whose files may carry hostile
|
||||
instructions. Prefer project scope; choose user scope only after accepting
|
||||
that tradeoff.
|
||||
|
||||
Codex: there is no choice — `codex mcp add` has no scope flag, so the
|
||||
registration is always user-global and the tradeoff above is the standing
|
||||
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
|
||||
`gbrain bootstrap uninstall` is the full teardown.
|
||||
|
||||
## The transcript corpus
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
This folder is the workspace of {{AGENT_NAME}}, {{PRINCIPAL_NAME}}'s persistent
|
||||
agent. This agent lives in THIS directory — sessions opened elsewhere do not load
|
||||
it (that is the project-scope registration, by design).
|
||||
its identity files (harnesses load them from the folder you open; the brain's MCP
|
||||
reach is a separate knob — see ACCESS_POLICY.md).
|
||||
|
||||
@AGENTS.md
|
||||
@SOUL.md
|
||||
|
||||
@@ -117,8 +117,8 @@
|
||||
},
|
||||
"MCP_SCOPE": {
|
||||
"consent": true,
|
||||
"phase": "wire",
|
||||
"question": "Register the brain for THIS folder only (recommended — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
|
||||
"phase": "interview",
|
||||
"question": "(Claude Code only. Codex has no scope flag — its registrations are always user-global; on Codex, state that plainly instead of asking.) Register the brain for THIS folder only (recommended — any other repo you open cannot read it), or for every session on this machine (your agent everywhere, but any repo you open can query your brain, and two open sessions will contend for the local database)?",
|
||||
"default": "project",
|
||||
"allowed": ["project", "user"],
|
||||
"maxLength": 8
|
||||
|
||||
@@ -36,11 +36,17 @@ enter a session (and should not be filed where retrieval can inject it).
|
||||
|
||||
## MCP registration scope
|
||||
|
||||
A `project`-scoped MCP registration exposes this brain only to sessions opened
|
||||
in this folder. A `user`-scoped registration makes the brain queryable from ANY
|
||||
repository opened on this machine — including someone else's checked-out code
|
||||
whose files may carry hostile instructions that try to read it. Prefer project
|
||||
scope; choose user scope only after accepting that tradeoff.
|
||||
Claude Code: a `project`-scoped MCP registration exposes this brain only to
|
||||
sessions opened in this folder. A `user`-scoped registration makes the brain
|
||||
reachable — read and write — from ANY repository opened on this machine,
|
||||
including someone else's checked-out code whose files may carry hostile
|
||||
instructions. Prefer project scope; choose user scope only after accepting
|
||||
that tradeoff.
|
||||
|
||||
Codex: there is no choice — `codex mcp add` has no scope flag, so the
|
||||
registration is always user-global and the tradeoff above is the standing
|
||||
state. Off-ramps: `codex mcp remove gbrain` removes just the registration;
|
||||
`gbrain bootstrap uninstall` is the full teardown.
|
||||
|
||||
## The transcript corpus
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
This folder is the workspace of {{AGENT_NAME}}, {{PRINCIPAL_NAME}}'s persistent
|
||||
agent. This agent lives in THIS directory — sessions opened elsewhere do not load
|
||||
it (that is the project-scope registration, by design).
|
||||
its identity files (harnesses load them from the folder you open; the brain's MCP
|
||||
reach is a separate knob — see ACCESS_POLICY.md).
|
||||
|
||||
@AGENTS.md
|
||||
@SOUL.md
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# gbrain agent workspace — template
|
||||
|
||||
<!-- gbrain-template-stamp: 0.45.2.0 -->
|
||||
<!-- gbrain-template-stamp: 0.45.3.0 -->
|
||||
|
||||
This repository is the **"Use this template"** distribution artifact for a
|
||||
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
|
||||
|
||||
@@ -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<T>(parent: string, fn: () => Promise<T>): Promise<T> {
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
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<string, unknown> };
|
||||
// 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`;
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\nPhase: preflight\nPhase: not_a_phase\n',
|
||||
`<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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':
|
||||
'<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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': `<!-- gbrain-runbook-stamp: 1.2.3.4 -->\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', () => {
|
||||
|
||||
Reference in New Issue
Block a user