@bookedsolid/rea 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/THREAT_MODEL.md CHANGED
@@ -495,6 +495,76 @@ Ref: `hooks/settings-protection.sh:86-336`, `.claude/hooks/settings-protection.s
495
495
 
496
496
  ---
497
497
 
498
+ ### 5.23 Bootstrap Allowlist (0.49.0)
499
+
500
+ **Threat:** Pre-0.49.0, `rea init` wrote `.claude/hooks/blocked-paths-bash-gate.sh` and `.claude/hooks/protected-paths-bash-gate.sh` shims that depend on the `@bookedsolid/rea` CLI being resolvable from `node_modules/`. The init flow did NOT add the dep to the consumer's `package.json`. Any fresh clone of a consumer repo + `pnpm install` produced a brick state where the shims found no CLI and refused 100% of `Bash` calls — including the very `pnpm add -D @bookedsolid/rea` that would recover the install. Hot consumer paths (helixir, BST clients) routinely tripped this whenever a fresh checkout landed.
501
+
502
+ Two paired fixes ship in 0.49.0. The structural fix is `rea init` self-pin (`src/cli/install/self-pin.ts`, §5.23.1 below). The bootstrap allowlist is the safety net that recovers consumers who upgraded to a hooks-shipping `rea init` but were NEVER on the self-pinned init flow.
503
+
504
+ **Scope:** The bootstrap allowlist is a narrow CLI-missing recovery surface. It permits a small set of legitimate-shape PM invocations (bare `pnpm install` / `npm ci` / `yarn install`; `pnpm add -D @bookedsolid/rea` and its npm/yarn equivalents in BARE form) to flow when the CLI is unreachable AND `package.json` already declares `@bookedsolid/rea`. The precondition exists to differentiate consumers who have already opted into `@bookedsolid/rea` (post-`rea init`) from arbitrary repos. It is NOT a defense against agent-initiated package.json mutations — a path-based blocklist on `package.json` cannot distinguish agent writes from PM-tool writes, so that surface is explicitly out of scope here.
505
+
506
+ **Mitigations:**
507
+
508
+ - `hooks/_lib/bootstrap-allowlist.sh` exposes `bootstrap_allowlist_check`. The blocked-paths and protected-paths Bash gates consult it ONLY when (a) the CLI is unreachable AND (b) the substring scan said "this payload looks like it would write a protected/blocked path." When the allowlist returns "allow", the shim exits 0 BEFORE the CLI-missing banner fires.
509
+ - Allowlist is ALWAYS-ON by default (`policy.bootstrap_allowlist.enabled: true`). No env-var ever participates in the decision — `REA_BOOTSTRAP_ALLOW=1`, `REA_FORCE_BOOTSTRAP=*`, etc. are inert. Operators who want to opt out set `policy.bootstrap_allowlist.enabled: false`.
510
+ - Precondition: `<project>/package.json` exists, parses as a strict JSON object, and declares `@bookedsolid/rea` under `dependencies` OR `devDependencies` (NOT `optionalDependencies`, NOT `peerDependencies`, NOT `pnpm.overrides`). Without this declaration, the allowlist refuses every payload. The precondition is "consumer has run `rea init`, so a self-pin already exists" — not a defense against an attacker forging the declaration.
511
+ - The argv shape allowlist is FIXED in the helper, not consumer-mutable from policy. Recognised shapes are precisely:
512
+ - pnpm: `pnpm install`, `pnpm i`, with optional `--frozen-lockfile` / `--no-frozen-lockfile`; `pnpm add -D @bookedsolid/rea` (bare); `pnpm add --save-dev @bookedsolid/rea` (bare).
513
+ - npm: `npm install`, `npm i`, `npm ci`; `npm install -D @bookedsolid/rea` (bare); `npm install --save-dev @bookedsolid/rea` (bare).
514
+ - yarn: `yarn`, `yarn install`; `yarn add -D @bookedsolid/rea` (bare); `yarn add --dev @bookedsolid/rea` (bare).
515
+ - corepack: `corepack enable`, `corepack enable {pnpm,yarn,npm}`, `corepack prepare {pnpm,yarn,npm}@<ver> --activate`.
516
+
517
+ Bun, `pnpm fetch`, `--global`/`-g`, any `--registry=` override, and any `--ignore-scripts` flag are deliberately OUT of the allowlist for 0.49.0 — adding any of them would broaden the contract beyond "recover the brick state."
518
+
519
+ **R6-P2 (codex round 6):** `@bookedsolid/rea` is accepted ONLY in its bare form. Version-pinned shapes `@bookedsolid/rea@<anything>` are REFUSED — this includes dist-tags (`@latest`, `@next`), exact versions (`@0.48.0`), and semver ranges (`@^0.50.0`). Version selection is `rea init` (caret pin at install time) and `rea upgrade` (managed-caret bump, audited via the TS path) territory; the Bash-tier bootstrap path must not allow a CLI-missing session to retarget the trusted gate binary by pinning a chosen version. `corepack prepare pnpm@<ver> --activate` retains the version slot because that selects the package-manager binary (out-of-scope for rea's gate trust), but the rea package spec itself is locked to bare.
520
+ - Multi-segment payloads refuse: the gate's `cmd-segments.sh` segmentation runs ahead of the allowlist, and the helper itself re-counts segments as defense in depth. Any `&&`, `;`, `||`, `|`, newline, or backgrounding refuses.
521
+ - argv[0] basename match is exact-string with no slashes — `./pnpm install`, `/usr/local/bin/pnpm install`, `pnpm/x install` all refuse. The character class for argv[0] is `[A-Za-z0-9._-]`.
522
+ - The package-spec match for `@bookedsolid/rea` is the BARE form only (R6-P2). All versioned forms `@bookedsolid/rea@<ver>` refuse — dist-tags, exact versions, semver ranges, shell metacharacters, command substitutions, and empty `@`-suffixes all hit the refuse branch uniformly.
523
+ - Quoted argv forms (`pnpm "install"`, `'pnpm' install`) refuse-fallthrough. This is a defense feature — a quoted token does NOT match the bare-string shape lists, so any attacker laundering through quotes hits the refuse branch.
524
+ - IFS leakage is defeated by `local IFS=$' \t\n'` declared inside `bootstrap_allowlist_check`. A hostile parent shell that sets `IFS=:` (or worse) cannot reshape the splitter.
525
+ - The `$proj` directory is realpath-resolved via `cd "$CLAUDE_PROJECT_DIR" && pwd -P` before the allowlist sees it. A hostile workspace whose `CLAUDE_PROJECT_DIR` symlinks outside the actual project does not get the allowlist sandbox: the hashing + audit-emit still references the resolved path, NOT the symlink.
526
+ - Every `allow` verdict emits a `rea.bash.bootstrap_allow` audit record through the hash-chained log. Audit fields are fixed-shape: `shim`, `pm`, `argv_shape`, `argv_segments_sha256`, `package_json_sha256`, `package_json_declares_rea`, `declared_version_range` (truncated to 16 chars), `cli_resolution: "missing"`, `policy_enabled`. If `node` is unavailable, or the audit append fails (read-only `.rea/`, hasher missing, disk full), the allowlist REFUSES rather than allowing silently — every allow MUST be auditable.
527
+ - **R19-P1 (codex round 19):** `package.json` is in `blocked_paths` on the `bst-internal` and `bst-internal-no-codex` profiles (and the dogfood `.rea/policy.yaml`). This narrows the Edit/Write-tier forge surface specifically — an L1 Bash session cannot first `Edit` `package.json` to ADD a `@bookedsolid/rea` declaration and then route an otherwise-disallowed PM command through the allowlist's "consumer already declares `@bookedsolid/rea`" precondition. The blocklist gates Write/Edit/MultiEdit/NotebookEdit tool calls via the existing path-based machinery (`settings-protection.sh`, `blocked-paths-enforcer.sh`); it does NOT gate PM-induced writes to the manifest (those are explicitly out of scope, below). External profiles inherit `enabled: true` from the schema default but do NOT add `package.json` to their blocked_paths list (consumers may need to edit package.json freely; the `bst-internal` posture is the strict one).
528
+
529
+ **Trust boundary:** The bootstrap allowlist is reachable only when the CLI is unreachable. The CLI-present path through both gates flows through the full Node-binary scanner (`src/hooks/bash-scanner/`). Once `pnpm install` completes and `node_modules/@bookedsolid/rea/dist/cli/index.js` resolves, every subsequent Bash call is scanned by the canonical scanner — the allowlist no longer participates.
530
+
531
+ **Out of scope (explicit non-claim):**
532
+
533
+ - The allowlist does NOT prevent an attacker who can already write Bash payloads from invoking PM commands that mutate `package.json`. A path-based blocklist on `package.json` (re-added by R19-P1 above) defends against AGENT writes only — it cannot distinguish PM-tool writes from agent writes because the PM is invoked by Bash and the manifest mutation happens inside the PM process, not in argv that a static analyzer can see reliably. Defending against agent-initiated PM mutations requires a different abstraction (process-tree-aware policy, per-tool capability tokens, or operator-confirmed PM invocations) — none of which ship in 0.49.0. The R6-onward "manifest-write static analyzer" surface that would have attempted to close this gap was deliberately removed in R17 (scope-cut) because a path-based blocklist is the wrong abstraction for PM-process introspection. The 0.49.0 contract is solely: "recover the brick state without requiring the operator to disable hooks, AND gate agent-initiated Edit/Write to the manifest."
534
+
535
+ **Residual risks:**
536
+
537
+ - **CLOSED in R6-P2:** Pre-R6 the allowlist accepted `@bookedsolid/rea@<version>` and let a CLI-missing session forge a downgrade attack against a known-vulnerable historical rea version. R6-P2 strips all version-pinned forms from the allowlist; only the bare `@bookedsolid/rea` spec is permitted. Version selection lives in `rea init` (caret pin at install) and `rea upgrade` (managed-caret bump under audit). A CLI-missing Bash session can no longer retarget the trusted gate binary.
538
+ - An attacker who controls a npm registry mirror could publish a malicious `@bookedsolid/rea` once the allowlist runs the install. Mitigation: npm OIDC provenance is verified by `rea init` post-install (out of scope here). Operators on a hostile mirror are exposed regardless of the allowlist. R6-P2 reduces the attack surface because the version range is bounded by the consumer's existing self-pin — the mirror cannot choose which version to ship; the consumer's caret pin does.
539
+ - The `corepack prepare pnpm@<ver> --activate` shape allows arbitrary `<ver>` strings (within the 64-char `[A-Za-z0-9._^~+\-]` charset). This selects the package-MANAGER binary, not rea itself; consumers on a hostile pnpm version have other problems.
540
+ - The allowlist's policy-enabled probe relies on a narrow inline YAML regex (block-form scan, flow-form match). A malformed or pathologically-shaped `bootstrap_allowlist:` block reads as "unparseable" → schema default = enabled. This is intentional: a corrupted policy MUST NOT silently strip the bootstrap recovery path. The full zod schema validation at CLI load time catches typos / wrong types at the canonical boundary.
541
+
542
+ Ref: `hooks/_lib/bootstrap-allowlist.sh`, `hooks/blocked-paths-bash-gate.sh`, `hooks/protected-paths-bash-gate.sh`, `src/policy/loader.ts` (`BootstrapAllowlistPolicySchema`), `__tests__/hooks/bootstrap-allowlist/`.
543
+
544
+ #### 5.23.1 `rea init` self-pin (paired structural fix)
545
+
546
+ **Threat:** Same brick state described in §5.23. The bootstrap allowlist recovers consumers who get stuck; self-pin prevents them from getting stuck in the first place.
547
+
548
+ **Mitigations:**
549
+
550
+ - `rea init` writes `@bookedsolid/rea` as a `^<cli-version>` caret pin in the consumer's `devDependencies`. Workspace-root semantics: the upward walk finds the FIRST `package.json` from the invocation dir and stops; sub-package installs land at the sub-package, not the workspace root.
551
+ - Idempotent: a re-run produces byte-identical `package.json`. Indent, EOL (LF vs CRLF), and trailing-newline are sniffed from the existing file and preserved.
552
+ - Existing different version → warn + skip. The operator's pin is not mutated (covers exact-version reproducibility pins, workspace-relative paths, older-or-newer pins that disagree with the running CLI).
553
+ - Dogfood short-circuit: `pkg.name === '@bookedsolid/rea'` skips silently.
554
+ - `rea doctor` runs `checkSelfPinDeclared` and emits a `fail` row when `.claude/hooks/` is installed but no pin is declared. The recovery instruction names `rea upgrade` (which re-runs the self-pin step). This is the brick-state detector.
555
+ - `rea upgrade` re-runs `selfPinRea` so legacy installs that pre-date 0.49.0 self-heal on the next upgrade. Same warn-and-skip posture on existing different version.
556
+
557
+ **Trust boundary:** Self-pin only writes the `package.json` entry. It does not invoke any package manager, does not run lifecycle scripts, does not modify the lockfile. The consumer's next `pnpm install` is what materializes the install — same trust posture as a manual `pnpm add -D @bookedsolid/rea` would have.
558
+
559
+ **Residual risks:**
560
+
561
+ - An operator who runs `rea init` in a repo whose `package.json` is owned by a parent workspace will get a pin in the parent (the closest package.json found by the upward walk). This matches the architect's locked decision. Operators with per-package install discipline should run `rea init` from each sub-package directory after seeding a sub-package package.json.
562
+ - An operator who pinned a workspace-relative path (`workspace:^`, `file:../rea`) gets `skipped-different` on every re-run. This is the correct behavior — the operator owns the pin. The doctor's `pass` detection accepts any non-empty string in dependencies/devDependencies as a valid pin (including workspace specs).
563
+
564
+ Ref: `src/cli/install/self-pin.ts`, `src/cli/init.ts`, `src/cli/upgrade.ts`, `src/cli/doctor.ts` (`checkSelfPinDeclaredCheck`), `__tests__/cli/self-pin.test.ts`.
565
+
566
+ ---
567
+
498
568
  ## 6. Residual Risks and Open Issues
499
569
 
500
570
  | Risk | Severity | Status / Tracking |
@@ -29,6 +29,7 @@ export declare function checkFingerprintStore(baseDir: string): Promise<CheckRes
29
29
  */
30
30
  export declare const EXPECTED_AGENTS: string[];
31
31
  export declare const EXPECTED_HOOKS: string[];
32
+ export declare function checkSelfPinDeclaredCheck(baseDir: string): CheckResult;
32
33
  /**
33
34
  * 0.30.0 Class M — validate `.claude/settings.json` against the zod
34
35
  * schema in `src/config/settings-schema.ts`.
@@ -19,6 +19,7 @@ import { DELEGATION_SIGNAL_TOOL_NAME } from '../audit/delegation-event.js';
19
19
  import { computeHash } from '../audit/fs.js';
20
20
  import { PREPARE_COMMIT_MSG_BODY_MARKER, PREPARE_COMMIT_MSG_MARKER, } from './install/prepare-commit-msg.js';
21
21
  import { validateSettings } from '../config/settings-schema.js';
22
+ import { checkSelfPinDeclaredSync, REA_PACKAGE_NAME, stripUtf8Bom } from './install/self-pin.js';
22
23
  import { POLICY_FILE, REA_DIR, REGISTRY_FILE, getPkgVersion, log, reaPath } from './utils.js';
23
24
  function checkFileExists(label, filePath, fatal) {
24
25
  const exists = fs.existsSync(filePath);
@@ -255,6 +256,240 @@ function checkHooksInstalled(baseDir) {
255
256
  }
256
257
  return { label: 'hooks installed + executable', status: 'fail', detail: issues.join('; ') };
257
258
  }
259
+ /**
260
+ * 0.49.0 — fail when `.claude/hooks/` is present but no `@bookedsolid/rea`
261
+ * pin is declared in the consumer's `package.json`. This is the "brick
262
+ * state" detector — a fresh clone of a consumer repo whose hook shims
263
+ * exist but whose dependency declaration is missing is exactly the
264
+ * scenario the bash-gate bootstrap allowlist (paired Fix B) recovers
265
+ * from. Doctor surfaces it loudly so the operator runs `rea upgrade`
266
+ * (which re-runs the self-pin step) before assuming the shims have
267
+ * drifted.
268
+ *
269
+ * Statuses:
270
+ * - `pass` — hooks installed AND pin declared (in dependencies or
271
+ * devDependencies).
272
+ * - `pass` — no `.claude/hooks/` directory (check is N/A; the
273
+ * brick scenario does not exist).
274
+ * - `pass` — `pkg.name === '@bookedsolid/rea'` (dogfood — never
275
+ * self-pins).
276
+ * - `warn` — hooks installed but NO `package.json` upward. The
277
+ * bootstrap allowlist requires a pkg.json precondition,
278
+ * so without one the gates cannot self-recover anyway.
279
+ * We warn rather than fail to avoid spamming non-Node
280
+ * consumers who landed `.claude/hooks/` through a
281
+ * separate vendoring flow.
282
+ * - `fail` — hooks installed, package.json found, no rea pin.
283
+ * This is the brick state.
284
+ * - `fail` — package.json exists but is malformed/non-object.
285
+ */
286
+ /**
287
+ * R18-P1 (codex round 18) / R19-P2 (codex round 19): resolve the
288
+ * `@bookedsolid/rea` version that the consumer's HOOK SCRIPTS will
289
+ * actually invoke at runtime.
290
+ *
291
+ * Two layouts the shim chain accepts (mirrors `resolveCliDistPath`
292
+ * above):
293
+ *
294
+ * 1. `<baseDir>/node_modules/@bookedsolid/rea/package.json` — the
295
+ * consumer install (`pnpm i @bookedsolid/rea`). The version is
296
+ * that file's `version` field.
297
+ *
298
+ * 2. `<baseDir>/dist/cli/index.js` present AND `<baseDir>/package.json`
299
+ * has `name === '@bookedsolid/rea'` — the rea-repo dogfood
300
+ * after `pnpm build`. The dist is a build output of the same
301
+ * package.json that declares the rea CLI, so its `version`
302
+ * field is the CLI version the dogfood hooks will resolve.
303
+ *
304
+ * Pre-R18 doctor passed `getPkgVersion()` (the version of whatever
305
+ * `rea` binary launched `rea doctor`) into the pin-compat check.
306
+ * That caused false failures whenever an operator ran a newer
307
+ * GLOBAL CLI (e.g. `rea@0.50.0`) against a repo whose `package.json`
308
+ * intentionally pinned an older but compatible version. The repo's
309
+ * hooks resolve the LOCAL CLI; the global binary is irrelevant.
310
+ *
311
+ * R19-P2 narrows the fix: when neither layout resolves, return
312
+ * `null` — and the caller SKIPS the compat check entirely (no
313
+ * fallback to `getPkgVersion()`). Fresh clones (pre-`pnpm install`)
314
+ * and broken dist builds fall into this branch and now pass instead
315
+ * of false-failing.
316
+ *
317
+ * Returns `null` on any read/parse error or when no recognized
318
+ * layout matches. Best-effort, single read per call. Never throws.
319
+ */
320
+ function resolveLocalCliVersion(baseDir) {
321
+ // Layout 1: consumer install via node_modules.
322
+ const nmPkgPath = path.join(baseDir, 'node_modules', '@bookedsolid', 'rea', 'package.json');
323
+ const nmVersion = readPackageVersion(nmPkgPath);
324
+ if (nmVersion !== null)
325
+ return nmVersion;
326
+ // Layout 2: rea-repo dogfood. The CLI is `<baseDir>/dist/cli/
327
+ // index.js`; the source of truth for its version is the SAME
328
+ // `<baseDir>/package.json` that declares it. Guard with name-
329
+ // match so we never mis-identify a consumer's package.json
330
+ // (which lacks the dist build) as a rea install.
331
+ const distCliPath = path.join(baseDir, 'dist', 'cli', 'index.js');
332
+ if (!fs.existsSync(distCliPath))
333
+ return null;
334
+ const dogfoodPkgPath = path.join(baseDir, 'package.json');
335
+ let raw;
336
+ try {
337
+ raw = fs.readFileSync(dogfoodPkgPath, 'utf8');
338
+ }
339
+ catch {
340
+ return null;
341
+ }
342
+ raw = stripUtf8Bom(raw);
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(raw);
346
+ }
347
+ catch {
348
+ return null;
349
+ }
350
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
351
+ return null;
352
+ }
353
+ const pkg = parsed;
354
+ if (pkg['name'] !== REA_PACKAGE_NAME)
355
+ return null;
356
+ const version = pkg['version'];
357
+ return typeof version === 'string' && version.length > 0 ? version : null;
358
+ }
359
+ /**
360
+ * Helper for `resolveLocalCliVersion` — read a package.json's
361
+ * `version` field with the same BOM tolerance + defensive parse
362
+ * posture as the rest of the self-pin surface. Returns `null` on
363
+ * any failure.
364
+ */
365
+ function readPackageVersion(pkgPath) {
366
+ let raw;
367
+ try {
368
+ raw = fs.readFileSync(pkgPath, 'utf8');
369
+ }
370
+ catch {
371
+ return null;
372
+ }
373
+ raw = stripUtf8Bom(raw);
374
+ let parsed;
375
+ try {
376
+ parsed = JSON.parse(raw);
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
382
+ return null;
383
+ }
384
+ const version = parsed['version'];
385
+ return typeof version === 'string' && version.length > 0 ? version : null;
386
+ }
387
+ export function checkSelfPinDeclaredCheck(baseDir) {
388
+ const label = `${REA_PACKAGE_NAME} declared in package.json`;
389
+ try {
390
+ // R11-P3 (codex round 11): pass the running CLI version so the
391
+ // check verifies the declared range admits it (not just that
392
+ // it's declared). Pre-R11 the check was presence-only — a stale
393
+ // pin like `"0.48.0"` reported pass even though the running
394
+ // CLI was 0.49.x. Doctor's job is to catch skew BEFORE it
395
+ // bricks the consumer.
396
+ //
397
+ // R18-P1 (codex round 18): prefer the LOCAL CLI's version
398
+ // (`<baseDir>/node_modules/@bookedsolid/rea`) over the global
399
+ // invoker's. The consumer's HOOKS resolve the local install at
400
+ // runtime, so that's the version the pin must admit.
401
+ //
402
+ // R19-P2 (codex round 19): when the local CLI is absent (no
403
+ // node_modules layout AND no dogfood dist build), SKIP the
404
+ // compat check entirely. Falling back to the invoker version
405
+ // (the R18-P1 implementation) still produced false-fails on
406
+ // fresh clones — an operator's newer global `rea@0.50.0`
407
+ // running doctor against a repo pinned `^0.49.0` before
408
+ // `pnpm install` saw fail-incompatible despite no real
409
+ // problem. With local CLI absent we cannot determine what
410
+ // version the hooks will run, so we report pass and let
411
+ // pnpm's own resolution-time errors surface any actual pin
412
+ // skew during install. The existing `fail-no-pin` /
413
+ // `fail-malformed` arms continue to catch the brick states.
414
+ const resolvedCliVersion = resolveLocalCliVersion(baseDir);
415
+ const result = checkSelfPinDeclaredSync(baseDir, resolvedCliVersion ?? undefined);
416
+ switch (result.kind) {
417
+ case 'pass':
418
+ return {
419
+ label,
420
+ status: 'pass',
421
+ detail: `declared in ${result.declaredIn} as ${result.declaredRange}`,
422
+ };
423
+ case 'pass-no-hooks':
424
+ return {
425
+ label,
426
+ status: 'pass',
427
+ detail: 'no .claude/hooks/ — check is N/A',
428
+ };
429
+ case 'pass-dogfood':
430
+ return {
431
+ label,
432
+ status: 'pass',
433
+ detail: 'dogfood install (pkg.name === @bookedsolid/rea)',
434
+ };
435
+ case 'pass-no-pkg':
436
+ return {
437
+ label,
438
+ status: 'warn',
439
+ detail: 'hook shims installed but no package.json found upward — bash gates will refuse on fresh clones (the bootstrap allowlist requires a package.json precondition)',
440
+ };
441
+ case 'fail':
442
+ return {
443
+ label,
444
+ status: 'fail',
445
+ detail: `hook shims at ${path.relative(baseDir, result.hooksDir)} but ${REA_PACKAGE_NAME} is not declared in ${path.relative(baseDir, result.packageJsonPath)}. ` +
446
+ `Fresh clones will brick (bash gates refuse without a CLI). Run \`rea upgrade\` to self-heal.`,
447
+ };
448
+ case 'fail-malformed':
449
+ return {
450
+ label,
451
+ status: 'fail',
452
+ detail: `${path.relative(baseDir, result.packageJsonPath)} is missing or not a valid JSON object`,
453
+ };
454
+ // R10-P2 (codex round 10): symlinked package.json. Surface
455
+ // the write-path's refusal verbatim so the operator sees the
456
+ // same diagnostic at doctor time as they would at upgrade
457
+ // time (avoiding drift between the two surfaces).
458
+ case 'fail-symlink':
459
+ return {
460
+ label,
461
+ status: 'fail',
462
+ detail: result.reason,
463
+ };
464
+ // R11-P3 (codex round 11): declared range doesn't admit the
465
+ // running CLI. Surface the helper's full reason — it includes
466
+ // the recovery command and the explainer.
467
+ case 'fail-incompatible':
468
+ return {
469
+ label,
470
+ status: 'fail',
471
+ detail: result.reason,
472
+ };
473
+ // R11-P3: declared as workspace:* / file:.. / git URL / dist-
474
+ // tag. Can't statically determine whether the resolved version
475
+ // admits the running CLI; surface as fail so the operator
476
+ // audits the resolution path.
477
+ case 'fail-non-semver':
478
+ return {
479
+ label,
480
+ status: 'fail',
481
+ detail: result.reason,
482
+ };
483
+ }
484
+ }
485
+ catch (e) {
486
+ return {
487
+ label,
488
+ status: 'fail',
489
+ detail: e instanceof Error ? e.message : String(e),
490
+ };
491
+ }
492
+ }
258
493
  /**
259
494
  * 0.30.0 Class M — validate `.claude/settings.json` against the zod
260
495
  * schema in `src/config/settings-schema.ts`.
@@ -2239,6 +2474,12 @@ export function collectChecks(baseDir, codexProbeState, prePushState, options =
2239
2474
  checkRegistryParses(baseDir, registryPath),
2240
2475
  checkAgentsPresent(baseDir),
2241
2476
  checkHooksInstalled(baseDir),
2477
+ // 0.49.0 brick-state detector. Hook shims installed without a
2478
+ // self-pin in package.json is the exact scenario the bash-gate
2479
+ // bootstrap allowlist (paired fix) recovers from. Doctor surfaces
2480
+ // it as a hard FAIL so the operator runs `rea upgrade` (which
2481
+ // re-runs self-pin) before assuming the gates are broken.
2482
+ checkSelfPinDeclaredCheck(baseDir),
2242
2483
  checkSettingsJson(baseDir),
2243
2484
  // 0.30.0 Class M — strict zod schema check of the full
2244
2485
  // .claude/settings.json shape. Complements checkSettingsJson
@@ -55,6 +55,18 @@ export interface ResolvedConfig {
55
55
  email?: string;
56
56
  skipMerge?: boolean;
57
57
  };
58
+ /**
59
+ * R12-P1 (codex round 12 / 0.49.0): bootstrap_allowlist.enabled.
60
+ * Preserved across re-init so an operator who opted out via
61
+ * `bootstrap_allowlist: { enabled: false }` doesn't get silently
62
+ * re-enabled by the next `rea init`. Seeded from the layered
63
+ * profile on first install (only `bst-internal` currently pins
64
+ * `enabled: true` explicitly; every other profile inherits the
65
+ * zod schema default which is also `true`). When `undefined`, the
66
+ * writer emits no block — consumers fall through to the schema
67
+ * default at policy load.
68
+ */
69
+ bootstrapAllowlistEnabled?: boolean;
58
70
  fromReagent: boolean;
59
71
  reagentPolicyPath: string | null;
60
72
  reagentNotices: string[];
package/dist/cli/init.js CHANGED
@@ -7,6 +7,7 @@ import { AutonomyLevel } from '../policy/types.js';
7
7
  import { HARD_DEFAULTS, loadProfile, mergeProfiles } from '../policy/profiles.js';
8
8
  import { copyArtifacts } from './install/copy.js';
9
9
  import { ensureReaGitignore } from './install/gitignore.js';
10
+ import { checkUpgradeBlockingPin, selfPinRea } from './install/self-pin.js';
10
11
  import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, readSettings, writeSettingsAtomic, } from './install/settings-merge.js';
11
12
  import { EXPECTED_HOOKS } from './doctor.js';
12
13
  import { installCommitMsgHook } from './install/commit-msg.js';
@@ -303,6 +304,9 @@ async function runWizard(options, targetDir, reagentPolicyPath, layeredBase, exi
303
304
  // `enabled: false`). Conditional spread so undefined → key omitted
304
305
  // (the field is exact-optional).
305
306
  ...attributionConfigSpread(layeredBase, existingPolicy),
307
+ // R12-P1 (codex round 12): preserve bootstrap_allowlist.enabled
308
+ // so an operator opt-out survives `rea init` re-runs.
309
+ ...bootstrapAllowlistConfigSpread(layeredBase, existingPolicy),
306
310
  fromReagent,
307
311
  reagentPolicyPath,
308
312
  reagentNotices: [],
@@ -348,6 +352,43 @@ function attributionConfigSpread(layered, existing) {
348
352
  },
349
353
  };
350
354
  }
355
+ /**
356
+ * R12-P1 (codex round 12 / 0.49.0): same shape-spread helper for
357
+ * `bootstrap_allowlist.enabled`. Precedence:
358
+ *
359
+ * 1. Existing on-disk policy `bootstrap_allowlist.enabled` (highest).
360
+ * 2. Layered profile's `bootstrap_allowlist.enabled` (e.g.
361
+ * `bst-internal` pins `true` explicitly).
362
+ * 3. Omitted — the writer skips emission and consumers fall through
363
+ * to the zod schema default at policy-load time.
364
+ *
365
+ * The omit-vs-emit distinction matters: external profiles
366
+ * (open-source, client-engagement, etc.) leave the block unset, and
367
+ * we want a clean policy.yaml that does NOT mention the field unless
368
+ * the operator or the profile explicitly pinned it. That preserves
369
+ * the existing emit-only-when-set posture for the other preserved
370
+ * keys (local_review, commit_hygiene).
371
+ *
372
+ * Pre-R12 the field was dropped entirely on re-init — an operator
373
+ * who opted out via `bootstrap_allowlist: { enabled: false }` got
374
+ * silently flipped back to `true` (schema default). This helper
375
+ * closes that drop class.
376
+ */
377
+ function bootstrapAllowlistConfigSpread(layered, existing) {
378
+ // Existing on-disk policy wins — preserves explicit opt-out.
379
+ if (existing?.bootstrapAllowlistEnabled !== undefined) {
380
+ return { bootstrapAllowlistEnabled: existing.bootstrapAllowlistEnabled };
381
+ }
382
+ // Profile-layer value next — `bst-internal` pins `true` explicitly
383
+ // so the on-disk policy shows the pinned posture rather than
384
+ // relying on the schema default.
385
+ const fromProfile = layered.bootstrap_allowlist?.enabled;
386
+ if (fromProfile !== undefined) {
387
+ return { bootstrapAllowlistEnabled: fromProfile };
388
+ }
389
+ // Neither set — omit from the emitted policy.yaml.
390
+ return {};
391
+ }
351
392
  /**
352
393
  * G6 — Codex install-assist probe.
353
394
  *
@@ -563,6 +604,20 @@ function readExistingPolicyForPreservation(targetDir) {
563
604
  out.attributionCoAuthor = preserved;
564
605
  }
565
606
  }
607
+ // R12-P1 (codex round 12 / 0.49.0): preserve bootstrap_allowlist
608
+ // across re-init. Critical for the documented opt-out — an
609
+ // operator who set `bootstrap_allowlist: { enabled: false }` MUST
610
+ // NOT have it silently flipped back to `true` by the next init.
611
+ // Inline (`bootstrap_allowlist: { enabled: false }`) and block
612
+ // (`bootstrap_allowlist:\n enabled: false`) forms fold to the
613
+ // same parsed object via yaml.parse.
614
+ const bootstrapAllowlist = policy['bootstrap_allowlist'];
615
+ if (bootstrapAllowlist !== null && typeof bootstrapAllowlist === 'object') {
616
+ const ba = bootstrapAllowlist;
617
+ if (typeof ba['enabled'] === 'boolean') {
618
+ out.bootstrapAllowlistEnabled = ba['enabled'];
619
+ }
620
+ }
566
621
  return out;
567
622
  }
568
623
  function readExistingInstalledAt(policyPath) {
@@ -720,6 +775,16 @@ function writePolicyYaml(targetDir, config, layered) {
720
775
  lines.push(` refuse_at_commits: ${config.commitHygieneRefuseAtCommits}`);
721
776
  }
722
777
  }
778
+ // R12-P1 (codex round 12 / 0.49.0): emit bootstrap_allowlist when
779
+ // the layered profile or the existing on-disk policy declared it.
780
+ // When unset, omit the block — consumers fall through to the zod
781
+ // schema default (`enabled: true`). The block form (vs flow form)
782
+ // mirrors what `bst-internal.yaml` emits so dogfood byte-fidelity
783
+ // is preserved.
784
+ if (config.bootstrapAllowlistEnabled !== undefined) {
785
+ lines.push(`bootstrap_allowlist:`);
786
+ lines.push(` enabled: ${config.bootstrapAllowlistEnabled ? 'true' : 'false'}`);
787
+ }
723
788
  lines.push(``);
724
789
  fs.writeFileSync(policyPath, lines.join('\n'), 'utf8');
725
790
  return policyPath;
@@ -1425,6 +1490,9 @@ export async function runInit(options) {
1425
1490
  // seeded from the layered profile. Same precedence as the
1426
1491
  // wizard path above. Conditional spread for exact-optional.
1427
1492
  ...attributionConfigSpread(layeredBase, existingPolicy),
1493
+ // R12-P1 (codex round 12): preserve bootstrap_allowlist.enabled
1494
+ // so an operator opt-out survives `rea init` re-runs.
1495
+ ...bootstrapAllowlistConfigSpread(layeredBase, existingPolicy),
1428
1496
  fromReagent,
1429
1497
  reagentPolicyPath,
1430
1498
  reagentNotices,
@@ -1459,6 +1527,40 @@ export async function runInit(options) {
1459
1527
  cancel('Init cancelled — no files written.');
1460
1528
  }
1461
1529
  }
1530
+ // R11-P1 (codex round 11): blocking-pin pre-flight. Same security
1531
+ // guarantee as `runUpgrade`'s pre-flight (R9-P1) but for the init
1532
+ // surface. If `package.json` already pins `@bookedsolid/rea` to a
1533
+ // version that does NOT admit the installed CLI version
1534
+ // (workspace:*, file:.., git URLs, dist-tags, exact older pins,
1535
+ // cross-major caret), writing 0.49 hooks + policy artifacts on
1536
+ // top creates a hook/CLI skew: the bash gates resolve the older
1537
+ // CLI from node_modules and that CLI's strict policy loader
1538
+ // rejects the new `bootstrap_allowlist:` top-level key.
1539
+ //
1540
+ // Pre-R11 the pre-flight was upgrade-only. Operators running `rea
1541
+ // init` to reinstall (a common pattern on consumer repos) hit
1542
+ // the same trap. We run the same check here, BEFORE the first
1543
+ // `.rea/` mkdir — so a refused init leaves the consumer's
1544
+ // existing state untouched.
1545
+ //
1546
+ // Fresh-clone repos (no existing pin) return `kind: 'ok'` from
1547
+ // the check, so this branch is a no-op for the canonical init
1548
+ // path.
1549
+ {
1550
+ const initPinCheck = await checkUpgradeBlockingPin({
1551
+ cwd: targetDir,
1552
+ cliVersion: getPkgVersion(),
1553
+ mode: 'init',
1554
+ });
1555
+ if (initPinCheck.kind === 'block' || initPinCheck.kind === 'block-symlink') {
1556
+ // R10-P2: block-symlink is the symlinked-pkg.json variant.
1557
+ // Both kinds share the `reason` field; throw with the
1558
+ // operator-actionable explainer. The throw lands in
1559
+ // `main().catch(...)` (src/cli/index.ts) which surfaces the
1560
+ // multi-line message via the standard `err` path.
1561
+ throw new Error(initPinCheck.reason);
1562
+ }
1563
+ }
1462
1564
  if (!fs.existsSync(reaDir))
1463
1565
  fs.mkdirSync(reaDir, { recursive: true });
1464
1566
  // 0.43.0 UX polish: wrap the file-write phase in a clack spinner so
@@ -1477,6 +1579,7 @@ export async function runInit(options) {
1477
1579
  let prePushResult;
1478
1580
  let mdResult;
1479
1581
  let gitignoreResult;
1582
+ let selfPinResult;
1480
1583
  let manifestPath;
1481
1584
  let fragmentInput;
1482
1585
  try {
@@ -1514,6 +1617,32 @@ export async function runInit(options) {
1514
1617
  // `rea serve` / `rea cache` / `/freeze` can write under `.rea/`. Idempotent
1515
1618
  // append (and `rea upgrade` backfills older installs that never got this).
1516
1619
  gitignoreResult = await ensureReaGitignore(targetDir);
1620
+ // 0.49.0 — self-pin `@bookedsolid/rea` as `^<cli-version>` in the
1621
+ // consumer's package.json (devDependencies). Without this, the hook
1622
+ // shims that init JUST wrote depend on a CLI that the next `pnpm
1623
+ // install` does not actually install. The bash-gate bootstrap
1624
+ // allowlist (Fix B) recovers the brick state when the dep IS
1625
+ // declared but the CLI is not yet built; without the dep declared,
1626
+ // the allowlist refuses (no precondition forge route — must be a
1627
+ // legitimate top-level declaration). See `src/cli/install/self-pin.ts`
1628
+ // for the full contract.
1629
+ //
1630
+ // R13-P1 (codex round 13): `mode: 'upgrade'` — managed-caret pins
1631
+ // that don't admit the installed CLI MUST bump in place during
1632
+ // `rea init` too. Pre-R13 init used the default `mode: 'init'`
1633
+ // (warn-and-skip), but the R11-P1 preflight already filters out
1634
+ // the non-managed-caret cases (workspace, file:, git, dist-tag,
1635
+ // exact) — so anything reaching this line is either a fresh
1636
+ // write OR a managed-caret bump. `mode: 'upgrade'` is the right
1637
+ // semantics for both. Without this fix, `rea init` on a repo
1638
+ // with `^0.49.0` + CLI 0.50.0 wrote new hooks/policy but left
1639
+ // the pin behind — recreating the hook/CLI skew the preflight
1640
+ // was supposed to prevent.
1641
+ selfPinResult = await selfPinRea({
1642
+ cwd: targetDir,
1643
+ cliVersion: getPkgVersion(),
1644
+ mode: 'upgrade',
1645
+ });
1517
1646
  // G12 — record the install manifest. SHAs are of the files actually on disk
1518
1647
  // after the copy pass, so drift detection compares against real state (not
1519
1648
  // canonical, which may differ if the consumer's copy was aborted mid-run).
@@ -1577,6 +1706,38 @@ export async function runInit(options) {
1577
1706
  }
1578
1707
  for (const w of gitignoreResult.warnings)
1579
1708
  warn(w);
1709
+ // 0.49.0 self-pin reporting. One line per outcome; warn-and-skip is
1710
+ // surfaced loudly so the operator notices a mismatched pin.
1711
+ //
1712
+ // R18-P2 (codex round 18): R13-P1 switched `rea init` to
1713
+ // `mode: 'upgrade'` so re-running init on a repo with a managed-
1714
+ // caret pin from an older CLI auto-bumps to the new CLI's caret.
1715
+ // The reporting ladder lacked a `'bumped'` arm — the file was
1716
+ // mutated but the success summary printed no line about it,
1717
+ // making the install output incomplete. Mirrors the `'bumped'`
1718
+ // arm in `rea upgrade` (see src/cli/upgrade.ts) so the operator
1719
+ // sees the pin delta explicitly in both surfaces.
1720
+ if (selfPinResult.action === 'wrote' && selfPinResult.packageJsonPath !== null) {
1721
+ console.log(` ~ ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: @bookedsolid/rea@${selfPinResult.pinnedRange})`);
1722
+ }
1723
+ else if (selfPinResult.action === 'bumped' && selfPinResult.packageJsonPath !== null) {
1724
+ console.log(` ✓ ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: bumped @bookedsolid/rea from ${selfPinResult.existingRange ?? '?'} to ${selfPinResult.pinnedRange})`);
1725
+ }
1726
+ else if (selfPinResult.action === 'skipped-same' && selfPinResult.packageJsonPath !== null) {
1727
+ console.log(` · ${path.relative(targetDir, selfPinResult.packageJsonPath)} (self-pin: already declared)`);
1728
+ }
1729
+ else if (selfPinResult.action === 'skipped-different') {
1730
+ warn(selfPinResult.message);
1731
+ }
1732
+ else if (selfPinResult.action === 'skipped-dogfood') {
1733
+ // Silent — dogfood install, expected.
1734
+ }
1735
+ else if (selfPinResult.action === 'skipped-no-package-json') {
1736
+ warn('self-pin skipped — no package.json found upward from target; bash gates will refuse on a fresh clone unless you add `@bookedsolid/rea` to a package.json');
1737
+ }
1738
+ else if (selfPinResult.action === 'skipped-malformed-package-json') {
1739
+ warn(selfPinResult.message);
1740
+ }
1580
1741
  console.log(` + ${path.relative(targetDir, manifestPath)}`);
1581
1742
  if (mergeResult.warnings.length > 0) {
1582
1743
  console.log('');