@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 +177 -0
- package/THREAT_MODEL.md +140 -0
- package/dist/cli/audit-timeline.d.ts +20 -0
- package/dist/cli/audit-timeline.js +262 -20
- package/dist/cli/audit-top-blocks.d.ts +154 -0
- package/dist/cli/audit-top-blocks.js +419 -0
- package/dist/cli/index.js +5 -0
- package/dist/config/tier-map.js +32 -0
- package/dist/policy/loader.d.ts +13 -0
- package/dist/policy/loader.js +36 -0
- package/dist/policy/types.d.ts +52 -0
- package/hooks/_lib/shim-cache.sh +650 -0
- package/hooks/_lib/shim-runtime.sh +293 -3
- package/package.json +1 -1
- package/scripts/profile-hooks.mjs +10 -1
- package/templates/_lib_shim-cache.dogfood-staged.sh +650 -0
- package/templates/_lib_shim-runtime.dogfood-staged.sh +293 -3
|
@@ -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
|
+
}
|