@bookedsolid/rea 0.47.0 → 0.48.1

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/MIGRATING.md CHANGED
@@ -662,3 +662,91 @@ verdict that triggered it is.
662
662
  document the ambivalence.
663
663
  - **My pre-commit hook breaks on push** → not rea (rea ships no
664
664
  pre-commit). Fix in your repo.
665
+
666
+ ## Shim session-cache (added in 0.48.0)
667
+
668
+ Every Node-binary shim (`.claude/hooks/*.sh`) does three steps that
669
+ do not change across same-session same-CLI fires:
670
+
671
+ 1. Resolve the rea CLI through the fixed 2-tier sandboxed order
672
+ (`node_modules/@bookedsolid/rea/dist/cli/index.js`, then
673
+ `./dist/cli/index.js`).
674
+ 2. Sandbox-check the resolved CLI (realpath inside project + ancestor
675
+ `package.json` with `name: @bookedsolid/rea` + optional
676
+ `dist/cli/index.js` shape enforcement).
677
+ 3. Version-probe via `rea hook <NAME> --help` to confirm the
678
+ subcommand exists in the resolved CLI.
679
+
680
+ The 0.48.0 per-session cache (`hooks/_lib/shim-cache.sh`) records
681
+ the answers under a key composed of `(session_token, project_realpath,
682
+ cli_realpath, cli_mtime, cli_size_bytes, euid, enforce_cli_shape,
683
+ shim_name, pkg_mtime, pkg_size_bytes, dist_dir_mtime, node_realpath,
684
+ node_mtime)`. Subsequent
685
+ fires that match the same key skip both the sandbox check (step 5
686
+ in `shim_run`) and the version probe (step 8). Cache hit latency is
687
+ roughly the cache-read cost (~5-10ms) instead of the full hot path
688
+ (~80-150ms on macOS).
689
+
690
+ ### Disable switches
691
+
692
+ - `REA_SHIM_CACHE=0` in env disables both reads and writes for the
693
+ current process. `pnpm perf:hooks` sets this automatically so
694
+ baseline measurements reflect the uncached path.
695
+ - `policy.shim_cache.enabled: false` disables the cache via
696
+ `.rea/policy.yaml`:
697
+ ```yaml
698
+ shim_cache:
699
+ enabled: false
700
+ ```
701
+ Default is `enabled: true`. The block is optional — a vanilla
702
+ install with no `shim_cache:` block gets the cache on.
703
+
704
+ ### Where the cache lives
705
+
706
+ `$TMPDIR/rea-shim-cache.<euid>/<key>.json` — per-user `0700`
707
+ directory, per-entry `0600` file. Entries are atomically written
708
+ via `mv` from `.tmp.$$` (no half-written reads). On reboot the
709
+ tmpfs is wiped — there is no cross-session persistence by design.
710
+
711
+ ### Invalidation
712
+
713
+ Cache entries are invalidated automatically when any of the key
714
+ fields change. The most common triggers:
715
+
716
+ - `pnpm install` / `npm install` updates the CLI mtime + size →
717
+ cache key differs → next fire takes the full path and writes a
718
+ fresh entry.
719
+ - Hard 3600s (1h) TTL ceiling enforced inside `shim_run` even when
720
+ mtime + size match — bounds staleness on long-lived sessions.
721
+ - Schema version bump (future `v2`) → every `v1` entry becomes a
722
+ cache-miss (no migration).
723
+ - Cross-user read attempts refused via owner check.
724
+
725
+ ### Forcing a cache rebuild
726
+
727
+ Use the env var or wipe the directory:
728
+
729
+ ```bash
730
+ # One invocation, uncached
731
+ REA_SHIM_CACHE=0 git commit -m "..."
732
+
733
+ # Wipe the entire cache for the current user
734
+ rm -rf "$TMPDIR/rea-shim-cache.$(id -u)"
735
+ ```
736
+
737
+ There is intentionally no `rea cache clear` CLI in 0.48.0 — the
738
+ env-var and rm patterns handle the realistic cases without adding
739
+ surface area.
740
+
741
+ ### Why this matters
742
+
743
+ Before 0.48.0 every shim fire paid the full sandbox + probe cost on
744
+ every Bash/Edit/Write/MultiEdit/NotebookEdit tool call. With 8+
745
+ hooks firing per Bash event, the cumulative latency was felt by the
746
+ operator on every single command. The cache brings warm-session
747
+ hot-path latency down to roughly the cache-read overhead, which is
748
+ the dominant cost ceiling that motivated 0.45.0's profiling work.
749
+
750
+ See `docs/shim-session-cache-design.md` for the security contract
751
+ and `docs/hook-perf-baseline.md` §"Per-session shim cache and the
752
+ baseline" for the perf methodology note.
package/THREAT_MODEL.md CHANGED
@@ -1277,3 +1277,143 @@ The bash gate explicitly does NOT defend against:
1277
1277
  to an attacker binary on PATH, the gate is defeated. Production
1278
1278
  deployments pin PATH via the harness; `rea doctor` verifies PATH
1279
1279
  integrity at install time but does not enforce it at runtime.
1280
+
1281
+ ---
1282
+
1283
+ ## 9. Per-session shim cache (0.48.0+)
1284
+
1285
+ The `hooks/_lib/shim-cache.sh` helper sits between the relevance
1286
+ pre-gate and the sandbox check inside `hooks/_lib/shim-runtime.sh`.
1287
+ On a cache HIT it short-circuits steps 5 (sandbox check) and 8
1288
+ (version probe) — those answers were validated when the entry was
1289
+ written, the cache key invalidates if any field changes, and the
1290
+ entry has a 3600-second TTL. On a cache MISS, corrupt file, or any
1291
+ error condition, the runtime falls through to the existing uncached
1292
+ hot path. The cache is an **optimization, not a security boundary**.
1293
+
1294
+ ### 9.1 Cache key construction
1295
+
1296
+ The cache key is `sha256(NUL-joined-tuple)[0:32]` of:
1297
+
1298
+ 1. `schema_version` (`"v1"`)
1299
+ 2. `session_token` (claude-ancestor PID+start-time hash, or
1300
+ tty/login-shell/boot-id fallback, or "cache disabled" if
1301
+ neither is derivable)
1302
+ 3. `project_root_realpath` (post-symlink-resolution)
1303
+ 4. `cli_realpath` (post-symlink-resolution)
1304
+ 5. `cli_mtime` (nanosecond precision uniformly across macOS and
1305
+ Linux — see §9.4 for the portability rationale; closes the
1306
+ same-second-same-size rebuild class flagged by codex round-2)
1307
+ 6. `cli_size_bytes` (defeats `touch -r` mtime-preserving swap)
1308
+ 7. `euid` (refuses cross-user reuse)
1309
+ 8. `SHIM_ENFORCE_CLI_SHAPE` (whether the `dist/cli/index.js`
1310
+ shape was required for this fire)
1311
+ 9. `SHIM_NAME` (0.48.0 codex round-1 P1 — hook-scoped key prevents
1312
+ a cache-warm shim letting a sibling skip its own probe)
1313
+ 10. `pkg_mtime` + `pkg_size` of the ancestor `package.json` the
1314
+ sandbox check found (codex round-3 P2 — same-session edit /
1315
+ rename of that file invalidates the entry)
1316
+ 11. `dist_mtime` of `dist/cli/` directory (codex round-3 P1 — a
1317
+ same-session rebuild that adds/removes ANY file in that
1318
+ directory invalidates the entry, the dominant signal for the
1319
+ rea-dev workflow where `tsc` rewrites many siblings of
1320
+ `index.js`)
1321
+ 12. `node_realpath` + `node_mtime` (codex round-4 P1 — a
1322
+ same-session `nvm use` / `volta pin` / PATH-prepended wrapper
1323
+ that swaps the node interpreter invalidates the entry; warm
1324
+ hits otherwise would skip both node-availability AND the
1325
+ version probe and forward through a different interpreter
1326
+ whose JS engine version may not match what the probe
1327
+ validated)
1328
+
1329
+ ### 9.2 Storage discipline
1330
+
1331
+ - Per-user directory at `$TMPDIR/rea-shim-cache.<euid>/`, created
1332
+ mode `0700` via `umask 077`. Reads refuse if mode is wider or
1333
+ owner != euid.
1334
+ - Per-entry file at `<dir>/<key>.json`, written mode `0600` via
1335
+ atomic `mv` from `.tmp.$$`. Reads refuse if mode is wider or
1336
+ owner != euid.
1337
+ - JSON entry shape includes `schema_version`, `cli_realpath`,
1338
+ `cli_mtime`, `cli_size_bytes`, `pkg_mtime`, `pkg_size_bytes`,
1339
+ `dist_mtime`, `node_realpath`, `node_mtime`, `sandbox_ok`,
1340
+ `shape_ok`, `cached_at_unix`, `ttl_seconds`. Any unknown / missing
1341
+ required
1342
+ field → ignore entry. Schema version bump → all v(n-1) entries
1343
+ become unreadable cache-misses (no migration).
1344
+
1345
+ ### 9.3 Attack enumeration
1346
+
1347
+ | # | Attack | Defense |
1348
+ |---|--------|---------|
1349
+ | 1 | Symlink swap of cached CLI between cache-write and cache-read | `cli_realpath` recomputed at every read and compared to entry; any drift → miss |
1350
+ | 2 | Two concurrent projects sharing `$TMPDIR` | `project_root_realpath` in key + per-user `0700` dir; cross-project read produces different key |
1351
+ | 3 | Cache file race on parallel hook fires | One file per key; identical inputs produce identical content; atomic `mv` from `.tmp.$$` ensures no partial reads |
1352
+ | 4 | Cross-user TOCTOU (other local user plants entry) | `0700` dir + `0600` file + owner check refuse foreign-owned reads |
1353
+ | 5 | mtime-preserving binary swap (attacker `touch -r`s after replacing CLI) | `cli_size_bytes` in key; size change forces miss even with preserved mtime |
1354
+ | 6 | Cached `sandbox_ok=true` after consumer moves project | `project_root_realpath` in key; CLAUDE_PROJECT_DIR drift → different key |
1355
+ | 7 | Schema rollback (older shim reads newer entry) | `schema_version` in key; cross-version reads miss cleanly |
1356
+ | 8 | Stale cache during a 0.x → 0.y upgrade mid-session | `cli_version` field + `cli_mtime` (ns precision) + `cli_size_bytes` — three independent invalidators |
1357
+ | 8b | Rebuild within the same wall-clock second + same byte length | `cli_mtime` recorded at nanosecond precision (codex round-2 P2) — APFS / ext4 / xfs all store ns mtime; a same-second rebuild moves the ns suffix so the key differs |
1358
+ | 9 | Long-lived session accumulating staleness past acceptable bound | Hard 3600s TTL enforced inside `shim-runtime.sh` even if mtime+size match |
1359
+ | 10 | Session-token spoofing on stripped containers (no `/proc`, no `ps -o lstart=`) | Final fallback is **cache disabled** (return 1), NOT "use PPID alone" — the contract is never silently weakened |
1360
+ | 11 | Same-session rebuild of CLI's hook surface that does not touch `dist/cli/index.js` itself (codex round-3 P1) | `dist_mtime` of the parent dir in the key — any add/remove in `dist/cli/` shifts the dir mtime and invalidates every entry under that CLI |
1361
+ | 12 | Same-session edit of the ancestor `package.json` that the sandbox check trusted (codex round-3 P2) | `pkg_mtime` + `pkg_size_bytes` in the key — any change forces a fresh sandbox + probe pass |
1362
+ | 13 | Same-session `node` interpreter swap (nvm use / volta pin / PATH-prepended wrapper) (codex round-4 P1 + round-7 P2) | `node_realpath` is derived from `process.execPath` (node's own path to itself), NOT from resolving the PATH-visible `node`. Stable version-manager shims like `~/.volta/bin/node` resolve to themselves; only `execPath` reveals which concrete Node binary the shim launched (e.g. `~/.volta/tools/image/node/22.x.x/bin/node`). Swapping versions changes execPath → different key |
1363
+ | 14 | Same-session rebuild that rewrites any module in `dist/**/*.js` without changing dist/cli/ — including transitively imported files like `dist/hooks/**`, `dist/policy/**`, `dist/audit/**` (codex round-5 P1 + round-9 P1) | `dist_mtime` is a sha256 of every `*.js` file's `ns-mtime / size / name` across the WHOLE dist tree (not just dist/cli/), built via `find -exec stat +` for ~15ms total cost. Any in-place rewrite of any imported module invalidates the entry. Falls back to single-dir mtime if `find`/`stat`/`shasum`/`sha256sum` are all unavailable. Hasher detection: `shasum -a 256` on macOS, `sha256sum` on GNU coreutils Linux (codex round-6 P2) |
1364
+ | 15 | Cache silently disabled on non-interactive subprocess launches (CI, vitest spawn, editor wrappers) where no claude/claude-code ancestor exists AND stdin is piped (codex round-6 P1) | Intermediate fallback: `(PPID basename + PPID start_time + boot_id)`. NOT "PPID alone" — start-time defeats PID reuse across reboots, boot-id confines to the current boot. Scopes the cache to "this specific parent process invocation, on this boot". A different parent / a reboot → different token |
1365
+ | 16 | Cache silently disabled on sandboxed macOS / locked-down CI where `/proc` is absent AND `ps`/`sysctl` are denied (codex round-8 P1) | Final fallback before disabled: `(euid + REA_ROOT)`. Coarser session scope than the process-anchored paths above, but cache poisoning still prevented by the per-user 0700 dir + 0600 file (foreign-owned reads refused) and cross-install reuse still prevented by REA_ROOT in the token PLUS every other key field (project, CLI mtime, dist hash, node execPath). The trade-off honors the spirit of design memo concern #2 (NEVER PPID alone) while letting the cache function in environments where the design's primary anchors are unavailable. Truly hostile environments (no euid AND no REA_ROOT) still fall through to "cache disabled" |
1366
+
1367
+ ### 9.4 Portability — mtime precision
1368
+
1369
+ macOS supports fractional-second mtime via `stat -f %Fm`; GNU
1370
+ coreutils supports the same via `stat -c %.Y`. Both produce the
1371
+ same `1779052861.082677123` string shape — string-equal across
1372
+ platforms for the same physical mtime. **Both are used** at
1373
+ nanosecond precision (codex round-2 P2 closure). The design memo
1374
+ concern #1 was conditional ("if you can't get ns on one platform,
1375
+ downgrade both") — since both CAN, we use ns and close the
1376
+ same-second-same-size rebuild collision class.
1377
+
1378
+ `cli_size_bytes` remains in the key as defense-in-depth for
1379
+ filesystems that truncate nanosecond mtime to seconds (some
1380
+ FAT/NTFS mounts). On those filesystems the same-second-same-size
1381
+ rebuild would still hit the cache for up to 3600s — the TTL is the
1382
+ backstop, plus `pnpm install` typically changes the file size.
1383
+ The realistic exposure is "consumer runs `npm run build` twice
1384
+ within the same wall-clock second on a FAT volume" which is a
1385
+ corner case for a dev tool.
1386
+
1387
+ ### 9.5 Disable switches
1388
+
1389
+ - `REA_SHIM_CACHE=0` in env disables both reads and writes. Used
1390
+ by `pnpm perf:hooks` so steady-state measurements reflect the
1391
+ uncached hot path (a warmed cache would silently mask
1392
+ regressions in the underlying resolve / sandbox / probe layers).
1393
+ - `policy.shim_cache.enabled: false` disables the cache via the
1394
+ policy file. The bash-tier helper consults this via a narrow
1395
+ inline YAML grep (the cache runs BEFORE the canonical 4-tier
1396
+ policy reader is available). The zod schema in
1397
+ `src/policy/loader.ts` validates the field at CLI load time so
1398
+ typos / wrong types are caught at the load boundary.
1399
+
1400
+ ### 9.6 Out of scope
1401
+
1402
+ The cache explicitly does NOT defend against:
1403
+
1404
+ - **A poisoned cache entry that happens to collide with a
1405
+ legitimate key.** The 32-hex-char (128-bit) key space and the
1406
+ fail-safe validate-on-read pass (mtime/size/realpath rechecked
1407
+ against disk, JSON shape verified, TTL enforced) make this
1408
+ economically infeasible without already having euid + tmpdir
1409
+ write access — which is a higher privilege than the gate
1410
+ protects against to begin with.
1411
+ - **An attacker with the same euid who has already compromised
1412
+ the shim runtime.** At that point the cache layer is moot —
1413
+ every code path is attacker-controlled.
1414
+ - **A long-running session where the operator wants to force a
1415
+ cache rebuild without the TTL expiring.** Set `REA_SHIM_CACHE=0`
1416
+ in the env, or `rm -rf $TMPDIR/rea-shim-cache.$(id -u)/`. There
1417
+ is intentionally no `rea cache clear` CLI surface in 0.48.0 —
1418
+ the disable switch + on-reboot tmpfs wipe handle the realistic
1419
+ cases.
@@ -301,6 +301,13 @@ declare const PolicySchema: z.ZodObject<{
301
301
  threshold?: number | undefined;
302
302
  exempt_subagents?: string[] | undefined;
303
303
  }>>;
304
+ shim_cache: z.ZodOptional<z.ZodObject<{
305
+ enabled: z.ZodDefault<z.ZodBoolean>;
306
+ }, "strict", z.ZodTypeAny, {
307
+ enabled: boolean;
308
+ }, {
309
+ enabled?: boolean | undefined;
310
+ }>>;
304
311
  }, "strict", z.ZodTypeAny, {
305
312
  version: string;
306
313
  profile: string;
@@ -379,6 +386,9 @@ declare const PolicySchema: z.ZodObject<{
379
386
  threshold: number;
380
387
  exempt_subagents: string[];
381
388
  } | undefined;
389
+ shim_cache?: {
390
+ enabled: boolean;
391
+ } | undefined;
382
392
  }, {
383
393
  version: string;
384
394
  profile: string;
@@ -457,6 +467,9 @@ declare const PolicySchema: z.ZodObject<{
457
467
  threshold?: number | undefined;
458
468
  exempt_subagents?: string[] | undefined;
459
469
  } | undefined;
470
+ shim_cache?: {
471
+ enabled?: boolean | undefined;
472
+ } | undefined;
460
473
  }>;
461
474
  /**
462
475
  * Async policy loader with TTL cache and mtime-based invalidation.
@@ -328,6 +328,32 @@ const DelegationAdvisoryPolicySchema = z
328
328
  ]),
329
329
  })
330
330
  .strict();
331
+ /**
332
+ * 0.48.0 — per-session shim cache policy. The
333
+ * `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
334
+ * shim via `hooks/_lib/shim-runtime.sh`, caches the (sandbox-ok,
335
+ * shape-ok) tuple for a given (session, project, CLI realpath,
336
+ * mtime, size, euid, enforce_shape) key, with a 3600s TTL ceiling.
337
+ * The cache is an OPTIMIZATION — every cache-miss path falls through
338
+ * to the existing uncached hot path. See
339
+ * `docs/shim-session-cache-design.md` for the full contract.
340
+ *
341
+ * Strict mode rejects unknown keys so a typo (`enabld`, `enable`)
342
+ * fails loudly at policy load. The block is optional — vanilla
343
+ * installs with no `shim_cache:` block get the default behavior
344
+ * (cache enabled). To disable: `shim_cache: { enabled: false }`.
345
+ *
346
+ * The bash-tier helper does a narrow YAML grep for the field
347
+ * BEFORE the canonical 4-tier policy reader is available (cache
348
+ * runs in the shim's pre-CLI section). This zod schema validates
349
+ * the field at CLI load time so wrong types / typos are caught at
350
+ * the load boundary.
351
+ */
352
+ const ShimCachePolicySchema = z
353
+ .object({
354
+ enabled: z.boolean().default(true),
355
+ })
356
+ .strict();
331
357
  const PolicySchema = z
332
358
  .object({
333
359
  version: z.string(),
@@ -387,6 +413,16 @@ const PolicySchema = z
387
413
  // when unset/false). When the block IS present the inner schema
388
414
  // supplies defaults for any omitted field.
389
415
  delegation_advisory: DelegationAdvisoryPolicySchema.optional(),
416
+ // 0.48.0 per-session shim cache — drives `hooks/_lib/shim-cache.sh`
417
+ // which short-circuits the sandbox check + version probe in
418
+ // `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
419
+ // shim. Optional — vanilla installs get the default behavior
420
+ // (cache enabled). The bash-tier `shim_cache_disabled` helper
421
+ // honors `enabled: false` via a narrow inline YAML grep before
422
+ // the canonical policy reader is reachable. `REA_SHIM_CACHE=0`
423
+ // in env overrides this to `false` for the current invocation
424
+ // regardless of policy.
425
+ shim_cache: ShimCachePolicySchema.optional(),
390
426
  })
391
427
  .strict();
392
428
  const DEFAULT_CACHE_TTL_MS = 30_000;
@@ -533,4 +533,56 @@ export interface Policy {
533
533
  * blocks. See `DelegationAdvisoryPolicy` for the full contract.
534
534
  */
535
535
  delegation_advisory?: DelegationAdvisoryPolicy;
536
+ /**
537
+ * Per-session shim cache (0.48.0+).
538
+ *
539
+ * The `hooks/_lib/shim-cache.sh` helper, sourced by every Node-binary
540
+ * shim via `hooks/_lib/shim-runtime.sh`, records the answers to the
541
+ * sandbox check + version probe under a per-user, per-session,
542
+ * per-CLI key. Subsequent shim fires within the same Claude Code
543
+ * session against the same CLI (mtime + size unchanged) skip
544
+ * straight to the forward step.
545
+ *
546
+ * The cache is an OPTIMIZATION, not a security boundary. Cache miss
547
+ * / disabled / corruption all fall through to the existing uncached
548
+ * hot path — never fail closed.
549
+ *
550
+ * `enabled` default: `true`. Set `false` to disable the cache layer
551
+ * at the policy tier (equivalent effect to setting the
552
+ * `REA_SHIM_CACHE=0` env var on every invocation). Operators who
553
+ * want to measure unconditional steady-state latency should use the
554
+ * env-var form so the cache stays off only for the measurement
555
+ * window. See `docs/shim-session-cache-design.md` for the security
556
+ * contract and `docs/hook-perf-baseline.md` for the perf
557
+ * methodology note.
558
+ */
559
+ shim_cache?: ShimCachePolicy;
560
+ }
561
+ /**
562
+ * Per-session shim cache policy (0.48.0+).
563
+ *
564
+ * The cache short-circuits the sandbox check + version probe in
565
+ * `hooks/_lib/shim-runtime.sh` on session-warm fires of the same
566
+ * shim. The on-disk entry shape is bound to `schema_version: "v1"`
567
+ * — a schema bump (future cache field additions) invalidates every
568
+ * existing entry. TTL is hard-capped at 3600s (1h) inside the
569
+ * runtime; this block does not expose a TTL knob in 0.48.0 because
570
+ * the optimization is steady-state-bound and a longer TTL would
571
+ * extend staleness without measurable benefit.
572
+ */
573
+ export interface ShimCachePolicy {
574
+ /**
575
+ * Master switch. `true` (default) enables the cache. `false`
576
+ * disables both reads and writes — the runtime falls through to
577
+ * the existing uncached hot path on every fire. `REA_SHIM_CACHE=0`
578
+ * in env overrides this to `false` for the current invocation
579
+ * regardless of policy.
580
+ *
581
+ * NOTE 0.48.0: the bash-tier `shim_cache_disabled` helper consults
582
+ * this field via a narrow YAML grep BEFORE the canonical 4-tier
583
+ * policy reader is available (cache runs in the shim's pre-CLI
584
+ * section). The TS loader's schema validation runs at full CLI
585
+ * load time and catches typos / wrong types.
586
+ */
587
+ enabled?: boolean;
536
588
  }