@bookedsolid/rea 0.47.0 → 0.48.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.
@@ -137,6 +137,15 @@
137
137
 
138
138
  set -uo pipefail
139
139
 
140
+ # Source the per-session cache helper (0.48.0). This must be sourced
141
+ # at the top of shim-runtime.sh because `shim_run` needs all of the
142
+ # `shim_cache_*` functions available. The helper itself fails safe —
143
+ # no operations fire unless `shim_run` calls them.
144
+ # shellcheck source=shim-cache.sh
145
+ _SHIM_RUNTIME_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
146
+ # shellcheck source=shim-cache.sh
147
+ . "$_SHIM_RUNTIME_DIR/shim-cache.sh"
148
+
140
149
  # -----------------------------------------------------------------------------
141
150
  # Defaults — applied by `shim_run` when the shim hasn't set them. We use
142
151
  # the `:=` operator to assign-if-unset so callers can override.
@@ -303,6 +312,241 @@ shim_run() {
303
312
  # 4. Resolve CLI.
304
313
  shim_resolve_cli
305
314
 
315
+ # 4b. Per-session cache lookup (0.48.0). When the cache is enabled
316
+ # AND the resolved CLI matches a recent same-session entry, the
317
+ # sandbox check (step 5) AND version probe (step 8) can both be
318
+ # skipped — those answers do not change for a stable CLI inside a
319
+ # stable session. Cache MISS / disabled / corrupt → fall through
320
+ # to the existing uncached hot path. NEVER fail closed on a cache
321
+ # error (see hooks/_lib/shim-cache.sh header for the security
322
+ # contract). The cache check runs AFTER `shim_is_relevant` (per
323
+ # design memo concern #3) so we never pay a stat-per-fire cost
324
+ # for irrelevant payloads.
325
+ local _shim_cache_hit=0
326
+ local _shim_cache_key=""
327
+ local _shim_cache_cli_real=""
328
+ local _shim_cache_cli_mtime=""
329
+ local _shim_cache_cli_size=""
330
+ local _shim_cache_pkg_real=""
331
+ local _shim_cache_pkg_mtime=""
332
+ local _shim_cache_pkg_size=""
333
+ local _shim_cache_dist_mtime=""
334
+ local _shim_cache_node_real=""
335
+ local _shim_cache_node_mtime=""
336
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && ! shim_cache_disabled; then
337
+ local _stat_out=""
338
+ local _proj_real=""
339
+ local _euid=""
340
+ local _session_tok=""
341
+ _stat_out=$(shim_cache_mtime_size "$RESOLVED_CLI_PATH" 2>/dev/null || true)
342
+ # 0.48.0 codex round-4 P1 + round-7 P2: capture the ACTUAL node
343
+ # interpreter realpath + mtime via `process.execPath` (node's own
344
+ # path to itself). Pre-round-7 we resolved `command -v node` via
345
+ # `fs.realpathSync` — but version managers like Volta and asdf
346
+ # use STABLE shim scripts (e.g. ~/.volta/bin/node) that resolve
347
+ # to themselves; only the spawned node's `process.execPath`
348
+ # reveals which concrete Node binary the shim ultimately
349
+ # launched (e.g. /Users/foo/.volta/tools/image/node/22.x.x/bin/
350
+ # node). Using execPath catches `volta pin`/`nvm use` interpreter
351
+ # swaps correctly. The mtime field is captured at second
352
+ # precision (consistent with the other mtime fields) — switching
353
+ # Node versions changes the realpath so the mtime alone is not
354
+ # load-bearing.
355
+ _shim_cache_node_real=$(node -e 'process.stdout.write(require("fs").realpathSync(process.execPath))' 2>/dev/null || true)
356
+ if [ -n "$_shim_cache_node_real" ]; then
357
+ local _node_stat=""
358
+ _node_stat=$(shim_cache_mtime_size "$_shim_cache_node_real" 2>/dev/null || true)
359
+ if [ -n "$_node_stat" ]; then
360
+ _shim_cache_node_mtime="${_node_stat%% *}"
361
+ fi
362
+ fi
363
+ _shim_cache_cli_real=$(node -e 'try { process.stdout.write(require("fs").realpathSync(process.argv[1])); } catch (e) { process.exit(1); }' -- "$RESOLVED_CLI_PATH" 2>/dev/null || true)
364
+ _proj_real=$(node -e 'try { process.stdout.write(require("fs").realpathSync(process.argv[1])); } catch (e) { process.exit(1); }' -- "$proj" 2>/dev/null || true)
365
+ _euid=$(id -u 2>/dev/null || true)
366
+ _session_tok=$(shim_cache_session_token 2>/dev/null || true)
367
+ # 0.48.0 codex round-3 P2: ALSO capture the ancestor package.json
368
+ # path + mtime/size. The sandbox check walks upward to find a
369
+ # package.json whose `name` is `@bookedsolid/rea`; without it in
370
+ # the key, a same-session edit to that package.json (renaming, or
371
+ # removing the `name` field) would still see warm cache hits even
372
+ # though the uncached sandbox check would reject the new state.
373
+ # Codex round-3 P1: ALSO capture the dist/cli/ DIR mtime so a
374
+ # rebuild that adds/removes files (most fresh tsc runs after a
375
+ # source-tree change) invalidates the key even if dist/cli/
376
+ # index.js content happens to round to the same ns.
377
+ _shim_cache_pkg_real=$(node -e '
378
+ try {
379
+ const fs = require("fs");
380
+ const path = require("path");
381
+ const real = fs.realpathSync(process.argv[1]);
382
+ let cur = path.dirname(path.dirname(path.dirname(real)));
383
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i++) {
384
+ const pj = path.join(cur, "package.json");
385
+ if (fs.existsSync(pj)) {
386
+ try {
387
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
388
+ if (data && data.name === "@bookedsolid/rea") {
389
+ process.stdout.write(pj);
390
+ process.exit(0);
391
+ }
392
+ } catch (e) {}
393
+ }
394
+ cur = path.dirname(cur);
395
+ }
396
+ process.exit(1);
397
+ } catch (e) { process.exit(1); }
398
+ ' -- "$RESOLVED_CLI_PATH" 2>/dev/null || true)
399
+ if [ -n "$_shim_cache_pkg_real" ]; then
400
+ local _pkg_stat=""
401
+ _pkg_stat=$(shim_cache_mtime_size "$_shim_cache_pkg_real" 2>/dev/null || true)
402
+ if [ -n "$_pkg_stat" ]; then
403
+ _shim_cache_pkg_mtime="${_pkg_stat%% *}"
404
+ _shim_cache_pkg_size="${_pkg_stat##* }"
405
+ fi
406
+ fi
407
+ # 0.48.0 codex round-5/7/9 — the cache key incorporates a hash of
408
+ # every `*.js` file's mtime across the FULL dist tree, not just
409
+ # dist/cli/. Pre-round-9 the hash covered only dist/cli/*.js, but
410
+ # `rea hook` actually executes a much larger module graph:
411
+ # dist/cli/hook.js imports ../hooks/**, ../policy/loader.js,
412
+ # ../audit/**, etc. A same-session rebuild that rewrote one of
413
+ # those imported files in place without touching a top-level
414
+ # dist/cli/*.js file would leave the hash unchanged, the warm
415
+ # cache would survive, and shim_run would skip the version probe
416
+ # against a changed CLI runtime. Hashing dist/**/*.js closes the
417
+ # gap. Cost: ~15ms on the rea dist (141 files) via `find -exec
418
+ # stat +` batched into a single subprocess call.
419
+ local _dist_root=""
420
+ # dist root is two parents above dist/cli/index.js
421
+ _dist_root=$(dirname "$(dirname "$RESOLVED_CLI_PATH")" 2>/dev/null || true)
422
+ if [ -n "$_dist_root" ] && [ -d "$_dist_root" ]; then
423
+ # 0.48.0 codex round-6 P2: pick a hasher that exists. macOS
424
+ # ships `shasum` (perl); GNU coreutils provides `sha256sum`.
425
+ local _hasher=""
426
+ if command -v shasum >/dev/null 2>&1; then
427
+ _hasher="shasum -a 256"
428
+ elif command -v sha256sum >/dev/null 2>&1; then
429
+ _hasher="sha256sum"
430
+ fi
431
+ if [ -n "$_hasher" ]; then
432
+ # 0.48.0 codex round-7 P2: ns-precision mtime so a
433
+ # same-second rewrite is caught. Try macOS `-f` form first;
434
+ # fall through to GNU `-c` on failure. `find -exec stat +`
435
+ # batches all paths into ONE stat call (~15ms total instead
436
+ # of the per-file 365ms loop).
437
+ local _stat_macos=""
438
+ local _stat_gnu=""
439
+ _stat_macos=$(find "$_dist_root" -name '*.js' -type f -exec stat -f "%Fm %z %N" {} + 2>/dev/null || true)
440
+ if [ -n "$_stat_macos" ]; then
441
+ _shim_cache_dist_mtime=$(printf '%s' "$_stat_macos" | sort | $_hasher 2>/dev/null | awk '{print $1}' | cut -c1-32)
442
+ else
443
+ _stat_gnu=$(find "$_dist_root" -name '*.js' -type f -exec stat -c "%.Y %s %n" {} + 2>/dev/null || true)
444
+ if [ -n "$_stat_gnu" ]; then
445
+ _shim_cache_dist_mtime=$(printf '%s' "$_stat_gnu" | sort | $_hasher 2>/dev/null | awk '{print $1}' | cut -c1-32)
446
+ fi
447
+ fi
448
+ fi
449
+ # Last-ditch fallback: just the dist/cli/ dir mtime (round-3
450
+ # behavior). Keeps the cache functional even when find / stat /
451
+ # shasum / sha256sum are all unavailable (truly stripped
452
+ # container) — though that's already the case where the cache
453
+ # layer should fall back to disabled via the session token.
454
+ if [ -z "$_shim_cache_dist_mtime" ]; then
455
+ local _cli_dir=""
456
+ _cli_dir=$(dirname "$RESOLVED_CLI_PATH" 2>/dev/null || true)
457
+ if [ -n "$_cli_dir" ] && [ -d "$_cli_dir" ]; then
458
+ local _dir_stat=""
459
+ _dir_stat=$(shim_cache_mtime_size "$_cli_dir" 2>/dev/null || true)
460
+ if [ -n "$_dir_stat" ]; then
461
+ _shim_cache_dist_mtime="${_dir_stat%% *}"
462
+ fi
463
+ fi
464
+ fi
465
+ fi
466
+ if [ -n "$_stat_out" ] && [ -n "$_shim_cache_cli_real" ] && [ -n "$_proj_real" ] \
467
+ && [ -n "$_euid" ] && [ -n "$_session_tok" ] \
468
+ && [ -n "$_shim_cache_pkg_real" ] && [ -n "$_shim_cache_pkg_mtime" ] \
469
+ && [ -n "$_shim_cache_dist_mtime" ] \
470
+ && [ -n "$_shim_cache_node_real" ] && [ -n "$_shim_cache_node_mtime" ]; then
471
+ _shim_cache_cli_mtime="${_stat_out%% *}"
472
+ _shim_cache_cli_size="${_stat_out##* }"
473
+ # 0.48.0 codex round-1 P1: the key MUST include SHIM_NAME because
474
+ # step 8's version probe is `rea hook $SHIM_NAME --help` — it's
475
+ # hook-specific. Without SHIM_NAME in the key, a cache-warm shim
476
+ # could let a sibling shim with the SAME (session, project, CLI,
477
+ # mtime, size, euid, shape) skip its OWN version-skew check and
478
+ # forward straight to a CLI that does not implement that hook
479
+ # (realistic on a 0.32 CLI + newer secret-scanner shim mismatch).
480
+ #
481
+ # 0.48.0 codex round-3 P1+P2: 3 new key fields cover (a) ancestor
482
+ # package.json mtime/size — invalidates if the rea package.json
483
+ # is renamed or its `name` field is edited; (b) dist/cli/ dir
484
+ # mtime — invalidates when any file in that directory is
485
+ # added/removed (most fresh `tsc` rebuilds do both); (c) the
486
+ # package.json realpath is implicitly part of the key via these
487
+ # mtime/size fields plus the project realpath above.
488
+ _shim_cache_key=$(shim_cache_key "v1" "$_session_tok" "$_proj_real" "$_shim_cache_cli_real" \
489
+ "$_shim_cache_cli_mtime" "$_shim_cache_cli_size" "$_euid" \
490
+ "$SHIM_ENFORCE_CLI_SHAPE" "$SHIM_NAME" \
491
+ "$_shim_cache_pkg_mtime" "$_shim_cache_pkg_size" \
492
+ "$_shim_cache_dist_mtime" \
493
+ "$_shim_cache_node_real" "$_shim_cache_node_mtime" \
494
+ 2>/dev/null || true)
495
+ if [ -n "$_shim_cache_key" ]; then
496
+ local _cache_json=""
497
+ _cache_json=$(shim_cache_read "$_shim_cache_key" 2>/dev/null || true)
498
+ if [ -n "$_cache_json" ]; then
499
+ # Parse + validate the entry. Failure → treat as miss.
500
+ local _cache_validate=""
501
+ _cache_validate=$(node -e '
502
+ try {
503
+ const e = JSON.parse(process.argv[1]);
504
+ const now = Math.floor(Date.now() / 1000);
505
+ const ttl = Number(e.ttl_seconds);
506
+ const cachedAt = Number(e.cached_at_unix);
507
+ const cliMtime = String(e.cli_mtime);
508
+ const cliSize = String(e.cli_size_bytes);
509
+ const cliReal = String(e.cli_realpath);
510
+ const pkgMtime = String(e.pkg_mtime);
511
+ const pkgSize = String(e.pkg_size_bytes);
512
+ const distMtime = String(e.dist_mtime);
513
+ const sandboxOk = e.sandbox_ok === true;
514
+ const shapeOk = e.shape_ok === true;
515
+ if (e.schema_version !== "v1") process.exit(1);
516
+ if (!Number.isFinite(ttl) || ttl <= 0 || ttl > 3600) process.exit(1);
517
+ if (!Number.isFinite(cachedAt)) process.exit(1);
518
+ if ((cachedAt + ttl) < now) process.exit(1);
519
+ if (cliMtime !== process.argv[2]) process.exit(1);
520
+ if (cliSize !== process.argv[3]) process.exit(1);
521
+ if (cliReal !== process.argv[4]) process.exit(1);
522
+ // 0.48.0 codex round-3 P1+P2: re-check the package.json
523
+ // mtime/size and the dist/cli/ dir mtime in addition to
524
+ // the CLI itself. Defense-in-depth against an entry
525
+ // whose key happened to collide but whose disk state
526
+ // has drifted.
527
+ if (pkgMtime !== process.argv[5]) process.exit(1);
528
+ if (pkgSize !== process.argv[6]) process.exit(1);
529
+ if (distMtime !== process.argv[7]) process.exit(1);
530
+ // 0.48.0 codex round-4 P1: re-check the resolved node
531
+ // binary realpath + mtime. A same-session interpreter
532
+ // swap (nvm use, volta pin) would otherwise let the
533
+ // warm entry silently forward through a different node.
534
+ const nodeReal = String(e.node_realpath);
535
+ const nodeMtime = String(e.node_mtime);
536
+ if (nodeReal !== process.argv[8]) process.exit(1);
537
+ if (nodeMtime !== process.argv[9]) process.exit(1);
538
+ if (!sandboxOk || !shapeOk) process.exit(1);
539
+ process.stdout.write("ok");
540
+ } catch (e) { process.exit(1); }
541
+ ' -- "$_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)
542
+ if [ "$_cache_validate" = "ok" ]; then
543
+ _shim_cache_hit=1
544
+ fi
545
+ fi
546
+ fi
547
+ fi
548
+ fi
549
+
306
550
  # 5. Sandbox check (when CLI was resolved). On failure clear REA_ARGV
307
551
  # + stash the reason so the eventual CLI-required branch can emit
308
552
  # the correct banner. Running the sandbox check BEFORE the policy
@@ -318,7 +562,7 @@ shim_run() {
318
562
  local sandbox_result=""
319
563
  local sandbox_failed=0
320
564
  local node_missing=0
321
- if [ "${#REA_ARGV[@]}" -gt 0 ]; then
565
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && [ "$_shim_cache_hit" -eq 0 ]; then
322
566
  if ! command -v node >/dev/null 2>&1; then
323
567
  # 0.38.1 round-2 P2 fix: pre-fix this branch exited 0/2 IMMEDIATELY
324
568
  # without ever calling shim_policy_short_circuit, so a blocking-
@@ -399,8 +643,10 @@ shim_run() {
399
643
  # 8. Version probe (skipped when SHIM_SKIP_VERSION_PROBE=1, used by
400
644
  # delegation-capture whose pre-port body had no probe — a stale
401
645
  # CLI drops the signal silently rather than spamming the operator
402
- # on every Agent/Skill dispatch).
403
- if [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ]; then
646
+ # on every Agent/Skill dispatch). Also skipped on cache hit — the
647
+ # probe answer was recorded when the entry was written and the
648
+ # cache key invalidates if mtime / size / realpath changes.
649
+ if [ "$SHIM_SKIP_VERSION_PROBE" -eq 0 ] && [ "$_shim_cache_hit" -eq 0 ]; then
404
650
  local probe_out probe_status
405
651
  probe_out=$("${REA_ARGV[@]}" hook "$SHIM_NAME" --help 2>&1)
406
652
  probe_status=$?
@@ -414,6 +660,50 @@ shim_run() {
414
660
  fi
415
661
  fi
416
662
 
663
+ # 8b. Cache write (0.48.0). At this point sandbox + probe both
664
+ # succeeded — record the answers for the next fire in this
665
+ # session. Cache write failure NEVER blocks the gate; we ignore
666
+ # the return value. Skipped on a cache hit (we just used the
667
+ # entry; rewriting it would be wasted work AND would refresh
668
+ # `cached_at_unix` past the TTL ceiling, defeating the staleness
669
+ # bound).
670
+ if [ "$_shim_cache_hit" -eq 0 ] && [ -n "$_shim_cache_key" ]; then
671
+ local _write_payload=""
672
+ _write_payload=$(node -e '
673
+ const args = process.argv.slice(1);
674
+ const now = Math.floor(Date.now() / 1000);
675
+ const entry = {
676
+ schema_version: "v1",
677
+ cli_realpath: args[0],
678
+ cli_mtime: args[1],
679
+ cli_size_bytes: args[2],
680
+ // 0.48.0 codex round-3 P1+P2: record the ancestor package.json
681
+ // mtime/size + dist/cli/ dir mtime so the read-side validator
682
+ // can re-check them on every hit. The cache key includes
683
+ // these too, so a drifted state produces a different key —
684
+ // but persisting them in the entry lets the validator catch
685
+ // a key collision as a stale-entry miss instead of trusting
686
+ // it.
687
+ pkg_mtime: args[3],
688
+ pkg_size_bytes: args[4],
689
+ dist_mtime: args[5],
690
+ // 0.48.0 codex round-4 P1: record the resolved node binary
691
+ // realpath + mtime so the read-side validator can re-check
692
+ // them and refuse a hit when the interpreter swapped.
693
+ node_realpath: args[6],
694
+ node_mtime: args[7],
695
+ sandbox_ok: true,
696
+ shape_ok: true,
697
+ cached_at_unix: now,
698
+ ttl_seconds: 3600,
699
+ };
700
+ process.stdout.write(JSON.stringify(entry));
701
+ ' -- "$_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)
702
+ if [ -n "$_write_payload" ]; then
703
+ shim_cache_write "$_shim_cache_key" "$_write_payload" >/dev/null 2>&1 || true
704
+ fi
705
+ fi
706
+
417
707
  # 9. Forward stdin.
418
708
  if declare -F shim_forward >/dev/null 2>&1; then
419
709
  shim_forward