@bookedsolid/rea 0.49.0 → 0.50.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.
@@ -187,8 +187,14 @@ shim_resolve_cli() {
187
187
  # 2. an ancestor package.json has `name`=`@bookedsolid/rea`
188
188
  # 3. (when SHIM_ENFORCE_CLI_SHAPE=1) realpath ends in dist/cli/index.js
189
189
  #
190
- # Echoes "ok" on success or "bad:<reason>" on failure. Caller compares
191
- # to "ok".
190
+ # Echoes "ok:<realpath>" on success or "bad:<reason>" on failure.
191
+ # Callers branch on the `ok:` prefix (bash 3.2: `case "$x" in ok:*)`)
192
+ # and use the realpath tail for realpath-exec (shim_run step 4a). The
193
+ # realpath is the same fs.realpathSync the sandbox already computed, so
194
+ # executing it shrinks the TOCTOU window to the in-place-swap residual
195
+ # the in-project tier already carries — it is NOT a same-inode
196
+ # guarantee (a rename-swap of the realpath after validation remains
197
+ # possible).
192
198
  #
193
199
  # Args:
194
200
  # $1 — resolved CLI path
@@ -240,10 +246,310 @@ shim_sandbox_check() {
240
246
  cur = path.dirname(cur);
241
247
  }
242
248
  if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
243
- process.stdout.write("ok");
249
+ // TOCTOU precursor: emit the validated realpath so the caller can
250
+ // execute it instead of the literal (possibly-symlinked) cli path.
251
+ // `real` is the fs.realpathSync(cli) computed at the top of this
252
+ // program. The `bad:<reason>` outputs above are byte-unchanged.
253
+ process.stdout.write("ok:" + real);
244
254
  ' -- "$cli_path" "$proj_dir" "$enforce_shape" 2>/dev/null
245
255
  }
246
256
 
257
+ # -----------------------------------------------------------------------------
258
+ # Global tier — A5 entry gate (registry ∧ membership) + resolve. 0.49.0
259
+ # Phase 1b. Derives the per-user trust root from the PASSWORD DATABASE
260
+ # (os.userInfo().homedir → libuv getpwuid_r), NEVER from $HOME / $XDG_* —
261
+ # an agent can set those in-process, so an env-derived root is the N3
262
+ # redirect surface this tier closes. A trust root an agent can move is not
263
+ # a trust root.
264
+ #
265
+ # Registry-ONLY (codex design-gate P1-1): NO policy read in this pre-CLI
266
+ # window. policy.yaml is NOT scanner-protected while no CLI is resolved, so
267
+ # the optional `allow_global_cli` veto lands POST-resolution in a later
268
+ # phase (registry can only ENABLE; policy can only further-RESTRICT — the
269
+ # asymmetry holds).
270
+ #
271
+ # Every A5 / registry-unusable / not-installed condition prints
272
+ # "unavailable" (codex P1-3: SILENT in the hot path — an un-blessed
273
+ # checkout MUST stay byte-identical to feature-absent; a loud
274
+ # `bad:global-registry-*` banner would perturb every CLI-less checkout via
275
+ # shared per-user state). Loud diagnosis lives only in `rea doctor`.
276
+ #
277
+ # Output:
278
+ # success → "<candidate_path>\n<g_root_path>" (literal probed paths)
279
+ # else → "unavailable"
280
+ #
281
+ # Args: $1 — CLAUDE_PROJECT_DIR (agent-controllable; spoofing only causes
282
+ # the TRUSTED global CLI to run — A5 is a consent gate, not a
283
+ # containment gate).
284
+ # -----------------------------------------------------------------------------
285
+ shim_global_entry_gate() {
286
+ local proj_dir="$1"
287
+ node -e '
288
+ const fs = require("fs");
289
+ const path = require("path");
290
+ const os = require("os");
291
+ const projDir = process.argv[1];
292
+ function unavailable() { process.stdout.write("unavailable"); process.exit(0); }
293
+ // A5.1 — passwd-derived home. NEVER $HOME / $XDG_*.
294
+ let pwDir;
295
+ try { pwDir = os.userInfo().homedir; } catch (e) { unavailable(); }
296
+ if (!pwDir || typeof pwDir !== "string" || pwDir.charAt(0) !== "/") unavailable();
297
+ const euid = process.geteuid();
298
+ const reaDir = path.join(pwDir, ".rea");
299
+ const gRoot = path.join(reaDir, "cli");
300
+ const registry = path.join(reaDir, "trusted-projects");
301
+ // A5.3a — validate <pw_dir>/.rea (lstat; never lstat pwDir or above:
302
+ // firmlinks / BSD /home symlinks legitimately exist there).
303
+ let rs;
304
+ try { rs = fs.lstatSync(reaDir); } catch (e) { unavailable(); }
305
+ if (rs.isSymbolicLink() || !rs.isDirectory()) unavailable();
306
+ if (rs.uid !== euid) unavailable();
307
+ if ((rs.mode & 0o022) !== 0) unavailable();
308
+ // A5.3b — validate the registry file: regular, not a symlink, owner,
309
+ // strict 0600 mask (NOT the 0o022 dir mask), single link (codex P2-7:
310
+ // a hardlinked registry is a same-uid mutation primitive).
311
+ let regs;
312
+ try { regs = fs.lstatSync(registry); } catch (e) { unavailable(); }
313
+ if (regs.isSymbolicLink() || !regs.isFile()) unavailable();
314
+ if (regs.uid !== euid) unavailable();
315
+ if ((regs.mode & 0o077) !== 0) unavailable();
316
+ if (regs.nlink !== 1) unavailable();
317
+ // A5.4 — project realpath (same resolution the writer anchors trust on).
318
+ let projReal;
319
+ try { projReal = fs.realpathSync(projDir); } catch (e) { unavailable(); }
320
+ // A5.5 — membership: fixed-string, full-line match (grep -Fxq). No
321
+ // parser ever (membership is decided pre-CLI-resolution); comment/blank
322
+ // lines are inert because realpath queries start with "/".
323
+ let content;
324
+ try { content = fs.readFileSync(registry, "utf8"); } catch (e) { unavailable(); }
325
+ const lines = content.split("\n");
326
+ let member = false;
327
+ for (let i = 0; i < lines.length; i += 1) {
328
+ if (lines[i] === projReal) { member = true; break; }
329
+ }
330
+ if (!member) unavailable();
331
+ // Resolve — probe (1) node_modules shape then (2) bare-drop fallback.
332
+ const c1 = path.join(gRoot, "node_modules", "@bookedsolid", "rea", "dist", "cli", "index.js");
333
+ const c2 = path.join(gRoot, "dist", "cli", "index.js");
334
+ let cand = "";
335
+ if (fs.existsSync(c1)) cand = c1;
336
+ else if (fs.existsSync(c2)) cand = c2;
337
+ if (!cand) unavailable(); // blessed-but-not-installed
338
+ process.stdout.write(cand + "\n" + gRoot);
339
+ ' -- "$proj_dir" 2>/dev/null
340
+ }
341
+
342
+ # -----------------------------------------------------------------------------
343
+ # Global tier — A1–A4 sandbox over a resolved candidate. 0.49.0 Phase 1b.
344
+ # Echoes "ok:<realpath>" on success (consistent with shim_sandbox_check),
345
+ # "bad:global-<reason>" on a fatal tier failure (blessed-but-hostile tree),
346
+ # or "unavailable" when realpath throws (ENOENT / automount — NOT a
347
+ # refusal). Evaluation order is cheapest/most-decisive first.
348
+ #
349
+ # A2 per-component lstat walk from the candidate UP to AND INCLUDING
350
+ # <rea_dir> (= dirname(g_root)); STOP there (never lstat pw_dir or
351
+ # above). Rejects ANY symlink component (an inside-pointing symlink
352
+ # is still a same-uid repoint primitive), foreign owner, group/other
353
+ # write, OR a device-number change vs g_root (mount/bind/automount
354
+ # aliasing, codex P2-6). The candidate index.js must have nlink===1
355
+ # (codex P2-7).
356
+ # A1 realpath(candidate) contained in realpath(g_root).
357
+ # A4 realpath ends sep+"dist/cli/index.js" — ALWAYS-on for global,
358
+ # independent of SHIM_ENFORCE_CLI_SHAPE.
359
+ # A3 ancestor package.json#name==="@bookedsolid/rea" (walk ≤20); that
360
+ # package.json must also have nlink===1.
361
+ #
362
+ # Args:
363
+ # $1 — candidate CLI path (literal, as probed)
364
+ # $2 — g_root (<pw_dir>/.rea/cli)
365
+ # -----------------------------------------------------------------------------
366
+ shim_sandbox_check_global() {
367
+ local cand="$1"
368
+ local g_root="$2"
369
+ node -e '
370
+ const fs = require("fs");
371
+ const path = require("path");
372
+ const cand = process.argv[1];
373
+ const gRoot = process.argv[2];
374
+ function bad(r) { process.stdout.write("bad:global-" + r); process.exit(1); }
375
+ function unavailable() { process.stdout.write("unavailable"); process.exit(1); }
376
+ function ok(r) { process.stdout.write("ok:" + r); process.exit(0); }
377
+ const euid = process.geteuid();
378
+ const reaDir = path.dirname(gRoot); // walk stops here; never above
379
+ const sep = path.sep;
380
+ // Capture g_root device number for the mount/bind aliasing check.
381
+ let gRootDev;
382
+ try { gRootDev = fs.lstatSync(gRoot).dev; } catch (e) { unavailable(); }
383
+ // A2 — per-component lstat walk: candidate UP to AND INCLUDING reaDir.
384
+ let comp = cand;
385
+ let first = true;
386
+ let guard = 0;
387
+ for (;;) {
388
+ guard += 1;
389
+ if (guard > 128) bad("perm"); // pathological depth — fail to tier
390
+ let st;
391
+ try { st = fs.lstatSync(comp); } catch (e) { unavailable(); }
392
+ if (st.isSymbolicLink()) bad("symlink");
393
+ if (st.uid !== euid) bad("perm");
394
+ if ((st.mode & 0o022) !== 0) bad("perm");
395
+ if (st.dev !== gRootDev) bad("perm");
396
+ if (first) {
397
+ // The candidate (index.js) must be a single-link regular file.
398
+ if (st.nlink !== 1) bad("hardlink");
399
+ first = false;
400
+ }
401
+ if (comp === reaDir) break;
402
+ const parent = path.dirname(comp);
403
+ if (parent === comp) break; // reached fs root without reaDir
404
+ comp = parent;
405
+ }
406
+ // A1 — realpath containment.
407
+ let real, realRoot;
408
+ try { real = fs.realpathSync(cand); } catch (e) { unavailable(); }
409
+ try { realRoot = fs.realpathSync(gRoot); } catch (e) { unavailable(); }
410
+ const rootSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
411
+ if (!(real === realRoot || real.startsWith(rootSep))) bad("escapes-root");
412
+ // A4 — dist/cli/index.js shape (ALWAYS-on for global).
413
+ const endWith = path.join("dist", "cli", "index.js");
414
+ if (!(real.endsWith(sep + endWith) || real === sep + endWith)) bad("shape");
415
+ // A3 — ancestor package.json with the rea name + nlink===1.
416
+ let cur = path.dirname(path.dirname(path.dirname(real)));
417
+ let pkg = "";
418
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
419
+ const pj = path.join(cur, "package.json");
420
+ if (fs.existsSync(pj)) {
421
+ try {
422
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
423
+ if (data && data.name === "@bookedsolid/rea") { pkg = pj; break; }
424
+ } catch (e) { /* keep walking */ }
425
+ }
426
+ cur = path.dirname(cur);
427
+ }
428
+ if (!pkg) bad("no-rea-pkg");
429
+ let ps;
430
+ try { ps = fs.lstatSync(pkg); } catch (e) { bad("no-rea-pkg"); }
431
+ if (ps.nlink !== 1) bad("hardlink");
432
+ ok(real);
433
+ ' -- "$cand" "$g_root" 2>/dev/null
434
+ }
435
+
436
+ # -----------------------------------------------------------------------------
437
+ # Global tier resolver. Called by shim_run ONLY when the in-project tiers
438
+ # missed (in-project ALWAYS wins; the registry is NEVER consulted when an
439
+ # in-project CLI resolved). On success sets REA_ARGV + RESOLVED_CLI_PATH +
440
+ # TRUST_TIER=global + _SHIM_GLOBAL_G_ROOT; on a fatal A1–A4 failure sets
441
+ # _SHIM_GLOBAL_BAD_REASON; on a silent A5 / registry / resolve miss leaves
442
+ # everything empty (byte-identical to feature-absent).
443
+ # -----------------------------------------------------------------------------
444
+ shim_resolve_cli_global() {
445
+ _SHIM_GLOBAL_BAD_REASON=""
446
+ _SHIM_GLOBAL_G_ROOT=""
447
+ # node is required for passwd derivation + lstat walk + realpath. No node
448
+ # → tier silently unavailable (same terminal as feature-absent).
449
+ command -v node >/dev/null 2>&1 || return 0
450
+ local gate=""
451
+ gate=$(shim_global_entry_gate "$proj" 2>/dev/null)
452
+ case "$gate" in
453
+ unavailable|"") return 0 ;; # silent A5 / registry / resolve miss
454
+ esac
455
+ # Success form is "<candidate>\n<g_root>". Parse the two lines (bash 3.2:
456
+ # ANSI-C $'\n' parameter-expansion trim — no `mapfile`).
457
+ local candidate="" g_root=""
458
+ candidate="${gate%%$'\n'*}"
459
+ g_root="${gate#*$'\n'}"
460
+ # Defensive: both must be non-empty absolute paths; else treat as silent.
461
+ case "$candidate" in /*) ;; *) return 0 ;; esac
462
+ case "$g_root" in /*) ;; *) return 0 ;; esac
463
+ local result=""
464
+ result=$(shim_sandbox_check_global "$candidate" "$g_root" 2>/dev/null)
465
+ case "$result" in
466
+ ok:*)
467
+ local real=""
468
+ real="${result#ok:}"
469
+ REA_ARGV=(node "$real")
470
+ RESOLVED_CLI_PATH="$real"
471
+ TRUST_TIER="global"
472
+ _SHIM_GLOBAL_G_ROOT="$g_root"
473
+ ;;
474
+ bad:*)
475
+ _SHIM_GLOBAL_BAD_REASON="$result"
476
+ ;;
477
+ *)
478
+ # "unavailable" (realpath threw) — silent, parity preserved.
479
+ ;;
480
+ esac
481
+ }
482
+
483
+ # -----------------------------------------------------------------------------
484
+ # Global-tier veto decision (0.50.0 Phase 2b). The A5 registry gate can only
485
+ # ENABLE the per-user global CLI; a project's OWN policy may further-RESTRICT
486
+ # it via `runtime.allow_global_cli` — the enable/restrict asymmetry the design
487
+ # turns on. This is the SINGLE source of truth for that decision, shared by
488
+ # BOTH shim_run (step-4-global-veto) and local-review-gate.sh (push/commit
489
+ # gate), so the 14 shims and the push gate cannot drift.
490
+ #
491
+ # Reads the veto THROUGH the sandbox-validated REA_ARGV CLI (the exact trust
492
+ # level shim_policy_short_circuit relies on post-sandbox). MUST be called ONLY
493
+ # when TRUST_TIER=global — that guarantees REA_ARGV is the non-empty validated
494
+ # global CLI, so "${REA_ARGV[@]}" is safe under set -u.
495
+ #
496
+ # TWO-STEP + TYPE-STRICT + fail-closed on malformed config. `rea hook
497
+ # policy-get` only PARSES YAML — it does NOT run the strict zod schema
498
+ # loadPolicy() / `rea doctor` apply, so ANY shape the strict loader would
499
+ # REJECT must never silently ENABLE the tier:
500
+ #
501
+ # 1. Read the PARENT `runtime` block (policy-get runtime --json). Verified
502
+ # shapes against the built CLI:
503
+ # runtime: {allow_global_cli: …} → {"allow_global_cli":…} (valid object)
504
+ # runtime: [] → [] (malformed)
505
+ # runtime: "off" → "off" (malformed)
506
+ # runtime: 42 → 42 (malformed)
507
+ # runtime absent → null
508
+ # malformed YAML → null, exit 1
509
+ # Classify:
510
+ # non-zero exit → fail-closed VETO
511
+ # null → runtime absent → ALLOW
512
+ # starts with `{` → valid object → step 2
513
+ # anything else ([, ", number …) → malformed runtime block → fail-closed VETO
514
+ # 2. Read the LEAF `runtime.allow_global_cli --json` (TYPE-PRESERVING: JSON
515
+ # boolean `true` vs the string `"true"` vs `null`):
516
+ # non-zero exit → fail-closed VETO
517
+ # true → ALLOW
518
+ # null → allow_global_cli absent → ALLOW
519
+ # anything else (false, "true", "yes", garbage) → VETO
520
+ #
521
+ # Return: 0 = VETO (refuse the global tier), 1 = ALLOW (proceed).
522
+ # -----------------------------------------------------------------------------
523
+ shim_global_tier_vetoed() {
524
+ local _rt_out="" _rt_status=0
525
+ _rt_out=$("${REA_ARGV[@]}" hook policy-get runtime --json 2>/dev/null); _rt_status=$?
526
+ if [ "$_rt_status" -ne 0 ]; then
527
+ return 0 # policy-get errored / unparseable policy → fail-closed veto
528
+ fi
529
+ case "$_rt_out" in
530
+ null)
531
+ return 1 # runtime block absent → allow
532
+ ;;
533
+ '{'*)
534
+ : # valid object → fall through to the type-strict leaf read
535
+ ;;
536
+ *)
537
+ # runtime present with the WRONG TYPE ([], "off", 42, …) — a shape the
538
+ # strict loader REJECTS. Fail closed rather than ENABLE the tier.
539
+ return 0
540
+ ;;
541
+ esac
542
+ local _av_out="" _av_status=0
543
+ _av_out=$("${REA_ARGV[@]}" hook policy-get runtime.allow_global_cli --json 2>/dev/null); _av_status=$?
544
+ if [ "$_av_status" -ne 0 ]; then
545
+ return 0 # fail-closed veto
546
+ fi
547
+ case "$_av_out" in
548
+ true|null) return 1 ;; # JSON boolean true, or null (allow_global_cli absent) → allow
549
+ *) return 0 ;; # false | "true" | "yes" | any string / garbage → fail-closed veto
550
+ esac
551
+ }
552
+
247
553
  # -----------------------------------------------------------------------------
248
554
  # Standardized banners — keep stderr templates identical across shims.
249
555
  # -----------------------------------------------------------------------------
@@ -268,6 +574,15 @@ shim_emit_sandbox_skip_banner() {
268
574
  printf 'rea: %s skipped (sandbox check: %s)\n' "$SHIM_NAME" "$reason" >&2
269
575
  }
270
576
 
577
+ # Global tier — one-line advisory when a BLESSED project resolves a global
578
+ # CLI tree that fails A1–A4 (hostile/malformed). Never fired by an
579
+ # un-blessed project (which degrades silently), so it cannot perturb the
580
+ # feature-absent parity surface.
581
+ shim_emit_global_tier_banner() {
582
+ local reason="$1"
583
+ printf 'rea: %s — global rea CLI tier rejected (%s); falling back to no-CLI.\n' "$SHIM_NAME" "$reason" >&2
584
+ }
585
+
271
586
  shim_emit_version_skew_banner_blocking() {
272
587
  printf 'rea: this shim requires the `rea hook %s` subcommand (introduced in %s).\n' "$SHIM_NAME" "$SHIM_INTRODUCED_IN" >&2
273
588
  printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
@@ -309,8 +624,52 @@ shim_run() {
309
624
  fi
310
625
  fi
311
626
 
312
- # 4. Resolve CLI.
627
+ # 4. Resolve CLI. In-project tiers ALWAYS win (byte-identical to today).
313
628
  shim_resolve_cli
629
+ local TRUST_TIER="project"
630
+
631
+ # 4-global (0.49.0 Phase 1b). ONLY when both in-project tiers missed do we
632
+ # consult the opt-in global tier. Registry membership is the SOLE gate
633
+ # (codex design-gate P1-1: NO policy read in this pre-CLI window). An
634
+ # un-blessed project leaves REA_ARGV empty + emits nothing, so the no-CLI
635
+ # terminal below is byte-identical to feature-absent. A5 + global
636
+ # resolution complete (and TRUST_TIER is final) BEFORE the cache block at
637
+ # step 4b builds its key — a HARD design invariant (codex P3-9): the
638
+ # cache's trust_tier + registry mtime/size fields are only sound if the
639
+ # live registry gate ran first.
640
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
641
+ shim_resolve_cli_global
642
+ fi
643
+
644
+ # 4-global-veto (0.50.0 Phase 2b). OPTIONAL in-project veto over the global
645
+ # tier, via the SHARED shim_global_tier_vetoed helper (single source of
646
+ # truth for the decision — see its header for the two-step type-strict +
647
+ # fail-closed-on-malformed logic). Runs ONLY when the global tier actually
648
+ # resolved (TRUST_TIER=global ⇒ REA_ARGV is the sandbox-validated global
649
+ # CLI, so the helper's "${REA_ARGV[@]}" reads are safe under set -u).
650
+ #
651
+ # Placement is deliberate: this sits BEFORE the step-4b cache block and
652
+ # therefore fires on EVERY invocation, warm-cache fires included.
653
+ # `runtime.allow_global_cli` is intentionally kept OUT of the cache key
654
+ # (design memo) precisely so a mid-session edit adding
655
+ # `allow_global_cli: false` is honored on the very NEXT fire instead of
656
+ # surviving inside a warm entry. Gating this read behind the cache would
657
+ # reintroduce exactly the stale-consent hole the key-omission avoids. It
658
+ # also completes BEFORE the step-4b cache-key build, so TRUST_TIER is
659
+ # still final when that key is constructed (extends the step-4 invariant).
660
+ #
661
+ # On veto: behave EXACTLY like "project not in registry" / no-CLI — clear
662
+ # REA_ARGV, revert TRUST_TIER to project, and DO NOT set
663
+ # _SHIM_GLOBAL_BAD_REASON. A veto is a legitimate project CHOICE, not a
664
+ # security signal, so it stays SILENT — no bad:global-* advisory. The
665
+ # existing step-7 relevance-gated no-CLI terminal then makes a vetoed
666
+ # project byte-identical to an un-blessed / no-CLI project (harmless
667
+ # payload → exit 0 silent; relevant payload on a blocking shim →
668
+ # cli-missing banner + exit 2).
669
+ if [ "$TRUST_TIER" = "global" ] && shim_global_tier_vetoed; then
670
+ REA_ARGV=()
671
+ TRUST_TIER="project"
672
+ fi
314
673
 
315
674
  # 4a. Sandbox check (0.48.1 — moved earlier from step 5). Runs BEFORE
316
675
  # the cache-key-prep block so the dist-tree `find` walk (added in
@@ -332,7 +691,16 @@ shim_run() {
332
691
  local sandbox_result=""
333
692
  local sandbox_failed=0
334
693
  local node_missing=0
335
- if [ "${#REA_ARGV[@]}" -gt 0 ]; then
694
+ local real=""
695
+ # 0.49.0 Phase 1b: gated on TRUST_TIER=project. The global tier already
696
+ # ran its own A1–A4 sandbox (incl. the per-component lstat walk, st_dev,
697
+ # nlink, realpath containment, and the always-on dist/cli/index.js shape)
698
+ # inside shim_resolve_cli_global, and REA_ARGV already points at the
699
+ # validated realpath. Re-running the IN-PROJECT check here would reject a
700
+ # global CLI with bad:cli-escapes-project (it legitimately lives outside
701
+ # CLAUDE_PROJECT_DIR). node was already required for the global resolve,
702
+ # so the node-missing branch below is project-tier-only too.
703
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$TRUST_TIER" = "project" ]; then
336
704
  if ! command -v node >/dev/null 2>&1; then
337
705
  # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
338
706
  # without ever calling shim_policy_short_circuit, so a blocking-
@@ -347,17 +715,33 @@ shim_run() {
347
715
  REA_ARGV=()
348
716
  else
349
717
  sandbox_result=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
350
- if [ "$sandbox_result" != "ok" ]; then
351
- sandbox_failed=1
352
- if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
353
- shim_emit_sandbox_skip_banner "$sandbox_result"
354
- exit 0
355
- fi
356
- # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
357
- # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
358
- # of invoking the untrusted CLI.
359
- REA_ARGV=()
360
- fi
718
+ # TOCTOU precursor: shim_sandbox_check now echoes `ok:<realpath>`
719
+ # on success. Execute the VALIDATED realpath instead of the
720
+ # literal RESOLVED_CLI_PATH so the version probe (step 8) and the
721
+ # forward (step 9) run the exact path the sandbox vetted. This
722
+ # shrinks the TOCTOU window to the same in-place-swap residual the
723
+ # in-project tier already has — it is NOT a same-inode guarantee
724
+ # (a rename-swap of the realpath after validation remains
725
+ # possible). bash 3.2: `case` glob match, no `[[ =~ ]]`.
726
+ case "$sandbox_result" in
727
+ ok:*)
728
+ real="${sandbox_result#ok:}"
729
+ REA_ARGV=(node "$real")
730
+ RESOLVED_CLI_PATH="$real"
731
+ ;;
732
+ *)
733
+ # bad:<reason> — sandbox refused.
734
+ sandbox_failed=1
735
+ if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
736
+ shim_emit_sandbox_skip_banner "$sandbox_result"
737
+ exit 0
738
+ fi
739
+ # Blocking-tier: clear REA_ARGV so Tier-1 policy reads (in
740
+ # shim_policy_short_circuit) degrade to Tier 2 / Tier 3 instead
741
+ # of invoking the untrusted CLI.
742
+ REA_ARGV=()
743
+ ;;
744
+ esac
361
745
  fi
362
746
  fi
363
747
 
@@ -383,6 +767,10 @@ shim_run() {
383
767
  local _shim_cache_dist_mtime=""
384
768
  local _shim_cache_node_real=""
385
769
  local _shim_cache_node_mtime=""
770
+ # 0.49.0 Phase 1b cache-key v2 fields.
771
+ local _registry_mtime=""
772
+ local _registry_size=""
773
+ local _effective_shape="$SHIM_ENFORCE_CLI_SHAPE"
386
774
  # 0.48.1: gated on `sandbox_failed -eq 0` so a sandbox refusal
387
775
  # short-circuits BEFORE the dist-tree hash walk runs (was running
388
776
  # against a possibly-symlinked-out target pre-0.48.1).
@@ -400,6 +788,12 @@ shim_run() {
400
788
  local _proj_real=""
401
789
  local _euid=""
402
790
  local _session_tok=""
791
+ # TOCTOU precursor cache-stability note: step 4a may have rewritten
792
+ # RESOLVED_CLI_PATH to its realpath. That does NOT drift the cache
793
+ # key value — `_shim_cache_cli_real` below runs realpathSync, so
794
+ # realpath-of-realpath is byte-identical to realpath-of-symlink, and
795
+ # `_stat_out` lstat reads the same underlying file (intermediate
796
+ # symlinks are kernel-resolved either way).
403
797
  _stat_out=$(shim_cache_mtime_size "$RESOLVED_CLI_PATH" 2>/dev/null || true)
404
798
  # 0.48.0 codex round-4 P1 + round-7 P2: capture the ACTUAL node
405
799
  # interpreter realpath + mtime via `process.execPath` (node's own
@@ -426,6 +820,27 @@ shim_run() {
426
820
  _proj_real=$(node -e 'try { process.stdout.write(require("fs").realpathSync(process.argv[1])); } catch (e) { process.exit(1); }' -- "$proj" 2>/dev/null || true)
427
821
  _euid=$(id -u 2>/dev/null || true)
428
822
  _session_tok=$(shim_cache_session_token 2>/dev/null || true)
823
+ # 0.49.0 Phase 1b — tier-aware key inputs. The global tier enforces the
824
+ # dist/cli/index.js shape unconditionally (A4), so the effective
825
+ # enforce_cli_shape key field is "1" there regardless of
826
+ # SHIM_ENFORCE_CLI_SHAPE. The registry mtime/size pin the trust source
827
+ # so an untrust mid-session (atomic rename → new mtime; removed line →
828
+ # smaller size — two invalidators defeat `touch -r`) misses a warm
829
+ # global entry. Stat failure → leave them empty → the completeness gate
830
+ # below produces a clean miss (fail-safe, NEVER fail-closed).
831
+ if [ "$TRUST_TIER" = "global" ]; then
832
+ _effective_shape="1"
833
+ if [ -n "${_SHIM_GLOBAL_G_ROOT:-}" ]; then
834
+ local _reg_path=""
835
+ _reg_path="$(dirname "$_SHIM_GLOBAL_G_ROOT")/trusted-projects"
836
+ local _reg_stat=""
837
+ _reg_stat=$(shim_cache_mtime_size "$_reg_path" 2>/dev/null || true)
838
+ if [ -n "$_reg_stat" ]; then
839
+ _registry_mtime="${_reg_stat%% *}"
840
+ _registry_size="${_reg_stat##* }"
841
+ fi
842
+ fi
843
+ fi
429
844
  # 0.48.0 codex round-3 P2: ALSO capture the ancestor package.json
430
845
  # path + mtime/size. The sandbox check walks upward to find a
431
846
  # package.json whose `name` is `@bookedsolid/rea`; without it in
@@ -529,7 +944,8 @@ shim_run() {
529
944
  && [ -n "$_euid" ] && [ -n "$_session_tok" ] \
530
945
  && [ -n "$_shim_cache_pkg_real" ] && [ -n "$_shim_cache_pkg_mtime" ] \
531
946
  && [ -n "$_shim_cache_dist_mtime" ] \
532
- && [ -n "$_shim_cache_node_real" ] && [ -n "$_shim_cache_node_mtime" ]; then
947
+ && [ -n "$_shim_cache_node_real" ] && [ -n "$_shim_cache_node_mtime" ] \
948
+ && { [ "$TRUST_TIER" != "global" ] || { [ -n "$_registry_mtime" ] && [ -n "$_registry_size" ]; }; }; then
533
949
  _shim_cache_cli_mtime="${_stat_out%% *}"
534
950
  _shim_cache_cli_size="${_stat_out##* }"
535
951
  # 0.48.0 codex round-1 P1: the key MUST include SHIM_NAME because
@@ -547,12 +963,17 @@ shim_run() {
547
963
  # added/removed (most fresh `tsc` rebuilds do both); (c) the
548
964
  # package.json realpath is implicitly part of the key via these
549
965
  # mtime/size fields plus the project realpath above.
550
- _shim_cache_key=$(shim_cache_key "v1" "$_session_tok" "$_proj_real" "$_shim_cache_cli_real" \
966
+ # 0.49.0 Phase 1b: schema "v1""v2"; enforce_cli_shape carries the
967
+ # EFFECTIVE value; trust_tier + registry mtime/size appended
968
+ # (positions 15-17). v1 entries become a clean miss at the read-side
969
+ # schema check (hard cutover).
970
+ _shim_cache_key=$(shim_cache_key "v2" "$_session_tok" "$_proj_real" "$_shim_cache_cli_real" \
551
971
  "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_euid" \
552
- "$SHIM_ENFORCE_CLI_SHAPE" "$SHIM_NAME" \
972
+ "$_effective_shape" "$SHIM_NAME" \
553
973
  "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" \
554
974
  "$_shim_cache_dist_mtime" \
555
975
  "$_shim_cache_node_real" "$_shim_cache_node_mtime" \
976
+ "$TRUST_TIER" "$_registry_mtime" "$_registry_size" \
556
977
  2>/dev/null || true)
557
978
  if [ -n "$_shim_cache_key" ]; then
558
979
  local _cache_json=""
@@ -572,9 +993,12 @@ shim_run() {
572
993
  const pkgMtime = String(e.pkg_mtime);
573
994
  const pkgSize = String(e.pkg_size_bytes);
574
995
  const distMtime = String(e.dist_mtime);
996
+ const trustTier = String(e.trust_tier);
997
+ const regMtime = String(e.registry_mtime);
998
+ const regSize = String(e.registry_size);
575
999
  const sandboxOk = e.sandbox_ok === true;
576
1000
  const shapeOk = e.shape_ok === true;
577
- if (e.schema_version !== "v1") process.exit(1);
1001
+ if (e.schema_version !== "v2") process.exit(1);
578
1002
  if (!Number.isFinite(ttl) || ttl <= 0 || ttl > 3600) process.exit(1);
579
1003
  if (!Number.isFinite(cachedAt)) process.exit(1);
580
1004
  if ((cachedAt + ttl) < now) process.exit(1);
@@ -597,10 +1021,18 @@ shim_run() {
597
1021
  const nodeMtime = String(e.node_mtime);
598
1022
  if (nodeReal !== process.argv[8]) process.exit(1);
599
1023
  if (nodeMtime !== process.argv[9]) process.exit(1);
1024
+ // 0.49.0 Phase 1b: re-check trust_tier + registry mtime/size.
1025
+ // These are in the key too (a drifted state produces a
1026
+ // different key), but persisting + re-validating them catches
1027
+ // a key collision as a stale-entry miss instead of trusting
1028
+ // it — and makes the cross-tier non-collision explicit.
1029
+ if (trustTier !== process.argv[10]) process.exit(1);
1030
+ if (regMtime !== process.argv[11]) process.exit(1);
1031
+ if (regSize !== process.argv[12]) process.exit(1);
600
1032
  if (!sandboxOk || !shapeOk) process.exit(1);
601
1033
  process.stdout.write("ok");
602
1034
  } catch (e) { process.exit(1); }
603
- ' -- "$_cache_json" "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_shim_cache_cli_real" "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" "$_shim_cache_dist_mtime" "$_shim_cache_node_real" "$_shim_cache_node_mtime" 2>/dev/null || true)
1035
+ ' -- "$_cache_json" "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_shim_cache_cli_real" "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" "$_shim_cache_dist_mtime" "$_shim_cache_node_real" "$_shim_cache_node_mtime" "$TRUST_TIER" "$_registry_mtime" "$_registry_size" 2>/dev/null || true)
604
1036
  if [ "$_cache_validate" = "ok" ]; then
605
1037
  _shim_cache_hit=1
606
1038
  fi
@@ -648,20 +1080,46 @@ shim_run() {
648
1080
  shim_emit_sandbox_failure_banner "$sandbox_result"
649
1081
  exit 2
650
1082
  fi
1083
+ # 0.49.0 Phase 1b: a BLESSED project resolved a global CLI tree that
1084
+ # failed A1–A4 (hostile/malformed). This MUST behave EXACTLY like "no
1085
+ # CLI" for the relevance decision — otherwise a broken ~/.rea/cli would
1086
+ # turn a harmless Bash/Write call into a repo-wide lockout for opted-in
1087
+ # users on the relevance-gated blocking shims (dangerous-bash-
1088
+ # interceptor, secret-scanner, settings-protection, blocked-paths-*,
1089
+ # protected-paths-bash-gate). The relevance + tier exit logic below is
1090
+ # SHARED between the plain CLI-missing and the bad-global-tree cases;
1091
+ # the only difference is that a bad global tree emits its one-line
1092
+ # advisory (which REPLACES the generic cli-missing banner) once we are
1093
+ # past the not-relevant-allow exit. An irrelevant payload is therefore
1094
+ # allowed SILENTLY, byte-identical to the pre-Phase-1b no-CLI path. Do
1095
+ # NOT escalate advisory hooks to a hard block on a global plant (A2
1096
+ # already proved only euid wrote the tree). Un-blessed projects never
1097
+ # reach here (they degrade silently). The bad reason is unset when
1098
+ # in-project resolved, so guard with :- for set -u.
651
1099
  if declare -F shim_cli_missing_relevant >/dev/null 2>&1; then
652
1100
  if ! shim_cli_missing_relevant; then
653
- # CLI missing AND payload is not relevant per shim's keyword
654
- # scan the pre-port bash body would have allowed this.
1101
+ # CLI missing or a bad global tree, treated identically — AND the
1102
+ # payload is not relevant per the shim's keyword scan. The pre-port
1103
+ # bash body would have allowed this. Silent, no advisory.
655
1104
  exit 0
656
1105
  fi
657
1106
  fi
658
- # Either no callback defined OR the callback said "yes, relevant".
1107
+ # Past here the payload IS relevant (or no callback is defined) → the
1108
+ # gate would act. Surface the global-tier diagnostic ONCE if a bad
1109
+ # global tree is the cause (it replaces the generic cli-missing banner).
1110
+ if [ -n "${_SHIM_GLOBAL_BAD_REASON:-}" ]; then
1111
+ shim_emit_global_tier_banner "$_SHIM_GLOBAL_BAD_REASON"
1112
+ fi
659
1113
  if [ "$SHIM_FAIL_OPEN" -eq 1 ]; then
660
- # Advisory tier — drop the gate silently. No banner; advisory
661
- # hooks are nudges, not security claims.
1114
+ # Advisory tier — drop the gate. Any global-bad advisory was already
1115
+ # emitted; advisory hooks are nudges, not security claims.
662
1116
  exit 0
663
1117
  fi
664
- shim_emit_cli_missing_banner
1118
+ # Relevant + blocking: refuse. The generic cli-missing banner only when
1119
+ # a bad global tree did NOT already explain the refusal.
1120
+ if [ -z "${_SHIM_GLOBAL_BAD_REASON:-}" ]; then
1121
+ shim_emit_cli_missing_banner
1122
+ fi
665
1123
  exit 2
666
1124
  fi
667
1125
 
@@ -708,7 +1166,7 @@ shim_run() {
708
1166
  const args = process.argv.slice(1);
709
1167
  const now = Math.floor(Date.now() / 1000);
710
1168
  const entry = {
711
- schema_version: "v1",
1169
+ schema_version: "v2",
712
1170
  cli_realpath: args[0],
713
1171
  cli_mtime: args[1],
714
1172
  cli_size_bytes: args[2],
@@ -727,13 +1185,20 @@ shim_run() {
727
1185
  // them and refuse a hit when the interpreter swapped.
728
1186
  node_realpath: args[6],
729
1187
  node_mtime: args[7],
1188
+ // 0.49.0 Phase 1b: trust_tier + registry mtime/size (the read-side
1189
+ // validator re-checks all three). registry_* are "" on the project
1190
+ // tier; sandbox_ok/shape_ok are the tier-appropriate results — for
1191
+ // global, A4 shape was enforced unconditionally during resolution.
1192
+ trust_tier: args[8],
1193
+ registry_mtime: args[9],
1194
+ registry_size: args[10],
730
1195
  sandbox_ok: true,
731
1196
  shape_ok: true,
732
1197
  cached_at_unix: now,
733
1198
  ttl_seconds: 3600,
734
1199
  };
735
1200
  process.stdout.write(JSON.stringify(entry));
736
- ' -- "$_shim_cache_cli_real" "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" "$_shim_cache_dist_mtime" "$_shim_cache_node_real" "$_shim_cache_node_mtime" 2>/dev/null || true)
1201
+ ' -- "$_shim_cache_cli_real" "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" "$_shim_cache_dist_mtime" "$_shim_cache_node_real" "$_shim_cache_node_mtime" "$TRUST_TIER" "$_registry_mtime" "$_registry_size" 2>/dev/null || true)
737
1202
  if [ -n "$_write_payload" ]; then
738
1203
  shim_cache_write "$_shim_cache_key" "$_write_payload" >/dev/null 2>&1 || true
739
1204
  fi