@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.
@@ -0,0 +1,650 @@
1
+ #!/bin/bash
2
+ # hooks/_lib/shim-cache.sh — per-session shim cache helper.
3
+ # Introduced 0.48.0.
4
+ #
5
+ # Source via:
6
+ # source "$(dirname "$0")/_lib/shim-cache.sh"
7
+ #
8
+ # # What this is
9
+ #
10
+ # An OPTIMIZATION layer over `shim_run`'s sandbox check + version
11
+ # probe (steps 5-8 of shim-runtime.sh). When the same shim fires
12
+ # repeatedly in the same Claude Code session against the same CLI on
13
+ # the same project, the answers to "is the CLI inside the sandbox"
14
+ # and "does it implement this subcommand" do not change. The cache
15
+ # records those answers keyed on every input the sandbox check
16
+ # would otherwise re-verify, so subsequent fires can skip straight
17
+ # to the forward step.
18
+ #
19
+ # # What this is NOT
20
+ #
21
+ # A SECURITY boundary. Every cache-miss path falls through to the
22
+ # existing uncached hot path in `shim_run`. Every cache operation is
23
+ # fail-safe — corruption / parse failure / stat failure / hash
24
+ # failure all degrade to a clean miss, NEVER fail-closed. A cached
25
+ # entry that goes stale produces a different key (one of:
26
+ # mtime/size/realpath/euid changed), so the wrong CLI cannot be
27
+ # executed from a poisoned entry.
28
+ #
29
+ # See `docs/shim-session-cache-design.md` for the full security
30
+ # contract (key construction, threat enumeration, invalidation
31
+ # triggers).
32
+ #
33
+ # # Cache key fields (NUL-joined, sha256-hashed, first 32 hex chars)
34
+ #
35
+ # schema_version — "v1"
36
+ # session_token — see shim_cache_session_token
37
+ # project_root_realpath — realpath(CLAUDE_PROJECT_DIR)
38
+ # cli_realpath — realpath(resolved CLI)
39
+ # cli_mtime — `stat` mtime (ns precision; see "Portability"
40
+ # below for why both platforms can do ns)
41
+ # cli_size_bytes — `stat` size — defeats `touch -r` mtime-preserving
42
+ # swap
43
+ # euid — `id -u` — refuses cross-user cache reuse
44
+ # enforce_cli_shape — SHIM_ENFORCE_CLI_SHAPE value
45
+ # shim_name — SHIM_NAME (0.48.0 codex round-1 P1: the
46
+ # version probe is `rea hook $SHIM_NAME --help`,
47
+ # so the cache must be hook-scoped to prevent
48
+ # a cache-warm shim letting a sibling skip its
49
+ # OWN version-skew check)
50
+ # pkg_mtime / pkg_size — `stat` of the ancestor package.json the
51
+ # sandbox check found (codex round-3 P2: a
52
+ # same-session edit or rename of that
53
+ # package.json invalidates the entry)
54
+ # dist_mtime — `stat` mtime of dist/cli/ (codex round-3 P1:
55
+ # a same-session rebuild that adds or removes
56
+ # ANY file in dist/cli/ invalidates the entry;
57
+ # this is the dominant signal for the rea-dev
58
+ # workflow where `pnpm build` rewrites many
59
+ # siblings of index.js)
60
+ # node_realpath — realpath(resolved `node` binary) (codex
61
+ # round-4 P1: a same-session `nvm use` or PATH
62
+ # prepend that swaps the interpreter invalidates
63
+ # the entry, since the warm hit otherwise skips
64
+ # the node-availability check + version probe)
65
+ # node_mtime — `stat` mtime of the node binary (defense for
66
+ # a swap that re-resolves to the SAME realpath
67
+ # but with a different binary, e.g. an in-place
68
+ # rebuild of node itself; rare but cheap)
69
+ #
70
+ # # Storage
71
+ #
72
+ # $TMPDIR/rea-shim-cache.<euid>/<key>.json
73
+ #
74
+ # Per-user directory created mode 0700 (umask 077). Per-entry file
75
+ # written mode 0600 via atomic `mv` from `.tmp.$$`. Entries refuse
76
+ # to be read when:
77
+ #
78
+ # - directory mode is wider than 0700 OR owned by another user
79
+ # - file mode is wider than 0600 OR owned by another user
80
+ # - JSON parse fails OR required field missing
81
+ # - `cli_mtime` / `cli_size_bytes` from disk differ from the
82
+ # entry (caller already includes those in the key, but we double-
83
+ # check post-load to defend against a key-collision attacker)
84
+ # - `cached_at_unix + ttl_seconds < now` — TTL hard ceiling 3600s
85
+ #
86
+ # # Portability — mtime precision
87
+ #
88
+ # 0.48.0 codex round-2 P2: nanosecond precision used uniformly on both
89
+ # platforms. macOS `stat -f %Fm` produces fractional seconds (e.g.
90
+ # `1779052861.082677123`); GNU coreutils `stat -c %.Y` produces the
91
+ # same string shape. Both round to the underlying filesystem's
92
+ # resolution (APFS / ext4 both store ns mtime today). The design memo
93
+ # concern #1 was conditional ("if you can't on one platform, downgrade
94
+ # both") — since both platforms CAN, we use ns and close the
95
+ # same-second-same-size rebuild collision class.
96
+ #
97
+ # `cli_size_bytes` remains in the key as defense-in-depth against
98
+ # nanosecond-truncating filesystems (some FAT/NTFS mounts) — there
99
+ # the second-precision mtime would still discriminate at the cost of
100
+ # the same-second-same-size hole, which is an extreme corner case for
101
+ # a dev tool.
102
+ #
103
+ # # Portability — sha256
104
+ #
105
+ # `shasum -a 256` ships with macOS and most Linux distros (perl
106
+ # bundle). `sha256sum` ships with GNU coreutils on Linux. We try both;
107
+ # if neither is present the cache returns a clean miss (NEVER fails
108
+ # closed).
109
+ #
110
+ # # Disable switch
111
+ #
112
+ # `REA_SHIM_CACHE=0` in env disables both reads and writes (this is
113
+ # what `pnpm perf:hooks` sets so steady-state numbers do not silently
114
+ # improve and mask regressions in the underlying resolve/probe layers).
115
+ # `policy.shim_cache.enabled: false` is a forward-compatibility hook
116
+ # (schema landed in 0.48.0; honored at the bash layer below).
117
+ #
118
+ # # Public API
119
+ #
120
+ # shim_cache_disabled — returns 0 if cache OFF, 1 if ON
121
+ # shim_cache_session_token — prints session token to stdout
122
+ # (empty + exit 1 means "cache
123
+ # disabled, no token derivable")
124
+ # shim_cache_key — args:
125
+ # schema_version session_token
126
+ # project_realpath cli_realpath
127
+ # cli_mtime cli_size euid
128
+ # enforce_cli_shape shim_name
129
+ # (9 args; the helper accepts
130
+ # variadic but the caller in
131
+ # shim-runtime always passes 9)
132
+ # prints the 32-char hex key
133
+ # (exit 1 on hash failure → caller
134
+ # treats as clean miss)
135
+ # shim_cache_read <key> — prints the cached JSON line on hit;
136
+ # exit 0 on hit, 1 on miss/error
137
+ # shim_cache_write <key> <json> — atomic write; exit 0 on success,
138
+ # 1 on error (callers ignore the
139
+ # return value — cache write
140
+ # failure NEVER blocks the gate)
141
+ #
142
+ # All operations are wrapped to fail-safe. A `set -e` inside the cache
143
+ # block is forbidden — use explicit `|| true` per step.
144
+
145
+ # -----------------------------------------------------------------------------
146
+ # Internal: portable nanosecond-precision mtime + size. Echoes
147
+ # "<mtime_with_fractional_seconds> <size_bytes>" on success; empty
148
+ # string on failure. 0.48.0 codex round-2 P2: uses ns precision on
149
+ # both platforms (was second-only before) to close the
150
+ # same-second-same-size rebuild collision class. macOS `%Fm` and GNU
151
+ # `%.Y` both produce the `1779052861.082677123` shape — string-equal
152
+ # across platforms for the same physical mtime.
153
+ # -----------------------------------------------------------------------------
154
+ _shim_cache_stat_mtime_size() {
155
+ local file="$1"
156
+ local out=""
157
+ if [ "$(uname -s 2>/dev/null || echo)" = "Darwin" ]; then
158
+ out=$(stat -f "%Fm %z" "$file" 2>/dev/null || true)
159
+ else
160
+ out=$(stat -c "%.Y %s" "$file" 2>/dev/null || true)
161
+ fi
162
+ printf '%s' "$out"
163
+ }
164
+
165
+ # -----------------------------------------------------------------------------
166
+ # Internal: sha256 of NUL-joined args. Echoes the first 32 hex chars on
167
+ # success; empty string + non-zero exit on failure.
168
+ # -----------------------------------------------------------------------------
169
+ _shim_cache_sha256_hex() {
170
+ # Build the NUL-joined payload on stdin so we never argv-leak content.
171
+ local first=1
172
+ local arg
173
+ {
174
+ for arg in "$@"; do
175
+ if [ "$first" -eq 1 ]; then
176
+ first=0
177
+ else
178
+ printf '\0'
179
+ fi
180
+ printf '%s' "$arg"
181
+ done
182
+ } | _shim_cache_sha256_pipe
183
+ }
184
+
185
+ _shim_cache_sha256_pipe() {
186
+ local out=""
187
+ if command -v shasum >/dev/null 2>&1; then
188
+ out=$(shasum -a 256 2>/dev/null | awk '{print $1}' || true)
189
+ elif command -v sha256sum >/dev/null 2>&1; then
190
+ out=$(sha256sum 2>/dev/null | awk '{print $1}' || true)
191
+ fi
192
+ if [ -z "$out" ]; then
193
+ return 1
194
+ fi
195
+ # First 32 hex chars (128 bits) — plenty for cache-key uniqueness.
196
+ printf '%s' "${out:0:32}"
197
+ }
198
+
199
+ # -----------------------------------------------------------------------------
200
+ # Disable switch. Returns 0 if cache OFF, 1 if cache ON.
201
+ # Cache is OFF when:
202
+ # - REA_SHIM_CACHE=0 in env, OR
203
+ # - policy.shim_cache.enabled is explicitly "false" (best-effort read
204
+ # via grep — the cache short-circuits BEFORE the policy reader is
205
+ # available, so we use a lightweight pattern. The policy schema in
206
+ # src/policy/loader.ts validates the field at CLI load time; this
207
+ # read only fires before that).
208
+ # -----------------------------------------------------------------------------
209
+ shim_cache_disabled() {
210
+ if [ "${REA_SHIM_CACHE:-1}" = "0" ]; then
211
+ return 0
212
+ fi
213
+ # Best-effort policy read. The cache layer runs in the shim's
214
+ # pre-CLI section, so the canonical 4-tier policy reader may not yet
215
+ # have been sourced. We do a narrow inline grep — the goal is forward-
216
+ # compat: a consumer who wants to disable the cache via policy (not
217
+ # env) gets the right behavior. A parse failure or missing key
218
+ # silently leaves the cache enabled (cache being on is the safer
219
+ # default — at worst it adds latency, never refuses).
220
+ local policy_path="${REA_ROOT:-}/.rea/policy.yaml"
221
+ if [ -f "$policy_path" ]; then
222
+ # 0.48.0 codex round-1 P2: handle BOTH block-form AND flow-form
223
+ # YAML. The TypeScript loader accepts both shapes (zod schema is
224
+ # form-agnostic); the bash helper must match. Forms accepted:
225
+ #
226
+ # shim_cache:
227
+ # enabled: false <-- block-form
228
+ #
229
+ # shim_cache: { enabled: false } <-- flow-form
230
+ # shim_cache: {enabled: false} <-- flow-form, no spaces
231
+ #
232
+ # awk pattern keeps the apostrophe rule from 0.34.0 lockout in mind:
233
+ # no single quotes inside awk single-quoted body (use \047 if needed).
234
+ local result=""
235
+ # 0.48.0 codex round-2 P2: tolerate inline YAML comments. Both
236
+ # forms can end with ` # ...`-style trailing comments — `enabled:
237
+ # false # temporary` is valid YAML and the TS loader accepts it.
238
+ # The trailing-content trailer matches any whitespace + optional
239
+ # `#` + rest-of-line. The block-form section opener is also
240
+ # comment-tolerant.
241
+ #
242
+ # 0.48.0 codex round-4 P2: YAML accepts mixed-case booleans like
243
+ # `False`, `FALSE`. The TS loader (yaml.parse → zod boolean)
244
+ # accepts those spellings. To match we lowercase each line via
245
+ # `tolower()` before pattern matching. The `false` literal in the
246
+ # regex matches the canonical lowercase form after normalization.
247
+ # 0.48.0 codex round-9 P3: tolerate leading indentation on the
248
+ # `shim_cache:` opener. The TS loader accepts a policy.yaml
249
+ # reformatted as ` shim_cache:\n enabled: false` (uncommon
250
+ # but valid YAML). Pre-fix the bash matcher pinned column 0.
251
+ # We strip leading whitespace from the line for matching
252
+ # purposes; for the block-form sub-block scan we ALSO track the
253
+ # opener's indentation depth so we don't mistake a deeper-
254
+ # indented sibling block's `enabled: false` for ours. The
255
+ # block-form-end heuristic is now "first non-empty line at or
256
+ # below the opener's indent level".
257
+ result=$(awk '
258
+ {
259
+ lc = tolower($0)
260
+ # Compute indentation depth of current line (number of
261
+ # leading whitespace characters before any non-ws).
262
+ indent_of_line = match(lc, /[^[:space:]]/) - 1
263
+ if (indent_of_line < 0) indent_of_line = 0
264
+ }
265
+ # Flow-form: leading whitespace allowed before the key.
266
+ lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{[^}]*enabled[[:space:]]*:[[:space:]]*false[^}]*\}([[:space:]]*(#.*)?)?$/ {
267
+ print "off"; exit
268
+ }
269
+ # Block-form opener: leading whitespace allowed.
270
+ lc ~ /^[[:space:]]*shim_cache:[[:space:]]*(#.*)?$/ {
271
+ in_block = 1
272
+ opener_indent = indent_of_line
273
+ next
274
+ }
275
+ # End the block when we see a non-empty line at or below the
276
+ # opener indent (a sibling YAML key at the same level).
277
+ in_block && lc !~ /^[[:space:]]*$/ && indent_of_line <= opener_indent {
278
+ in_block = 0
279
+ }
280
+ in_block && lc ~ /^[[:space:]]+enabled:[[:space:]]*false([[:space:]]+(#.*)?)?[[:space:]]*$/ {
281
+ print "off"; exit
282
+ }
283
+ ' "$policy_path" 2>/dev/null || true)
284
+ if [ "$result" = "off" ]; then
285
+ return 0
286
+ fi
287
+ fi
288
+ return 1
289
+ }
290
+
291
+ # -----------------------------------------------------------------------------
292
+ # Session token derivation. See design §3.
293
+ #
294
+ # 1. Walk PPID ancestry up to N hops looking for an ancestor whose
295
+ # command name matches "claude" or "claude-code". Use that PID +
296
+ # start-time, sha256-hashed.
297
+ # 2. Fallback: tty_name + login_shell_pid + boot_id (or kernel
298
+ # uptime on macOS where /proc/sys/kernel/random/boot_id is
299
+ # absent).
300
+ # 3. Final fallback: empty + exit 1. NEVER use PPID alone.
301
+ #
302
+ # Echoes the token (32 hex chars) to stdout on success; empty + exit
303
+ # 1 means the caller should treat the cache as disabled for this
304
+ # invocation.
305
+ # -----------------------------------------------------------------------------
306
+ shim_cache_session_token() {
307
+ local pid="$$"
308
+ local ppid="${PPID:-0}"
309
+ local cur="$ppid"
310
+ local hops=0
311
+ local max_hops=20
312
+ local match_pid=""
313
+ local match_start=""
314
+
315
+ while [ "$cur" -gt 1 ] && [ "$hops" -lt "$max_hops" ]; do
316
+ local comm=""
317
+ # Linux: /proc/<pid>/comm has the basename (truncated to 15 chars).
318
+ if [ -r "/proc/$cur/comm" ]; then
319
+ comm=$(cat "/proc/$cur/comm" 2>/dev/null || true)
320
+ else
321
+ # macOS / BSD: ps -o comm=
322
+ comm=$(ps -o comm= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
323
+ # ps may give "/usr/local/bin/claude" — basename it.
324
+ comm=$(basename "$comm" 2>/dev/null || true)
325
+ fi
326
+ case "$comm" in
327
+ claude|claude-code)
328
+ # 0.48.0 codex round-1 P2: claude and claude-code are the only
329
+ # ancestors we accept as authoritative session anchors. We
330
+ # previously also matched `cli` / `node` here without setting
331
+ # match_pid — confusing dead branch removed. Non-TTY launches
332
+ # under `cli` or `node` parents reach the tty/login-shell/boot-id
333
+ # fallback below; if THAT also fails (truly stripped container)
334
+ # the final `cache disabled` path fires and the gate runs
335
+ # uncached. The narrower match list keeps the session-token
336
+ # contract strict — we don't want to accept ANY `node`
337
+ # ancestor since that scope leaks across unrelated processes.
338
+ match_pid="$cur"
339
+ break
340
+ ;;
341
+ esac
342
+ # Walk up.
343
+ local next=""
344
+ if [ -r "/proc/$cur/status" ]; then
345
+ next=$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)
346
+ else
347
+ next=$(ps -o ppid= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
348
+ fi
349
+ if [ -z "$next" ] || [ "$next" = "$cur" ] || [ "$next" -le 1 ] 2>/dev/null; then
350
+ break
351
+ fi
352
+ cur="$next"
353
+ hops=$((hops + 1))
354
+ done
355
+
356
+ if [ -n "$match_pid" ]; then
357
+ # Get process start time. Linux: /proc/<pid>/stat field 22 (jiffies
358
+ # since boot). macOS: ps -o lstart= -p <pid>.
359
+ if [ -r "/proc/$match_pid/stat" ]; then
360
+ match_start=$(awk '{print $22}' "/proc/$match_pid/stat" 2>/dev/null || true)
361
+ fi
362
+ if [ -z "$match_start" ]; then
363
+ match_start=$(ps -o lstart= -p "$match_pid" 2>/dev/null || true)
364
+ fi
365
+ if [ -n "$match_start" ]; then
366
+ _shim_cache_sha256_hex "claude-ancestor" "$match_pid" "$match_start"
367
+ return $?
368
+ fi
369
+ fi
370
+
371
+ # Fallback: tty + login-shell-pid + boot identifier.
372
+ local tty_name=""
373
+ tty_name=$(tty 2>/dev/null || true)
374
+ local login_pid=""
375
+ # Walk PPID chain looking for a shell (basename match against typical
376
+ # login shells). This is best-effort.
377
+ cur="$ppid"
378
+ hops=0
379
+ while [ "$cur" -gt 1 ] && [ "$hops" -lt "$max_hops" ]; do
380
+ local comm=""
381
+ if [ -r "/proc/$cur/comm" ]; then
382
+ comm=$(cat "/proc/$cur/comm" 2>/dev/null || true)
383
+ else
384
+ comm=$(ps -o comm= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
385
+ comm=$(basename "$comm" 2>/dev/null || true)
386
+ fi
387
+ case "$comm" in
388
+ bash|zsh|fish|sh|dash|ksh|tcsh|csh|-bash|-zsh)
389
+ login_pid="$cur"
390
+ break
391
+ ;;
392
+ esac
393
+ local next=""
394
+ if [ -r "/proc/$cur/status" ]; then
395
+ next=$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)
396
+ else
397
+ next=$(ps -o ppid= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
398
+ fi
399
+ if [ -z "$next" ] || [ "$next" = "$cur" ] || [ "$next" -le 1 ] 2>/dev/null; then
400
+ break
401
+ fi
402
+ cur="$next"
403
+ hops=$((hops + 1))
404
+ done
405
+
406
+ local boot_id=""
407
+ if [ -r "/proc/sys/kernel/random/boot_id" ]; then
408
+ boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)
409
+ else
410
+ # macOS / BSD: kern.boottime (seconds-since-epoch of the last boot).
411
+ boot_id=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/[^0-9]//g' || true)
412
+ fi
413
+
414
+ if [ -n "$tty_name" ] && [ -n "$login_pid" ] && [ -n "$boot_id" ]; then
415
+ _shim_cache_sha256_hex "tty-fallback" "$tty_name" "$login_pid" "$boot_id"
416
+ return $?
417
+ fi
418
+
419
+ # 0.48.0 codex round-6 P1: intermediate fallback for non-interactive
420
+ # subprocess launches (CI, vitest harness, editor-spawned subprocess)
421
+ # where:
422
+ # - no claude/claude-code ancestor exists (running under a different
423
+ # harness or none at all)
424
+ # - no tty (stdin is piped)
425
+ # without this, the tty fallback above fails and the function returns
426
+ # 1 → cache disabled → cumulative latency the cache exists to fix.
427
+ #
428
+ # The token is composed of PPID's basename + PPID's start-time + the
429
+ # boot identifier. This is NOT "use PPID alone" — the start-time
430
+ # disambiguates PID reuse across reboots, and the boot-id confines
431
+ # it to the current boot. A different parent process → different
432
+ # token; a reboot → different token. The session is scoped to
433
+ # "this specific parent invocation, on this boot". Slightly broader
434
+ # than the tty-fallback (which scopes to "this tty session") but
435
+ # narrower than "no cache at all". Requires lstart and a boot
436
+ # identifier; if either is missing we fall through to the final
437
+ # disabled state per design memo concern #2.
438
+ local ppid_comm=""
439
+ if [ -r "/proc/$ppid/comm" ]; then
440
+ ppid_comm=$(cat "/proc/$ppid/comm" 2>/dev/null || true)
441
+ else
442
+ ppid_comm=$(ps -o comm= -p "$ppid" 2>/dev/null | awk '{print $1}' || true)
443
+ ppid_comm=$(basename "$ppid_comm" 2>/dev/null || true)
444
+ fi
445
+ local ppid_start=""
446
+ if [ -r "/proc/$ppid/stat" ]; then
447
+ ppid_start=$(awk '{print $22}' "/proc/$ppid/stat" 2>/dev/null || true)
448
+ fi
449
+ if [ -z "$ppid_start" ]; then
450
+ ppid_start=$(ps -o lstart= -p "$ppid" 2>/dev/null || true)
451
+ fi
452
+ if [ -n "$ppid_comm" ] && [ -n "$ppid_start" ] && [ -n "$boot_id" ]; then
453
+ _shim_cache_sha256_hex "ppid-fallback" "$ppid_comm" "$ppid_start" "$boot_id"
454
+ return $?
455
+ fi
456
+
457
+ # 0.48.0 codex round-8 P1: sandbox-safe fallback for environments
458
+ # where /proc is absent AND ps + sysctl are denied (sandboxed
459
+ # macOS runs, locked-down CI). In those environments the boot_id
460
+ # path above fails and the function would previously return 1
461
+ # → cache permanently disabled.
462
+ #
463
+ # This fallback derives the token from `(euid + REA_ROOT path)`
464
+ # — which is broader than a process-scoped session but still
465
+ # scoped to "this user's REA install". Cache poisoning is
466
+ # prevented by the per-user 0700 directory + 0600 entry file
467
+ # (another local user cannot plant entries in our cache dir).
468
+ # Cross-install reuse is prevented by REA_ROOT being part of the
469
+ # token AND by every cache key field below this layer encoding
470
+ # project + CLI mtime + dist hash — a rebuild invalidates.
471
+ #
472
+ # This satisfies concern #2's intent: it never uses PPID alone.
473
+ # The trade-off is a coarser "session" scope (broader than a
474
+ # single Claude Code session) in exchange for the cache actually
475
+ # functioning in sandboxed environments.
476
+ local euid=""
477
+ euid=$(id -u 2>/dev/null || true)
478
+ if [ -n "$euid" ] && [ -n "${REA_ROOT:-}" ]; then
479
+ _shim_cache_sha256_hex "user-project-fallback" "$euid" "$REA_ROOT"
480
+ return $?
481
+ fi
482
+
483
+ # Final fallback per design §3 concern #2: cache disabled.
484
+ # Truly hostile environment with no euid AND no REA_ROOT —
485
+ # NEVER weaken the contract by accepting PPID alone.
486
+ return 1
487
+ }
488
+
489
+ # -----------------------------------------------------------------------------
490
+ # Cache key derivation.
491
+ #
492
+ # shim_cache_key SCHEMA SESSION_TOKEN PROJECT_REALPATH CLI_REALPATH \
493
+ # CLI_MTIME CLI_SIZE EUID ENFORCE_SHAPE SHIM_NAME \
494
+ # PKG_MTIME PKG_SIZE DIST_DIR_MTIME \
495
+ # NODE_REALPATH NODE_MTIME
496
+ #
497
+ # Echoes the 32-char hex key on success; empty + exit 1 on hash
498
+ # failure. 0.48.0 evolution:
499
+ # - codex round-1 P1: SHIM_NAME added so the cache is hook-scoped
500
+ # (the skipped probe `rea hook \$SHIM_NAME --help` is hook-specific)
501
+ # - codex round-3 P1+P2: PKG_MTIME / PKG_SIZE / DIST_DIR_MTIME added
502
+ # so an edit to the ancestor package.json (closes P2) or a rebuild
503
+ # that touches the dist/cli/ directory (closes P1's same-session
504
+ # rebuild gap) invalidates the entry.
505
+ # - codex round-4 P1: NODE_REALPATH / NODE_MTIME added so a
506
+ # same-session `nvm use` / `volta pin` / PATH-prepended wrapper
507
+ # invalidates the entry (the warm hit would otherwise skip both
508
+ # node-availability AND the version probe, forwarding through a
509
+ # different interpreter).
510
+ # -----------------------------------------------------------------------------
511
+ shim_cache_key() {
512
+ if [ "$#" -lt 14 ]; then
513
+ return 1
514
+ fi
515
+ _shim_cache_sha256_hex "$@"
516
+ }
517
+
518
+ # -----------------------------------------------------------------------------
519
+ # Per-user cache directory. Echoes the path on success; empty + exit 1
520
+ # on failure. Creates the dir mode 0700 if missing; refuses to use it
521
+ # (clears the stage) if the existing mode is wider or owner is wrong.
522
+ # -----------------------------------------------------------------------------
523
+ _shim_cache_dir() {
524
+ local euid=""
525
+ euid=$(id -u 2>/dev/null || true)
526
+ if [ -z "$euid" ]; then
527
+ return 1
528
+ fi
529
+ local tmp="${TMPDIR:-/tmp}"
530
+ # Strip trailing slash for predictable concatenation.
531
+ tmp="${tmp%/}"
532
+ local dir="$tmp/rea-shim-cache.$euid"
533
+ if [ -d "$dir" ]; then
534
+ # Owner + mode check.
535
+ local owner=""
536
+ local mode=""
537
+ if [ "$(uname -s 2>/dev/null || echo)" = "Darwin" ]; then
538
+ owner=$(stat -f "%u" "$dir" 2>/dev/null || true)
539
+ mode=$(stat -f "%Mp%Lp" "$dir" 2>/dev/null || true)
540
+ else
541
+ owner=$(stat -c "%u" "$dir" 2>/dev/null || true)
542
+ mode=$(stat -c "%a" "$dir" 2>/dev/null || true)
543
+ fi
544
+ if [ "$owner" != "$euid" ]; then
545
+ # Foreign-owned dir — refuse, do not clobber.
546
+ return 1
547
+ fi
548
+ # Mode comparison: GNU prints "700"; macOS %Mp%Lp prints "40700"
549
+ # (file-type + perms). We accept either by checking the last 3
550
+ # digits.
551
+ local last3="${mode: -3}"
552
+ if [ "$last3" != "700" ]; then
553
+ return 1
554
+ fi
555
+ else
556
+ # Create with 0700 via umask + mkdir.
557
+ local old_umask
558
+ old_umask=$(umask)
559
+ umask 077
560
+ mkdir -p "$dir" 2>/dev/null || { umask "$old_umask"; return 1; }
561
+ umask "$old_umask"
562
+ # Defensive chmod in case mkdir ignored the umask (some FS layers do).
563
+ chmod 0700 "$dir" 2>/dev/null || true
564
+ fi
565
+ printf '%s' "$dir"
566
+ }
567
+
568
+ # -----------------------------------------------------------------------------
569
+ # Read a cache entry. Args: key. Echoes the JSON content on hit
570
+ # (single line); exit 0 on hit, 1 on miss/error.
571
+ # -----------------------------------------------------------------------------
572
+ shim_cache_read() {
573
+ local key="$1"
574
+ if [ -z "$key" ]; then
575
+ return 1
576
+ fi
577
+ local dir=""
578
+ dir=$(_shim_cache_dir) || return 1
579
+ local file="$dir/$key.json"
580
+ if [ ! -f "$file" ]; then
581
+ return 1
582
+ fi
583
+ # Owner + mode check on the file.
584
+ local euid=""
585
+ euid=$(id -u 2>/dev/null || true)
586
+ local owner=""
587
+ local mode=""
588
+ if [ "$(uname -s 2>/dev/null || echo)" = "Darwin" ]; then
589
+ owner=$(stat -f "%u" "$file" 2>/dev/null || true)
590
+ mode=$(stat -f "%Mp%Lp" "$file" 2>/dev/null || true)
591
+ else
592
+ owner=$(stat -c "%u" "$file" 2>/dev/null || true)
593
+ mode=$(stat -c "%a" "$file" 2>/dev/null || true)
594
+ fi
595
+ if [ "$owner" != "$euid" ]; then
596
+ return 1
597
+ fi
598
+ local last3="${mode: -3}"
599
+ if [ "$last3" != "600" ]; then
600
+ return 1
601
+ fi
602
+ local content=""
603
+ content=$(cat "$file" 2>/dev/null || true)
604
+ if [ -z "$content" ]; then
605
+ return 1
606
+ fi
607
+ printf '%s' "$content"
608
+ return 0
609
+ }
610
+
611
+ # -----------------------------------------------------------------------------
612
+ # Write a cache entry atomically. Args: key, json_content. Returns 0 on
613
+ # success, 1 on any error. Callers IGNORE the return value — cache
614
+ # write failure NEVER blocks the gate.
615
+ # -----------------------------------------------------------------------------
616
+ shim_cache_write() {
617
+ local key="$1"
618
+ local content="$2"
619
+ if [ -z "$key" ] || [ -z "$content" ]; then
620
+ return 1
621
+ fi
622
+ local dir=""
623
+ dir=$(_shim_cache_dir) || return 1
624
+ local final="$dir/$key.json"
625
+ local tmp="$dir/$key.tmp.$$"
626
+ local old_umask
627
+ old_umask=$(umask)
628
+ umask 077
629
+ # Write to tmp, then atomic rename.
630
+ printf '%s\n' "$content" > "$tmp" 2>/dev/null || {
631
+ umask "$old_umask"
632
+ rm -f "$tmp" 2>/dev/null || true
633
+ return 1
634
+ }
635
+ chmod 0600 "$tmp" 2>/dev/null || true
636
+ mv -f "$tmp" "$final" 2>/dev/null || {
637
+ umask "$old_umask"
638
+ rm -f "$tmp" 2>/dev/null || true
639
+ return 1
640
+ }
641
+ umask "$old_umask"
642
+ return 0
643
+ }
644
+
645
+ # -----------------------------------------------------------------------------
646
+ # Export portable stat helper for shim-runtime callers.
647
+ # -----------------------------------------------------------------------------
648
+ shim_cache_mtime_size() {
649
+ _shim_cache_stat_mtime_size "$1"
650
+ }