@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.
@@ -48,6 +48,7 @@ import { canonicalSettingsSubsetHash, defaultDesiredHooks, mergeSettings, pruneH
48
48
  import { validateSettings } from '../config/settings-schema.js';
49
49
  import { ensureReaGitignore } from './install/gitignore.js';
50
50
  import { installPrepareCommitMsgHook } from './install/prepare-commit-msg.js';
51
+ import { checkUpgradeBlockingPin, selfPinRea } from './install/self-pin.js';
51
52
  import { manifestExists, readManifest, writeManifestAtomic } from './install/manifest-io.js';
52
53
  import { sha256OfBuffer, sha256OfFile } from './install/sha.js';
53
54
  import { err, getPkgVersion, log, warn } from './utils.js';
@@ -499,6 +500,65 @@ export async function runUpgrade(options = {}) {
499
500
  `unchanged. zod errors: ${validation.errors.join('; ')}`);
500
501
  }
501
502
  }
503
+ // R9-P1 (codex round 9 / 0.49.0): blocking-pin pre-flight.
504
+ //
505
+ // When package.json pins @bookedsolid/rea to a version that does
506
+ // NOT admit the installed CLI version (workspace:*, file:.., git
507
+ // URLs, dist-tags, exact older pins, cross-major caret), writing
508
+ // 0.49 hooks + policy artifacts on top creates a hook/CLI skew:
509
+ // the bash gates resolve the older CLI from node_modules and that
510
+ // CLI's strict policy loader rejects the new `bootstrap_allowlist:`
511
+ // top-level key, breaking every Bash payload until the operator
512
+ // reconciles the pin.
513
+ //
514
+ // The check is READ-ONLY (single package.json read) and runs
515
+ // BEFORE every artifact-writing step:
516
+ // - migrateReviewPolicyFor0110 (rewrites .rea/policy.yaml)
517
+ // - the canonical file-write loop (hooks, agents, commands)
518
+ // - the dedicated selfPinRea call further below
519
+ // - .gitignore + prepare-commit-msg + manifest writes
520
+ //
521
+ // In dry-run we DESCRIBE the abort condition without exiting
522
+ // non-zero — operators using `rea upgrade --check` get to see the
523
+ // would-block diagnostic, then the dry-run completes normally.
524
+ // In live mode we abort with exit 1 and the operator-facing
525
+ // reconciliation steps.
526
+ {
527
+ const pinCheck = await checkUpgradeBlockingPin({
528
+ cwd: resolvedRoot,
529
+ cliVersion: getPkgVersion(),
530
+ mode: 'upgrade',
531
+ });
532
+ if (pinCheck.kind === 'block' || pinCheck.kind === 'block-symlink') {
533
+ // R10-P2 (codex round 10): block-symlink is the new variant —
534
+ // package.json is a symlink and writing through it would
535
+ // mutate a file outside the requested project tree. Handle
536
+ // it identically to the R9-P1 block case (throw with the
537
+ // operator-actionable reason; dry-run describes without
538
+ // throwing).
539
+ const dryRunHeadline = pinCheck.kind === 'block'
540
+ ? `dry-run: rea upgrade WOULD refuse — pin ${JSON.stringify(pinCheck.existingRange)} ` +
541
+ `in ${path.relative(resolvedRoot, pinCheck.packageJsonPath)} does not admit ` +
542
+ `CLI ${pinCheck.newCliVersion}.`
543
+ : `dry-run: rea upgrade WOULD refuse — ${path.relative(resolvedRoot, pinCheck.packageJsonPath)} ` +
544
+ `is a symlink and rea must not mutate files outside the requested project tree.`;
545
+ if (dryRun) {
546
+ console.log('');
547
+ warn(dryRunHeadline);
548
+ for (const line of pinCheck.reason.split('\n')) {
549
+ console.log(` ${line}`);
550
+ }
551
+ console.log('');
552
+ }
553
+ else {
554
+ // R9-P1: throw (don't process.exit) so the CLI wrapper can
555
+ // surface the message via its standard error path AND tests
556
+ // can assert on the thrown reason. Matches the settings
557
+ // pre-flight (line ~588) which also throws.
558
+ throw new Error(pinCheck.reason);
559
+ }
560
+ }
561
+ }
502
562
  // 0.11.0 migration — strip removed review.* fields and backfill the new
503
563
  // concerns_blocks default. Runs before canonical file reconciliation so a
504
564
  // policy that fails strict schema load (which happens on upgrade from
@@ -681,6 +741,80 @@ export async function runUpgrade(options = {}) {
681
741
  for (const w of pcmResult.warnings)
682
742
  warn(w);
683
743
  }
744
+ // 0.49.0 — self-heal legacy installs that pre-date `rea init`'s
745
+ // self-pin step. Bricks-on-fresh-clone is a 0.48.x-and-earlier
746
+ // problem; `rea upgrade` rolls forward to a healthy state by
747
+ // re-running the same self-pin module.
748
+ //
749
+ // P1-1 (codex round 2): `mode: 'upgrade'` opts the upgrade path
750
+ // into auto-bumping a managed-caret pin when the existing range
751
+ // does not admit the new CLI minor. `^0.49.0` does NOT admit
752
+ // `0.50.0` (npm pre-1.0 caret = tilde semantics) — without auto-
753
+ // bump, `rea upgrade 0.50.0` would copy newer hooks against the
754
+ // older CLI pin and recreate the hook/CLI skew this feature
755
+ // exists to prevent. Auto-bump fires ONLY when the existing
756
+ // range is a strict managed-caret shape (something we wrote);
757
+ // workspace:*, file:.., git URLs, tags, exact pins, tildes, and
758
+ // cross-major bumps all hands-off and warn+skip. See
759
+ // src/cli/install/self-pin.ts::shouldBumpManagedCaret for the
760
+ // gate.
761
+ //
762
+ // R3-P2 (codex round 3): dry-run must surface the SAME action the
763
+ // live run would take — operators ran `rea upgrade --dry-run`,
764
+ // saw zero pin-related output, and were surprised when the live
765
+ // run mutated `package.json`. We now invoke `selfPinRea` in BOTH
766
+ // dry-run and live mode; the helper's `dryRun: true` opt computes
767
+ // the action discriminant without writing. Dry-run console output
768
+ // uses "would" verbs so the planned vs done distinction is
769
+ // unambiguous.
770
+ {
771
+ const selfPinResult = await selfPinRea({
772
+ cwd: resolvedRoot,
773
+ cliVersion: getPkgVersion(),
774
+ mode: 'upgrade',
775
+ dryRun,
776
+ });
777
+ const rel = selfPinResult.packageJsonPath !== null
778
+ ? path.relative(resolvedRoot, selfPinResult.packageJsonPath)
779
+ : null;
780
+ if (selfPinResult.action === 'wrote' && rel !== null) {
781
+ console.log(dryRun
782
+ ? ` ~ ${rel} (would self-pin: write devDependencies["@bookedsolid/rea"] = ${selfPinResult.pinnedRange})`
783
+ : ` ~ ${rel} (self-pin: @bookedsolid/rea@${selfPinResult.pinnedRange})`);
784
+ }
785
+ else if (selfPinResult.action === 'bumped' && rel !== null) {
786
+ // P1-1: surface the bump explicitly so the upgrade log shows
787
+ // exactly what changed about the pin (vs the silent same-bytes
788
+ // case which uses the dot marker).
789
+ console.log(dryRun
790
+ ? ` ~ ${rel} (would self-pin: bump @bookedsolid/rea from ${selfPinResult.existingRange ?? '?'} to ${selfPinResult.pinnedRange})`
791
+ : ` ✓ ${rel} (self-pin: bumped @bookedsolid/rea from ${selfPinResult.existingRange ?? '?'} to ${selfPinResult.pinnedRange})`);
792
+ }
793
+ else if (selfPinResult.action === 'skipped-same' && rel !== null) {
794
+ console.log(` · ${rel} (self-pin: already declared)`);
795
+ }
796
+ else if (selfPinResult.action === 'skipped-different') {
797
+ // Same warn-and-skip path for both dry-run and live — the
798
+ // operator-owned-pin posture doesn't change between modes.
799
+ // The message itself describes the existing pin and the
800
+ // hands-off rationale.
801
+ warn(selfPinResult.message);
802
+ }
803
+ else if (selfPinResult.action === 'skipped-no-package-json') {
804
+ warn('self-pin skipped — no package.json found upward from target; bash gates may refuse without it');
805
+ }
806
+ else if (selfPinResult.action === 'skipped-malformed-package-json') {
807
+ warn(selfPinResult.message);
808
+ }
809
+ else if (selfPinResult.action === 'skipped-symlink-package-json') {
810
+ // R10-P2 (codex round 10): dry-run path only. Live mode throws
811
+ // at the symlink check; reaching this branch means the
812
+ // pre-flight already described the block-symlink condition to
813
+ // the operator earlier in this `rea upgrade --dry-run`
814
+ // invocation. No additional output needed beyond the
815
+ // already-printed pre-flight diagnostic.
816
+ }
817
+ }
684
818
  // BUG-010 — ensure `.gitignore` carries every runtime artifact entry. This
685
819
  // backfills older installs that predate the scaffolding in `rea init`. A
686
820
  // consumer who upgraded from 0.3.x/0.4.0 was previously seeing
@@ -308,6 +308,13 @@ declare const PolicySchema: z.ZodObject<{
308
308
  }, {
309
309
  enabled?: boolean | undefined;
310
310
  }>>;
311
+ bootstrap_allowlist: z.ZodOptional<z.ZodObject<{
312
+ enabled: z.ZodDefault<z.ZodBoolean>;
313
+ }, "strict", z.ZodTypeAny, {
314
+ enabled: boolean;
315
+ }, {
316
+ enabled?: boolean | undefined;
317
+ }>>;
311
318
  }, "strict", z.ZodTypeAny, {
312
319
  version: string;
313
320
  profile: string;
@@ -389,6 +396,9 @@ declare const PolicySchema: z.ZodObject<{
389
396
  shim_cache?: {
390
397
  enabled: boolean;
391
398
  } | undefined;
399
+ bootstrap_allowlist?: {
400
+ enabled: boolean;
401
+ } | undefined;
392
402
  }, {
393
403
  version: string;
394
404
  profile: string;
@@ -470,6 +480,9 @@ declare const PolicySchema: z.ZodObject<{
470
480
  shim_cache?: {
471
481
  enabled?: boolean | undefined;
472
482
  } | undefined;
483
+ bootstrap_allowlist?: {
484
+ enabled?: boolean | undefined;
485
+ } | undefined;
473
486
  }>;
474
487
  /**
475
488
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -354,6 +354,32 @@ const ShimCachePolicySchema = z
354
354
  enabled: z.boolean().default(true),
355
355
  })
356
356
  .strict();
357
+ /**
358
+ * 0.49.0 — bootstrap allowlist policy. Drives the narrow CLI-missing
359
+ * pass-through in `hooks/_lib/bootstrap-allowlist.sh` that the
360
+ * `blocked-paths-bash-gate.sh` and `protected-paths-bash-gate.sh`
361
+ * shims consult when the rea CLI is unreachable. See the helper
362
+ * header for the full security contract.
363
+ *
364
+ * The block is optional — vanilla installs with no
365
+ * `bootstrap_allowlist:` block get the default behavior (enabled).
366
+ * To disable: `bootstrap_allowlist: { enabled: false }`.
367
+ *
368
+ * Strict mode rejects unknown keys so a typo (`enabld`, `enable`)
369
+ * fails loudly at policy load.
370
+ *
371
+ * The bash-tier helper does a narrow YAML grep for the field via
372
+ * inline node (engines.node >=22 guarantees availability) BEFORE the
373
+ * canonical 4-tier policy reader is available (the allowlist runs
374
+ * precisely BECAUSE the CLI is missing). This zod schema validates
375
+ * the field at CLI load time so wrong types / typos are caught at the
376
+ * load boundary.
377
+ */
378
+ const BootstrapAllowlistPolicySchema = z
379
+ .object({
380
+ enabled: z.boolean().default(true),
381
+ })
382
+ .strict();
357
383
  const PolicySchema = z
358
384
  .object({
359
385
  version: z.string(),
@@ -423,6 +449,16 @@ const PolicySchema = z
423
449
  // in env overrides this to `false` for the current invocation
424
450
  // regardless of policy.
425
451
  shim_cache: ShimCachePolicySchema.optional(),
452
+ // 0.49.0 — bootstrap allowlist. Narrow CLI-missing pass-through
453
+ // for the `pnpm install` / `npm ci` / `yarn` / `corepack enable`
454
+ // class of recovery commands. ALWAYS-ON by default; opt-out via
455
+ // `bootstrap_allowlist: { enabled: false }`. The bash-tier
456
+ // gate consults the field via inline node BEFORE the canonical
457
+ // 4-tier policy reader is reachable, since the whole reason it
458
+ // runs is that the CLI is unbuilt. See
459
+ // `hooks/_lib/bootstrap-allowlist.sh` and
460
+ // `THREAT_MODEL.md §5.X`.
461
+ bootstrap_allowlist: BootstrapAllowlistPolicySchema.optional(),
426
462
  })
427
463
  .strict();
428
464
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -121,6 +121,13 @@ export declare const ProfileSchema: z.ZodObject<{
121
121
  threshold?: number | undefined;
122
122
  exempt_subagents?: string[] | undefined;
123
123
  }>>;
124
+ bootstrap_allowlist: z.ZodOptional<z.ZodObject<{
125
+ enabled: z.ZodOptional<z.ZodBoolean>;
126
+ }, "strict", z.ZodTypeAny, {
127
+ enabled?: boolean | undefined;
128
+ }, {
129
+ enabled?: boolean | undefined;
130
+ }>>;
124
131
  }, "strict", z.ZodTypeAny, {
125
132
  autonomy_level?: AutonomyLevel | undefined;
126
133
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -160,6 +167,9 @@ export declare const ProfileSchema: z.ZodObject<{
160
167
  threshold?: number | undefined;
161
168
  exempt_subagents?: string[] | undefined;
162
169
  } | undefined;
170
+ bootstrap_allowlist?: {
171
+ enabled?: boolean | undefined;
172
+ } | undefined;
163
173
  }, {
164
174
  autonomy_level?: AutonomyLevel | undefined;
165
175
  max_autonomy_level?: AutonomyLevel | undefined;
@@ -199,6 +209,9 @@ export declare const ProfileSchema: z.ZodObject<{
199
209
  threshold?: number | undefined;
200
210
  exempt_subagents?: string[] | undefined;
201
211
  } | undefined;
212
+ bootstrap_allowlist?: {
213
+ enabled?: boolean | undefined;
214
+ } | undefined;
202
215
  }>;
203
216
  export type Profile = z.infer<typeof ProfileSchema>;
204
217
  /** Hard defaults applied before any profile or wizard answer. */
@@ -116,6 +116,18 @@ export const ProfileSchema = z
116
116
  })
117
117
  .strict()
118
118
  .optional(),
119
+ // 0.49.0+ bootstrap allowlist (P3-1) — narrow CLI-missing pass-through
120
+ // in `hooks/_lib/bootstrap-allowlist.sh`. The `bst-internal` profile
121
+ // pins `enabled: true` for parity with `.rea/policy.yaml`; every other
122
+ // shipped profile inherits the schema default (also `true`). The
123
+ // profile-layer schema mirrors the policy-loader's
124
+ // `BootstrapAllowlistPolicySchema`; strict mode catches typos at init.
125
+ bootstrap_allowlist: z
126
+ .object({
127
+ enabled: z.boolean().optional(),
128
+ })
129
+ .strict()
130
+ .optional(),
119
131
  })
120
132
  .strict();
121
133
  /** Hard defaults applied before any profile or wizard answer. */
@@ -557,6 +557,44 @@ export interface Policy {
557
557
  * methodology note.
558
558
  */
559
559
  shim_cache?: ShimCachePolicy;
560
+ /**
561
+ * Bootstrap allowlist (0.49.0+).
562
+ *
563
+ * Drives the narrow CLI-missing pass-through in
564
+ * `hooks/_lib/bootstrap-allowlist.sh`. When the bash-tier
565
+ * `blocked-paths-bash-gate.sh` / `protected-paths-bash-gate.sh`
566
+ * shims would refuse a Bash payload because the rea CLI is
567
+ * unreachable, the allowlist consults this block + the
568
+ * consumer's `package.json` to decide whether the payload is a
569
+ * legitimate recovery command (pnpm install / npm ci / yarn /
570
+ * corepack enable / etc.) that should pass through.
571
+ *
572
+ * Always-on by default. Set `enabled: false` to disable.
573
+ * Opt-out is the only knob — the allowlist itself does not
574
+ * accept env-var participation in the decision (security
575
+ * architect locked).
576
+ */
577
+ bootstrap_allowlist?: BootstrapAllowlistPolicy;
578
+ }
579
+ /**
580
+ * Bootstrap allowlist policy (0.49.0+).
581
+ *
582
+ * See `hooks/_lib/bootstrap-allowlist.sh` for the full contract and
583
+ * `THREAT_MODEL.md §5.X` for the threat-model analysis. The single
584
+ * knob is `enabled` — there is no list of allowed shapes in policy;
585
+ * the shape set is hardcoded in the helper because the threat model
586
+ * depends on it being fixed (a consumer-mutable shape list would let
587
+ * an attacker who can edit `policy.yaml` widen the pass-through).
588
+ *
589
+ * `policy.yaml` is itself a `blocked_paths` entry in the
590
+ * `bst-internal` profile (so a consumer who is dogfooding rea's
591
+ * own profile cannot turn the allowlist off via a Bash payload
592
+ * without first earning a separate Write-tier audit event), but
593
+ * the field is intentionally simple so external profiles can drop
594
+ * it back to consumer-editable.
595
+ */
596
+ export interface BootstrapAllowlistPolicy {
597
+ enabled?: boolean;
560
598
  }
561
599
  /**
562
600
  * Per-session shim cache policy (0.48.0+).