@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.
@@ -0,0 +1,738 @@
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 indent depth so we do not mistake a deeper-indented
254
+ # sibling block enabled: false for ours. The block-form-end
255
+ # heuristic is now first non-empty line at or below the opener
256
+ # indent level.
257
+ #
258
+ # 0.48.1 R10 P3: pure-comment lines (matching ^[[:space:]]*#) MUST
259
+ # NOT close the block. Pre-fix a top-level comment like
260
+ # shim_cache:\n# note\n enabled: false closed the block on the
261
+ # comment line (non-empty, indent 0 <= opener_indent 0) and the
262
+ # subsequent enabled: false was treated as a top-level key with
263
+ # no parent block, so the disable was silently ignored.
264
+ #
265
+ # 0.48.1 multi-line flow-form: shim_cache: {\n enabled: false\n}
266
+ # is valid YAML the TS loader accepts. We add a flow-block state
267
+ # that opens on shim_cache: { (with the { unmatched on the same
268
+ # line), accumulates body until }, and matches enabled: false in
269
+ # the assembled buffer. The single-line flow-form rule above
270
+ # still wins for shim_cache: { enabled: false } on one line.
271
+ result=$(awk '
272
+ {
273
+ lc = tolower($0)
274
+ # Compute indentation depth of current line (number of
275
+ # leading whitespace characters before any non-ws).
276
+ indent_of_line = match(lc, /[^[:space:]]/) - 1
277
+ if (indent_of_line < 0) indent_of_line = 0
278
+ }
279
+ # Pure-comment line: skip without affecting state. Must come
280
+ # before BOTH the flow-block accumulator AND the block-end
281
+ # heuristic so a comment inside either context is transparent.
282
+ lc ~ /^[[:space:]]*#/ {
283
+ next
284
+ }
285
+ # 0.48.1 round-2 P2: removed the narrow single-line flow regex
286
+ # that matched shim_cache: { enabled: false } with [^}]* — it
287
+ # had no concept of quoted scalars or trailing comments and
288
+ # mis-fired on shim_cache: { note: "enabled: false", enabled:
289
+ # true }. The single-line case now flows through the brace-
290
+ # depth path below (which strips quotes + trailing comments
291
+ # per line); the only behavior change is that the inline form
292
+ # gets the same sanitization the multi-line form already does.
293
+ # Flow-form multi-line opener: shim_cache: { with the first {
294
+ # unmatched on the same line. Start accumulating until the
295
+ # matching close brace.
296
+ #
297
+ # 0.48.1 round-1 P2-B: track BRACE DEPTH across the buffer
298
+ # instead of closing on the first }. Valid YAML such as
299
+ # shim_cache: { meta: { foo: bar }, enabled: false } has
300
+ # nested {} pairs; a quoted scalar like note: "}" embeds a
301
+ # brace inside a string. We strip "..." and *...* (single-quote
302
+ # placeholder is \047 to keep this awk single-quoted body
303
+ # apostrophe-clean) before counting so quoted braces do not
304
+ # affect depth. Approximate but matches every shape the TS
305
+ # loader accepts; on a malformed policy the worst case is
306
+ # cache-stays-on (the safe default).
307
+ in_flow == 0 && lc ~ /^[[:space:]]*shim_cache:[[:space:]]*\{/ {
308
+ # Build a sanitized line: strip quoted scalars first so
309
+ # quoted braces / quoted comments / quoted enabled: false
310
+ # tokens cannot pollute brace-depth or value detection;
311
+ # then strip trailing #-comments so they cannot either.
312
+ # 0.48.1 round-2 P2 fix.
313
+ line_stripped = lc
314
+ gsub(/"[^"]*"/, "", line_stripped)
315
+ gsub(/\047[^\047]*\047/, "", line_stripped)
316
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
317
+ opens = gsub(/\{/, "{", line_stripped)
318
+ closes = gsub(/\}/, "}", line_stripped)
319
+ flow_depth = opens - closes
320
+ if (flow_depth <= 0) {
321
+ # Already balanced on this line — single-line flow form,
322
+ # potentially with nested braces (e.g. { meta: { foo: bar
323
+ # }, enabled: false }). The narrow single-line rule above
324
+ # cannot match nested-brace shapes (its [^}]* fails on the
325
+ # inner closing brace), so we check the SANITIZED line
326
+ # (quotes + comments stripped) here. Anchoring on a token
327
+ # boundary defends against accidental substring noise
328
+ # like enabled-false-something.
329
+ if (line_stripped ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
330
+ print "off"; exit
331
+ }
332
+ next
333
+ }
334
+ in_flow = 1
335
+ flow_buf = line_stripped
336
+ next
337
+ }
338
+ # Flow-form continuation: accumulate sanitized + maintain depth.
339
+ in_flow == 1 {
340
+ line_stripped = lc
341
+ gsub(/"[^"]*"/, "", line_stripped)
342
+ gsub(/\047[^\047]*\047/, "", line_stripped)
343
+ gsub(/[[:space:]]*#.*$/, "", line_stripped)
344
+ flow_buf = flow_buf " " line_stripped
345
+ opens = gsub(/\{/, "{", line_stripped)
346
+ closes = gsub(/\}/, "}", line_stripped)
347
+ flow_depth += opens - closes
348
+ if (flow_depth <= 0) {
349
+ in_flow = 0
350
+ if (flow_buf ~ /enabled[[:space:]]*:[[:space:]]*false([^[:alnum:]_]|$)/) {
351
+ print "off"; exit
352
+ }
353
+ }
354
+ next
355
+ }
356
+ # Block-form opener: leading whitespace allowed.
357
+ lc ~ /^[[:space:]]*shim_cache:[[:space:]]*(#.*)?$/ {
358
+ in_block = 1
359
+ opener_indent = indent_of_line
360
+ next
361
+ }
362
+ # End the block when we see a non-empty line at or below the
363
+ # opener indent (a sibling YAML key at the same level). Comment
364
+ # lines were already filtered above so they cannot close the block.
365
+ in_block && lc !~ /^[[:space:]]*$/ && indent_of_line <= opener_indent {
366
+ in_block = 0
367
+ }
368
+ in_block && lc ~ /^[[:space:]]+enabled:[[:space:]]*false([[:space:]]+(#.*)?)?[[:space:]]*$/ {
369
+ print "off"; exit
370
+ }
371
+ ' "$policy_path" 2>/dev/null || true)
372
+ if [ "$result" = "off" ]; then
373
+ return 0
374
+ fi
375
+ fi
376
+ return 1
377
+ }
378
+
379
+ # -----------------------------------------------------------------------------
380
+ # Session token derivation. See design §3.
381
+ #
382
+ # 1. Walk PPID ancestry up to N hops looking for an ancestor whose
383
+ # command name matches "claude" or "claude-code". Use that PID +
384
+ # start-time, sha256-hashed.
385
+ # 2. Fallback: tty_name + login_shell_pid + boot_id (or kernel
386
+ # uptime on macOS where /proc/sys/kernel/random/boot_id is
387
+ # absent).
388
+ # 3. Final fallback: empty + exit 1. NEVER use PPID alone.
389
+ #
390
+ # Echoes the token (32 hex chars) to stdout on success; empty + exit
391
+ # 1 means the caller should treat the cache as disabled for this
392
+ # invocation.
393
+ # -----------------------------------------------------------------------------
394
+ shim_cache_session_token() {
395
+ local pid="$$"
396
+ local ppid="${PPID:-0}"
397
+ local cur="$ppid"
398
+ local hops=0
399
+ local max_hops=20
400
+ local match_pid=""
401
+ local match_start=""
402
+
403
+ while [ "$cur" -gt 1 ] && [ "$hops" -lt "$max_hops" ]; do
404
+ local comm=""
405
+ # Linux: /proc/<pid>/comm has the basename (truncated to 15 chars).
406
+ if [ -r "/proc/$cur/comm" ]; then
407
+ comm=$(cat "/proc/$cur/comm" 2>/dev/null || true)
408
+ else
409
+ # macOS / BSD: ps -o comm=
410
+ comm=$(ps -o comm= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
411
+ # ps may give "/usr/local/bin/claude" — basename it.
412
+ comm=$(basename "$comm" 2>/dev/null || true)
413
+ fi
414
+ case "$comm" in
415
+ claude|claude-code)
416
+ # 0.48.0 codex round-1 P2: claude and claude-code are the only
417
+ # ancestors we accept as authoritative session anchors. We
418
+ # previously also matched `cli` / `node` here without setting
419
+ # match_pid — confusing dead branch removed. Non-TTY launches
420
+ # under `cli` or `node` parents reach the tty/login-shell/boot-id
421
+ # fallback below; if THAT also fails (truly stripped container)
422
+ # the final `cache disabled` path fires and the gate runs
423
+ # uncached. The narrower match list keeps the session-token
424
+ # contract strict — we don't want to accept ANY `node`
425
+ # ancestor since that scope leaks across unrelated processes.
426
+ match_pid="$cur"
427
+ break
428
+ ;;
429
+ esac
430
+ # Walk up.
431
+ local next=""
432
+ if [ -r "/proc/$cur/status" ]; then
433
+ next=$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)
434
+ else
435
+ next=$(ps -o ppid= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
436
+ fi
437
+ if [ -z "$next" ] || [ "$next" = "$cur" ] || [ "$next" -le 1 ] 2>/dev/null; then
438
+ break
439
+ fi
440
+ cur="$next"
441
+ hops=$((hops + 1))
442
+ done
443
+
444
+ if [ -n "$match_pid" ]; then
445
+ # Get process start time. Linux: /proc/<pid>/stat field 22 (jiffies
446
+ # since boot). macOS: ps -o lstart= -p <pid>.
447
+ if [ -r "/proc/$match_pid/stat" ]; then
448
+ match_start=$(awk '{print $22}' "/proc/$match_pid/stat" 2>/dev/null || true)
449
+ fi
450
+ if [ -z "$match_start" ]; then
451
+ match_start=$(ps -o lstart= -p "$match_pid" 2>/dev/null || true)
452
+ fi
453
+ if [ -n "$match_start" ]; then
454
+ _shim_cache_sha256_hex "claude-ancestor" "$match_pid" "$match_start"
455
+ return $?
456
+ fi
457
+ fi
458
+
459
+ # Fallback: tty + login-shell-pid + boot identifier.
460
+ local tty_name=""
461
+ tty_name=$(tty 2>/dev/null || true)
462
+ local login_pid=""
463
+ # Walk PPID chain looking for a shell (basename match against typical
464
+ # login shells). This is best-effort.
465
+ cur="$ppid"
466
+ hops=0
467
+ while [ "$cur" -gt 1 ] && [ "$hops" -lt "$max_hops" ]; do
468
+ local comm=""
469
+ if [ -r "/proc/$cur/comm" ]; then
470
+ comm=$(cat "/proc/$cur/comm" 2>/dev/null || true)
471
+ else
472
+ comm=$(ps -o comm= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
473
+ comm=$(basename "$comm" 2>/dev/null || true)
474
+ fi
475
+ case "$comm" in
476
+ bash|zsh|fish|sh|dash|ksh|tcsh|csh|-bash|-zsh)
477
+ login_pid="$cur"
478
+ break
479
+ ;;
480
+ esac
481
+ local next=""
482
+ if [ -r "/proc/$cur/status" ]; then
483
+ next=$(awk '/^PPid:/ {print $2; exit}' "/proc/$cur/status" 2>/dev/null || true)
484
+ else
485
+ next=$(ps -o ppid= -p "$cur" 2>/dev/null | awk '{print $1}' || true)
486
+ fi
487
+ if [ -z "$next" ] || [ "$next" = "$cur" ] || [ "$next" -le 1 ] 2>/dev/null; then
488
+ break
489
+ fi
490
+ cur="$next"
491
+ hops=$((hops + 1))
492
+ done
493
+
494
+ local boot_id=""
495
+ if [ -r "/proc/sys/kernel/random/boot_id" ]; then
496
+ boot_id=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null || true)
497
+ else
498
+ # macOS / BSD: kern.boottime (seconds-since-epoch of the last boot).
499
+ boot_id=$(sysctl -n kern.boottime 2>/dev/null | sed -E 's/[^0-9]//g' || true)
500
+ fi
501
+
502
+ if [ -n "$tty_name" ] && [ -n "$login_pid" ] && [ -n "$boot_id" ]; then
503
+ _shim_cache_sha256_hex "tty-fallback" "$tty_name" "$login_pid" "$boot_id"
504
+ return $?
505
+ fi
506
+
507
+ # 0.48.0 codex round-6 P1: intermediate fallback for non-interactive
508
+ # subprocess launches (CI, vitest harness, editor-spawned subprocess)
509
+ # where:
510
+ # - no claude/claude-code ancestor exists (running under a different
511
+ # harness or none at all)
512
+ # - no tty (stdin is piped)
513
+ # without this, the tty fallback above fails and the function returns
514
+ # 1 → cache disabled → cumulative latency the cache exists to fix.
515
+ #
516
+ # The token is composed of PPID's basename + PPID's start-time + the
517
+ # boot identifier. This is NOT "use PPID alone" — the start-time
518
+ # disambiguates PID reuse across reboots, and the boot-id confines
519
+ # it to the current boot. A different parent process → different
520
+ # token; a reboot → different token. The session is scoped to
521
+ # "this specific parent invocation, on this boot". Slightly broader
522
+ # than the tty-fallback (which scopes to "this tty session") but
523
+ # narrower than "no cache at all". Requires lstart and a boot
524
+ # identifier; if either is missing we fall through to the final
525
+ # disabled state per design memo concern #2.
526
+ local ppid_comm=""
527
+ if [ -r "/proc/$ppid/comm" ]; then
528
+ ppid_comm=$(cat "/proc/$ppid/comm" 2>/dev/null || true)
529
+ else
530
+ ppid_comm=$(ps -o comm= -p "$ppid" 2>/dev/null | awk '{print $1}' || true)
531
+ ppid_comm=$(basename "$ppid_comm" 2>/dev/null || true)
532
+ fi
533
+ local ppid_start=""
534
+ if [ -r "/proc/$ppid/stat" ]; then
535
+ ppid_start=$(awk '{print $22}' "/proc/$ppid/stat" 2>/dev/null || true)
536
+ fi
537
+ if [ -z "$ppid_start" ]; then
538
+ ppid_start=$(ps -o lstart= -p "$ppid" 2>/dev/null || true)
539
+ fi
540
+ if [ -n "$ppid_comm" ] && [ -n "$ppid_start" ] && [ -n "$boot_id" ]; then
541
+ _shim_cache_sha256_hex "ppid-fallback" "$ppid_comm" "$ppid_start" "$boot_id"
542
+ return $?
543
+ fi
544
+
545
+ # 0.48.0 codex round-8 P1: sandbox-safe fallback for environments
546
+ # where /proc is absent AND ps + sysctl are denied (sandboxed
547
+ # macOS runs, locked-down CI). In those environments the boot_id
548
+ # path above fails and the function would previously return 1
549
+ # → cache permanently disabled.
550
+ #
551
+ # This fallback derives the token from `(euid + REA_ROOT path)`
552
+ # — which is broader than a process-scoped session but still
553
+ # scoped to "this user's REA install". Cache poisoning is
554
+ # prevented by the per-user 0700 directory + 0600 entry file
555
+ # (another local user cannot plant entries in our cache dir).
556
+ # Cross-install reuse is prevented by REA_ROOT being part of the
557
+ # token AND by every cache key field below this layer encoding
558
+ # project + CLI mtime + dist hash — a rebuild invalidates.
559
+ #
560
+ # This satisfies concern #2's intent: it never uses PPID alone.
561
+ # The trade-off is a coarser "session" scope (broader than a
562
+ # single Claude Code session) in exchange for the cache actually
563
+ # functioning in sandboxed environments.
564
+ local euid=""
565
+ euid=$(id -u 2>/dev/null || true)
566
+ if [ -n "$euid" ] && [ -n "${REA_ROOT:-}" ]; then
567
+ _shim_cache_sha256_hex "user-project-fallback" "$euid" "$REA_ROOT"
568
+ return $?
569
+ fi
570
+
571
+ # Final fallback per design §3 concern #2: cache disabled.
572
+ # Truly hostile environment with no euid AND no REA_ROOT —
573
+ # NEVER weaken the contract by accepting PPID alone.
574
+ return 1
575
+ }
576
+
577
+ # -----------------------------------------------------------------------------
578
+ # Cache key derivation.
579
+ #
580
+ # shim_cache_key SCHEMA SESSION_TOKEN PROJECT_REALPATH CLI_REALPATH \
581
+ # CLI_MTIME CLI_SIZE EUID ENFORCE_SHAPE SHIM_NAME \
582
+ # PKG_MTIME PKG_SIZE DIST_DIR_MTIME \
583
+ # NODE_REALPATH NODE_MTIME
584
+ #
585
+ # Echoes the 32-char hex key on success; empty + exit 1 on hash
586
+ # failure. 0.48.0 evolution:
587
+ # - codex round-1 P1: SHIM_NAME added so the cache is hook-scoped
588
+ # (the skipped probe `rea hook \$SHIM_NAME --help` is hook-specific)
589
+ # - codex round-3 P1+P2: PKG_MTIME / PKG_SIZE / DIST_DIR_MTIME added
590
+ # so an edit to the ancestor package.json (closes P2) or a rebuild
591
+ # that touches the dist/cli/ directory (closes P1's same-session
592
+ # rebuild gap) invalidates the entry.
593
+ # - codex round-4 P1: NODE_REALPATH / NODE_MTIME added so a
594
+ # same-session `nvm use` / `volta pin` / PATH-prepended wrapper
595
+ # invalidates the entry (the warm hit would otherwise skip both
596
+ # node-availability AND the version probe, forwarding through a
597
+ # different interpreter).
598
+ # -----------------------------------------------------------------------------
599
+ shim_cache_key() {
600
+ if [ "$#" -lt 14 ]; then
601
+ return 1
602
+ fi
603
+ _shim_cache_sha256_hex "$@"
604
+ }
605
+
606
+ # -----------------------------------------------------------------------------
607
+ # Per-user cache directory. Echoes the path on success; empty + exit 1
608
+ # on failure. Creates the dir mode 0700 if missing; refuses to use it
609
+ # (clears the stage) if the existing mode is wider or owner is wrong.
610
+ # -----------------------------------------------------------------------------
611
+ _shim_cache_dir() {
612
+ local euid=""
613
+ euid=$(id -u 2>/dev/null || true)
614
+ if [ -z "$euid" ]; then
615
+ return 1
616
+ fi
617
+ local tmp="${TMPDIR:-/tmp}"
618
+ # Strip trailing slash for predictable concatenation.
619
+ tmp="${tmp%/}"
620
+ local dir="$tmp/rea-shim-cache.$euid"
621
+ if [ -d "$dir" ]; then
622
+ # Owner + mode check.
623
+ local owner=""
624
+ local mode=""
625
+ if [ "$(uname -s 2>/dev/null || echo)" = "Darwin" ]; then
626
+ owner=$(stat -f "%u" "$dir" 2>/dev/null || true)
627
+ mode=$(stat -f "%Mp%Lp" "$dir" 2>/dev/null || true)
628
+ else
629
+ owner=$(stat -c "%u" "$dir" 2>/dev/null || true)
630
+ mode=$(stat -c "%a" "$dir" 2>/dev/null || true)
631
+ fi
632
+ if [ "$owner" != "$euid" ]; then
633
+ # Foreign-owned dir — refuse, do not clobber.
634
+ return 1
635
+ fi
636
+ # Mode comparison: GNU prints "700"; macOS %Mp%Lp prints "40700"
637
+ # (file-type + perms). We accept either by checking the last 3
638
+ # digits.
639
+ local last3="${mode: -3}"
640
+ if [ "$last3" != "700" ]; then
641
+ return 1
642
+ fi
643
+ else
644
+ # Create with 0700 via umask + mkdir.
645
+ local old_umask
646
+ old_umask=$(umask)
647
+ umask 077
648
+ mkdir -p "$dir" 2>/dev/null || { umask "$old_umask"; return 1; }
649
+ umask "$old_umask"
650
+ # Defensive chmod in case mkdir ignored the umask (some FS layers do).
651
+ chmod 0700 "$dir" 2>/dev/null || true
652
+ fi
653
+ printf '%s' "$dir"
654
+ }
655
+
656
+ # -----------------------------------------------------------------------------
657
+ # Read a cache entry. Args: key. Echoes the JSON content on hit
658
+ # (single line); exit 0 on hit, 1 on miss/error.
659
+ # -----------------------------------------------------------------------------
660
+ shim_cache_read() {
661
+ local key="$1"
662
+ if [ -z "$key" ]; then
663
+ return 1
664
+ fi
665
+ local dir=""
666
+ dir=$(_shim_cache_dir) || return 1
667
+ local file="$dir/$key.json"
668
+ if [ ! -f "$file" ]; then
669
+ return 1
670
+ fi
671
+ # Owner + mode check on the file.
672
+ local euid=""
673
+ euid=$(id -u 2>/dev/null || true)
674
+ local owner=""
675
+ local mode=""
676
+ if [ "$(uname -s 2>/dev/null || echo)" = "Darwin" ]; then
677
+ owner=$(stat -f "%u" "$file" 2>/dev/null || true)
678
+ mode=$(stat -f "%Mp%Lp" "$file" 2>/dev/null || true)
679
+ else
680
+ owner=$(stat -c "%u" "$file" 2>/dev/null || true)
681
+ mode=$(stat -c "%a" "$file" 2>/dev/null || true)
682
+ fi
683
+ if [ "$owner" != "$euid" ]; then
684
+ return 1
685
+ fi
686
+ local last3="${mode: -3}"
687
+ if [ "$last3" != "600" ]; then
688
+ return 1
689
+ fi
690
+ local content=""
691
+ content=$(cat "$file" 2>/dev/null || true)
692
+ if [ -z "$content" ]; then
693
+ return 1
694
+ fi
695
+ printf '%s' "$content"
696
+ return 0
697
+ }
698
+
699
+ # -----------------------------------------------------------------------------
700
+ # Write a cache entry atomically. Args: key, json_content. Returns 0 on
701
+ # success, 1 on any error. Callers IGNORE the return value — cache
702
+ # write failure NEVER blocks the gate.
703
+ # -----------------------------------------------------------------------------
704
+ shim_cache_write() {
705
+ local key="$1"
706
+ local content="$2"
707
+ if [ -z "$key" ] || [ -z "$content" ]; then
708
+ return 1
709
+ fi
710
+ local dir=""
711
+ dir=$(_shim_cache_dir) || return 1
712
+ local final="$dir/$key.json"
713
+ local tmp="$dir/$key.tmp.$$"
714
+ local old_umask
715
+ old_umask=$(umask)
716
+ umask 077
717
+ # Write to tmp, then atomic rename.
718
+ printf '%s\n' "$content" > "$tmp" 2>/dev/null || {
719
+ umask "$old_umask"
720
+ rm -f "$tmp" 2>/dev/null || true
721
+ return 1
722
+ }
723
+ chmod 0600 "$tmp" 2>/dev/null || true
724
+ mv -f "$tmp" "$final" 2>/dev/null || {
725
+ umask "$old_umask"
726
+ rm -f "$tmp" 2>/dev/null || true
727
+ return 1
728
+ }
729
+ umask "$old_umask"
730
+ return 0
731
+ }
732
+
733
+ # -----------------------------------------------------------------------------
734
+ # Export portable stat helper for shim-runtime callers.
735
+ # -----------------------------------------------------------------------------
736
+ shim_cache_mtime_size() {
737
+ _shim_cache_stat_mtime_size "$1"
738
+ }