@bookedsolid/rea 0.46.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.
package/MIGRATING.md CHANGED
@@ -528,6 +528,95 @@ under `--since` and lets the per-record timestamp filter drop the
528
528
  out-of-window entries. Correctness over micro-optimization;
529
529
  `rea audit summary` performance is unchanged in practice.
530
530
 
531
+ ## Audit observability completion (added in 0.47.0)
532
+
533
+ 0.46.0 shipped `rea audit by-tool` and `rea audit timeline`. 0.47.0
534
+ rounds out the observability surface with two timeline ergonomics fixes
535
+ and a new refusal-debugging reader:
536
+
537
+ ### `rea audit timeline` — helpful MAX_BUCKETS errors + auto-clamp
538
+
539
+ Pre-0.47.0, `rea audit timeline --bucket=15m --since=21d` (= 2016
540
+ buckets, just past the 2000-bucket ceiling) rejected with a generic
541
+ "use a larger --bucket or narrower --since" message. The 0.47.0 error
542
+ now carries concrete remediation:
543
+
544
+ ```text
545
+ rea audit timeline: --bucket=15m × --since=21d = 2016 buckets exceeds
546
+ MAX_BUCKETS=2000. Try --bucket=1h (504 buckets) or --since=20d 20h
547
+ (1999 buckets).
548
+ ```
549
+
550
+ For the related "I omitted `--since` and the audit log spans a year"
551
+ case, the timeline now AUTO-CLAMPS to the widest window that fits at
552
+ the requested cadence rather than throwing. The clamp is surfaced
553
+ inline in human output:
554
+
555
+ ```text
556
+ rea audit timeline (clamped to ~1999h of newest activity, hourly)
557
+ ────────────────────────────────────────
558
+ note: --since not specified; auto-clamped to newest 2000 buckets
559
+ (~1999h span at --bucket=1h). Pass --since=DUR to anchor at
560
+ now, or rerun with a WIDER --bucket (current 1h) to fit the
561
+ full log.
562
+
563
+ ```
564
+
565
+ JSON consumers see the clamp as a new `clamped_since` field — `null`
566
+ in the common case, a duration string (e.g. `"1999h"`) when the
567
+ clamp fired. The field is informational, not reproducible: `--since`
568
+ always anchors at `now`, so a clamp anchored at an older record
569
+ cannot be round-tripped through `--since=<clamped_since>`. Use the
570
+ field to detect that clamping occurred and to size the rendered
571
+ window in dashboards. For a fully reproducible view, pass `--since`
572
+ or `--bucket` explicitly. Schema version is unchanged (still v1) —
573
+ the field is purely additive. `window.start/end/seconds` is also
574
+ nulled out on sparse-log clamps where the kept buckets don't form a
575
+ contiguous time lattice, so `total_events / window.seconds` never
576
+ derives a misleading rate.
577
+
578
+ ### `rea audit top-blocks` — debugging "why was that refused?"
579
+
580
+ A new subcommand surfaces the most recent refusal events (any record
581
+ whose `status` is `denied` or `error`) from the audit log:
582
+
583
+ ```bash
584
+ rea audit top-blocks # last 20 refusals, all time
585
+ rea audit top-blocks --since=24h # last 24h
586
+ rea audit top-blocks --since=7d --limit=50 # last week, top 50
587
+ rea audit top-blocks --json # dashboard shape
588
+ ```
589
+
590
+ Each row carries the short hash (first 8 chars), full timestamp, tool
591
+ name, and the refusal reason (sourced from the record's `error` field;
592
+ truncated to ~80 chars in human output, full text in JSON). Sorted
593
+ newest-first so the most recent refusals are at the top.
594
+
595
+ Use this when an agent reports "the hook blocked my push" or "the
596
+ write was refused" and you need the exact reason without grepping
597
+ `.rea/audit.jsonl` by hand.
598
+
599
+ JSON shape (stable, v1):
600
+
601
+ ```json
602
+ {
603
+ "schema_version": 1,
604
+ "since": "24h",
605
+ "limit": 20,
606
+ "window": { "seconds": 86400, "start": "...", "end": "..." },
607
+ "total_matched": 4,
608
+ "events": [
609
+ { "hash": "...", "timestamp": "...", "tool": "Bash",
610
+ "status": "denied", "reason": "...", "session_id": "..." }
611
+ ],
612
+ "files_scanned": ["/abs/path/.rea/audit.jsonl"]
613
+ }
614
+ ```
615
+
616
+ `total_matched` is the pre-limit count, so dashboards can show
617
+ "20 of 47 refusals in window". Walk scope mirrors the sibling audit
618
+ readers — current `.rea/audit.jsonl` PLUS every rotated segment.
619
+
531
620
  ## Policy knobs worth setting
532
621
 
533
622
  For consumers with a long-running migration branch (>30 commits since
@@ -573,3 +662,91 @@ verdict that triggered it is.
573
662
  document the ambivalence.
574
663
  - **My pre-commit hook breaks on push** → not rea (rea ships no
575
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.
@@ -112,6 +112,16 @@ export interface AuditTimelineResult {
112
112
  /** Index of the bucket with the highest count. `-1` when no events. */
113
113
  peak_index: number;
114
114
  files_scanned: string[];
115
+ /**
116
+ * 0.47.0 charter item 2: when `--since` was NOT specified and the
117
+ * audit log spans more than `MAX_BUCKETS` buckets at the requested
118
+ * cadence, the timeline auto-clamps the window to the widest duration
119
+ * that fits. This field carries the duration string that was actually
120
+ * applied (e.g. `"7d"`) — `null` when no clamping fired (the common
121
+ * case). Dashboard consumers use this to flag "the window you saw is
122
+ * not the whole log" in their UI.
123
+ */
124
+ clamped_since: string | null;
115
125
  }
116
126
  export interface ComputeAuditTimelineOptions {
117
127
  /** Override CWD. Tests set this; production uses `process.cwd()`. */
@@ -134,6 +144,16 @@ export interface ComputeAuditTimelineOptions {
134
144
  * value but `MAX_BUCKETS` will bound the rendered output.
135
145
  */
136
146
  export declare function resolveBucketSeconds(raw: string): number;
147
+ /**
148
+ * Format a duration in seconds as the coarsest single-unit compact
149
+ * string that round-trips through `parseDurationSeconds`. Mirrors the
150
+ * shape `--since` accepts (`s`/`m`/`h`/`d`/`w`).
151
+ *
152
+ * 0.47.0 charter item 1: powers the helpful-error suggestion + the
153
+ * auto-clamp `clamped_since` field. The largest-unit pass keeps the
154
+ * suggestion readable — `"21d"` not `"1814400s"`.
155
+ */
156
+ export declare function formatDurationCompact(seconds: number): string;
137
157
  /**
138
158
  * Compute the bucketed timeline. Pure (read-only). Throws
139
159
  * `AuditTimelineOptionError` on bad `--since` / `--bucket`; throws on