@bookedsolid/rea 0.48.1 → 0.49.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,1075 @@
1
+ # shellcheck shell=bash
2
+ # hooks/_lib/bootstrap-allowlist.sh — bootstrap allowlist for the
3
+ # Bash-tier protected-paths / blocked-paths gate shims.
4
+ # Introduced 0.49.0.
5
+ #
6
+ # # Problem this solves
7
+ #
8
+ # `rea init` writes `.claude/hooks/blocked-paths-bash-gate.sh` and
9
+ # `.claude/hooks/protected-paths-bash-gate.sh` that depend on the
10
+ # `@bookedsolid/rea` CLI being resolvable from `node_modules/`. Until
11
+ # 0.49.0, `rea init` did NOT add the dep to the consumer's
12
+ # `package.json` — so any fresh clone + `pnpm install` produced a
13
+ # brick state where the shims refused 100% of Bash calls (including
14
+ # the `pnpm add -D @bookedsolid/rea` that would recover).
15
+ #
16
+ # This helper provides a NARROW allowlist: when the CLI is missing,
17
+ # AND `package.json` declares `@bookedsolid/rea` (dependencies or
18
+ # devDependencies), AND the Bash payload is a single recognised PM
19
+ # install / add invocation, pass through. Everything else still
20
+ # refuses.
21
+ #
22
+ # # Security stance
23
+ #
24
+ # - Allowlist runs ONLY when the CLI is unreachable (CLI-missing
25
+ # branch). The CLI-present path is unaffected.
26
+ # - Allowlist is ALWAYS-ON by default (no env-var toggle ever
27
+ # participates in the decision). Operators can disable via policy
28
+ # (`policy.bootstrap_allowlist.enabled: false`).
29
+ # - Precondition: `<project>/package.json` parseable as JSON object and
30
+ # declares `@bookedsolid/rea` under `dependencies` OR
31
+ # `devDependencies` (exact-key match, string value). NOT
32
+ # `optionalDependencies`, NOT `peerDependencies`, NOT
33
+ # `pnpm.overrides`.
34
+ # - Multi-segment payloads refuse INSIDE this helper. The caller
35
+ # passes the RAW extracted Bash command; the helper sources
36
+ # `cmd-segments.sh`, runs `_rea_split_segments` on the trimmed
37
+ # payload, and short-circuits to `refuse` when the result has more
38
+ # than one segment. This keeps the segmentation contract centralised
39
+ # in the allowlist (the gate shims do not need to re-split, and any
40
+ # future caller that forgets to pre-split is still safe).
41
+ # - Quoted argv forms (`pnpm "install"`, `'pnpm' install`) refuse-
42
+ # fallthrough — quoted tokens are a defense feature, not a bug.
43
+ # - argv[0] basename match is exact-string, no slashes. Path-form
44
+ # commands (`./pnpm install`, `/usr/local/bin/pnpm install`) refuse.
45
+ # - Audit event `rea.bash.bootstrap_allow` is emitted on every match
46
+ # so operators can post-hoc verify what the allowlist let through.
47
+ #
48
+ # # Return convention
49
+ #
50
+ # `bootstrap_allowlist_check <command>`:
51
+ # - exits 0 on stdout `"allow"` — gate should pass the payload
52
+ # - exits 0 on stdout `"refuse"` — gate should follow its existing
53
+ # CLI-missing refusal path (banner +
54
+ # exit 2 for blocking, exit 0 for
55
+ # advisory)
56
+ #
57
+ # We deliberately use stdout-with-uniform-exit-0 rather than exit
58
+ # codes 0/1/2 so caller code can distinguish allowlist outcomes from
59
+ # any subshell process-control errors that bash would also surface as
60
+ # non-zero exits.
61
+ #
62
+ # # Bash 3.2 compatibility
63
+ #
64
+ # This helper targets macOS bash 3.2. Avoid: `mapfile`, `${var,,}`,
65
+ # `[[ =~ ]]`. OK: `case`, `read -ra`, `[[ ]]` for string compare
66
+ # without regex.
67
+
68
+ set -uo pipefail
69
+
70
+ # Source the segment splitter so the caller can reuse it on the same
71
+ # input. The caller MUST have already verified single-segment shape
72
+ # before calling us; we don't re-split here, but we DO defend against
73
+ # accidental misuse below.
74
+ _BOOTSTRAP_ALLOWLIST_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
75
+ # shellcheck source=cmd-segments.sh
76
+ . "$_BOOTSTRAP_ALLOWLIST_DIR/cmd-segments.sh"
77
+
78
+ # Hash a string with whichever SHA-256 tool exists. Echoes 64 hex
79
+ # chars to stdout on success, empty on failure.
80
+ _bootstrap_sha256() {
81
+ local input="$1"
82
+ local hash=""
83
+ if command -v shasum >/dev/null 2>&1; then
84
+ hash=$(printf '%s' "$input" | shasum -a 256 2>/dev/null | awk '{print $1}')
85
+ elif command -v sha256sum >/dev/null 2>&1; then
86
+ hash=$(printf '%s' "$input" | sha256sum 2>/dev/null | awk '{print $1}')
87
+ fi
88
+ printf '%s' "$hash"
89
+ }
90
+
91
+ # Hash a FILE with whichever SHA-256 tool exists. Echoes 64 hex chars.
92
+ _bootstrap_sha256_file() {
93
+ local file="$1"
94
+ local hash=""
95
+ if [ ! -f "$file" ]; then
96
+ printf ''
97
+ return 0
98
+ fi
99
+ if command -v shasum >/dev/null 2>&1; then
100
+ hash=$(shasum -a 256 "$file" 2>/dev/null | awk '{print $1}')
101
+ elif command -v sha256sum >/dev/null 2>&1; then
102
+ hash=$(sha256sum "$file" 2>/dev/null | awk '{print $1}')
103
+ fi
104
+ printf '%s' "$hash"
105
+ }
106
+
107
+ # Read whether the allowlist is policy-disabled.
108
+ # Returns 0 (enabled, default) or 1 (disabled).
109
+ #
110
+ # Conflict-resolution: the architect locked "drop the grep tier" —
111
+ # jq → node only. Node is guaranteed available via `engines.node:
112
+ # ">=22"`. If neither tool parses the policy, refuse to ALLOW (default
113
+ # = enabled, but a malformed policy reads as "unknown" and we keep the
114
+ # allowlist on, otherwise an attacker who corrupts the policy could
115
+ # strip the bootstrap recovery path).
116
+ _bootstrap_allowlist_policy_enabled() {
117
+ local policy_file="${1:-}"
118
+ if [ -z "$policy_file" ] || [ ! -f "$policy_file" ]; then
119
+ # No policy file → schema default = enabled.
120
+ return 0
121
+ fi
122
+ local val=""
123
+ # Tier 1: jq. Best signal — handles YAML poorly but operators
124
+ # whose policy.yaml is JSON-shaped get a clean read. We do not
125
+ # actually expect this to fire since policy.yaml is YAML; this is
126
+ # defense-in-depth.
127
+ if command -v jq >/dev/null 2>&1; then
128
+ val=$(jq -r 'try .bootstrap_allowlist.enabled // empty' "$policy_file" 2>/dev/null || true)
129
+ case "$val" in
130
+ true|false) ;;
131
+ *) val="" ;;
132
+ esac
133
+ fi
134
+ # Tier 2: node with a tightened inline parser.
135
+ #
136
+ # R7-P2 (codex round 7): the parser must mirror the TS reader's
137
+ # YAML semantics. The TS reader uses `yaml.parse()` then validates
138
+ # with zod `z.boolean()`, so the ONLY accepted bool tokens are
139
+ # `true`/`True`/`TRUE` and `false`/`False`/`FALSE` (the `yaml` v2
140
+ # package recognises these as booleans; everything else —
141
+ # `no`, `off`, `"false"`, etc. — parses as a STRING and then
142
+ # fails zod validation with a clear error at CLI load time).
143
+ # Pre-fix this parser only recognised lowercase `true|false`, so
144
+ # a policy with `enabled: False` parsed as "enabled" in bash but
145
+ # as "disabled" in TS — silent drift.
146
+ #
147
+ # NOTE: we cannot `require("yaml")` here because the whole reason
148
+ # this code path runs is the CLI is missing — `node_modules/yaml`
149
+ # may not yet exist. We rely on stdlib only.
150
+ #
151
+ # Recognised block forms (top-level OR indented):
152
+ # bootstrap_allowlist:
153
+ # enabled: <true|True|TRUE|false|False|FALSE>
154
+ # Recognised flow form:
155
+ # bootstrap_allowlist: { enabled: <bool> }
156
+ #
157
+ # Anything we cannot confidently parse keeps the schema default
158
+ # (enabled = true) — a malformed policy MUST NOT silently strip
159
+ # the bootstrap recovery path.
160
+ if [ -z "$val" ] && command -v node >/dev/null 2>&1; then
161
+ val=$(node -e '
162
+ const fs = require("fs");
163
+ // Normalize a YAML scalar bool token to canonical `true`/`false`.
164
+ // Returns null if the token is not a recognised YAML boolean
165
+ // (mirrors the strict `z.boolean()` validation in the TS reader).
166
+ function normBool(s) {
167
+ if (typeof s !== "string") return null;
168
+ const t = s.trim();
169
+ if (t === "true" || t === "True" || t === "TRUE") return "true";
170
+ if (t === "false" || t === "False" || t === "FALSE") return "false";
171
+ return null;
172
+ }
173
+ try {
174
+ const raw = fs.readFileSync(process.argv[1], "utf8");
175
+ const lines = raw.split(/\r?\n/);
176
+ // Find the `bootstrap_allowlist:` key. The TS reader honors
177
+ // top-level placement; we also accept (defensively) the same
178
+ // key at a single indentation level deeper, since the `yaml`
179
+ // package would accept it inside the document root regardless
180
+ // of leading whitespace.
181
+ let i = -1;
182
+ let inlineFlow = "";
183
+ let baseIndent = 0;
184
+ for (let k = 0; k < lines.length; k++) {
185
+ const m = /^(\s*)bootstrap_allowlist:\s*(.*)$/.exec(lines[k]);
186
+ if (m) {
187
+ i = k;
188
+ baseIndent = (m[1] || "").length;
189
+ inlineFlow = (m[2] || "").trim();
190
+ break;
191
+ }
192
+ }
193
+ if (i === -1) { process.exit(0); }
194
+ // Flow form on the same line: `bootstrap_allowlist: { enabled: <bool> }`
195
+ if (inlineFlow.length > 0) {
196
+ const flw = inlineFlow.match(/^\{\s*enabled:\s*([A-Za-z]+)\s*\}\s*$/);
197
+ if (flw) {
198
+ const b = normBool(flw[1]);
199
+ if (b !== null) { process.stdout.write(b); process.exit(0); }
200
+ }
201
+ process.exit(0);
202
+ }
203
+ // Block form: walk subsequent lines until indentation drops
204
+ // back to baseIndent or below (which terminates the block).
205
+ for (let k = i + 1; k < lines.length; k++) {
206
+ const line = lines[k];
207
+ // Blank or comment lines do not terminate the block.
208
+ if (/^\s*(#|$)/.test(line)) continue;
209
+ // Indentation must be strictly deeper than the parent key.
210
+ const indMatch = /^(\s*)\S/.exec(line);
211
+ const ind = indMatch ? indMatch[1].length : 0;
212
+ if (ind <= baseIndent) break;
213
+ // Match `enabled: <token>` — accept lowercase, capitalized,
214
+ // and uppercase boolean tokens via normBool. Reject quoted
215
+ // forms (the regex requires a bare identifier).
216
+ const m = /^\s+enabled:\s*([A-Za-z]+)\b/.exec(line);
217
+ if (m) {
218
+ const b = normBool(m[1]);
219
+ if (b !== null) { process.stdout.write(b); process.exit(0); }
220
+ }
221
+ }
222
+ } catch (e) { process.exit(0); }
223
+ ' -- "$policy_file" 2>/dev/null || true)
224
+ case "$val" in
225
+ true|false) ;;
226
+ *) val="" ;;
227
+ esac
228
+ fi
229
+ if [ "$val" = "false" ]; then
230
+ return 1
231
+ fi
232
+ # Default = enabled (true OR unparseable). A malformed policy MUST
233
+ # NOT strip the bootstrap recovery path — see security comment above.
234
+ return 0
235
+ }
236
+
237
+ # Verify package.json precondition.
238
+ # Args: $1 = path to package.json
239
+ # Echoes "<declared-range>" on stdout when matched (truncated to 16
240
+ # chars for the audit field). Empty on stdout when not matched.
241
+ # Returns 0 always — caller checks stdout.
242
+ _bootstrap_check_package_json() {
243
+ local pj="$1"
244
+ # R20-P2 (codex round 20): refuse when `package.json` is itself a
245
+ # symbolic link. Pre-fix `[ -f ]` follows symlinks, so a CLI-
246
+ # missing checkout whose `package.json` points outside the project
247
+ # tree would still trust the symlink target's declaration of
248
+ # `@bookedsolid/rea`. The package manager would then mutate that
249
+ # OUT-OF-TREE target on the next `pnpm add` / `npm install`,
250
+ # silently rewriting a file the operator did not intend the gate
251
+ # to cover. Mirrors the R10-P2 symlink refusal added to
252
+ # `selfPinRea` / `checkUpgradeBlockingPin` / `checkSelfPinDeclaredSync`
253
+ # — every surface that READS package.json on the bootstrap path
254
+ # must apply the same lstat-based guard. POSIX `[ -L ]` tests
255
+ # whether the path itself is a symlink without dereferencing it
256
+ # (bash 3.2 safe; supported on macOS / Linux / Alpine / Busybox).
257
+ if [ -L "$pj" ]; then
258
+ printf 'rea bootstrap allowlist refusing: %s is a symlink.\n' "$pj" >&2
259
+ printf 'Trusting it would let the package manager mutate a target outside the project tree.\n' >&2
260
+ return 0
261
+ fi
262
+ if [ ! -f "$pj" ]; then
263
+ return 0
264
+ fi
265
+ # Tier 1: jq.
266
+ #
267
+ # P2-2 (codex round 1): the previous expression used `//` to fall
268
+ # through from `dependencies` to `devDependencies`, but jq's `//`
269
+ # treats BOTH `null` AND `false` as "default triggers". A hostile
270
+ # `package.json` with `{ "dependencies": { "@bookedsolid/rea": false } }`
271
+ # would fall through to `devDependencies` lookup — inconsistent with
272
+ # the node tier (which type-guards `typeof x === "string"`) and a
273
+ # latent forge surface if npm ever relaxes its current rejection of
274
+ # non-string version values. `select(type=="string")` makes both
275
+ # tiers type-equivalent: only string values qualify, every other
276
+ # JSON shape (false / null / number / array / object) refuses.
277
+ local val=""
278
+ if command -v jq >/dev/null 2>&1; then
279
+ val=$(jq -r '
280
+ (.dependencies["@bookedsolid/rea"] // .devDependencies["@bookedsolid/rea"])
281
+ | select(type == "string")
282
+ // empty
283
+ ' "$pj" 2>/dev/null || true)
284
+ fi
285
+ # Tier 2: node (guaranteed via engines.node).
286
+ #
287
+ # P2-1 (codex round 1): strip a leading UTF-8 BOM (EF BB BF) before
288
+ # JSON.parse. Some Windows-authored package.json manifests start with
289
+ # a BOM; JSON.parse rejects it (the spec is unambiguous). Pre-fix, a
290
+ # BOM-prefixed manifest declaring @bookedsolid/rea would be treated
291
+ # as missing here, refusing the install command that the dogfood
292
+ # `selfPinRea` write path tolerates fine — asymmetric handling.
293
+ # Mirrors the strip applied in src/cli/install/self-pin.ts (P2-3).
294
+ if [ -z "$val" ] && command -v node >/dev/null 2>&1; then
295
+ val=$(node -e '
296
+ try {
297
+ const fs = require("fs");
298
+ let raw = fs.readFileSync(process.argv[1], "utf8");
299
+ if (raw.charCodeAt(0) === 0xFEFF) { raw = raw.slice(1); }
300
+ const pkg = JSON.parse(raw);
301
+ if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) process.exit(0);
302
+ const lookIn = function (key) {
303
+ const v = pkg[key];
304
+ if (v && typeof v === "object" && !Array.isArray(v)) {
305
+ const x = v["@bookedsolid/rea"];
306
+ if (typeof x === "string") return x;
307
+ }
308
+ return null;
309
+ };
310
+ const deps = lookIn("dependencies");
311
+ if (deps !== null) { process.stdout.write(deps); process.exit(0); }
312
+ const dev = lookIn("devDependencies");
313
+ if (dev !== null) { process.stdout.write(dev); process.exit(0); }
314
+ } catch (e) { /* fall through to empty */ }
315
+ ' -- "$pj" 2>/dev/null || true)
316
+ fi
317
+ # Defense-in-depth: refuse semver values longer than 256 chars
318
+ # (cap the audit field size and prevent a packed/forged JSON
319
+ # blob from blowing up the audit line).
320
+ if [ "${#val}" -gt 256 ]; then
321
+ val=""
322
+ fi
323
+ # Truncate to 16 chars for audit shape.
324
+ if [ -n "$val" ]; then
325
+ printf '%s' "${val:0:16}"
326
+ fi
327
+ }
328
+
329
+
330
+ # Emit audit event `rea.bash.bootstrap_allow`. Two-tier:
331
+ # Tier 1: `rea hook audit-emit` when CLI is reachable (rare in our
332
+ # caller — the whole point is CLI-missing — but `dist/cli/`
333
+ # could exist without `node_modules/@bookedsolid/rea/`).
334
+ # Tier 2: hand-write the JSONL with PINNED key order so the canonical
335
+ # TS audit reader accepts it.
336
+ #
337
+ # Args:
338
+ # $1 = shim name (e.g. "blocked-paths-bash-gate")
339
+ # $2 = pm token (e.g. "pnpm")
340
+ # $3 = argv shape (e.g. "install")
341
+ # $4 = argv-segments sha256
342
+ # $5 = package.json sha256
343
+ # $6 = package.json declares-rea ("true" or "false")
344
+ # $7 = declared version range (truncated to 16 chars)
345
+ # $8 = policy enabled ("true" or "false")
346
+ # $9 = CLAUDE_PROJECT_DIR
347
+ _bootstrap_emit_audit() {
348
+ local shim="$1"
349
+ local pm="$2"
350
+ local argv_shape="$3"
351
+ local argv_sha="$4"
352
+ local pj_sha="$5"
353
+ local declares_rea="$6"
354
+ local declared_range="$7"
355
+ local policy_enabled="$8"
356
+ local proj="$9"
357
+ local audit_file="$proj/.rea/audit.jsonl"
358
+ local timestamp
359
+ timestamp=$(node -e 'process.stdout.write(new Date().toISOString())' 2>/dev/null || true)
360
+ if [ -z "$timestamp" ]; then
361
+ timestamp=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z" 2>/dev/null || true)
362
+ fi
363
+ if [ -z "$timestamp" ]; then
364
+ # Truly stripped container — emit a sentinel so the record is
365
+ # still well-formed.
366
+ timestamp="1970-01-01T00:00:00.000Z"
367
+ fi
368
+
369
+ # P2-2 (codex round 1): cross-process mutex around the
370
+ # read-tail → compute-hash → append sequence. Two concurrently-allowed
371
+ # bootstrap commands (e.g. `pnpm install` started twice from racing
372
+ # editor instances, or `corepack prepare` + `pnpm install` in the
373
+ # same hook fire) would otherwise read the same `prev_hash` tail
374
+ # value and append two records with the same chain pointer —
375
+ # forking the audit chain and breaking `rea audit verify`.
376
+ #
377
+ # mkdir is the canonical portable mutex on POSIX: creation is
378
+ # atomic w.r.t. concurrent processes, fails fast if the dir exists,
379
+ # and works on macOS/Linux/Alpine/Busybox without external tools.
380
+ # The TS canonical writer (src/audit/append.ts) uses
381
+ # `proper-lockfile` on `.rea/`; we cannot reach for that here
382
+ # because the whole reason this helper runs is the CLI being
383
+ # unbuilt — proper-lockfile lives in node_modules. The two
384
+ # mechanisms are conservative w.r.t. each other: a TS writer
385
+ # holding the proper-lockfile lock does not block our mkdir, but
386
+ # the practical concern is OUR concurrent self-races (two bash
387
+ # helper invocations against the same .rea/), and mkdir serialises
388
+ # those cleanly. The race window where the TS writer and the bash
389
+ # helper interleave is vanishingly small in practice (the bash
390
+ # helper only runs when the CLI is unreachable; if the CLI is
391
+ # unreachable the TS writer cannot run anyway).
392
+ #
393
+ # Lock-acquire policy: retry up to 50x at 50ms (≈2.5s window)
394
+ # when sub-second sleep is available, otherwise 5x at 1s
395
+ # (5s window). The `pnpm install` / `npm ci` commands the
396
+ # allowlist guards take seconds-to-minutes; a multi-second lock
397
+ # window is invisible to operators. On lock-acquire failure we
398
+ # refuse rather than silently bypassing — every allow MUST be
399
+ # auditable.
400
+ local lockdir="$proj/.rea/.audit.lock"
401
+ local lock_max_iter=50
402
+ local lock_sleep="0.05"
403
+ if ! sleep 0.01 2>/dev/null; then
404
+ lock_max_iter=5
405
+ lock_sleep="1"
406
+ fi
407
+ local lock_acquired=0
408
+ local lock_i=0
409
+ # Ensure .rea/ exists so mkdir(.audit.lock) has a parent. The
410
+ # bash helper is the only writer in the bootstrap state, so this
411
+ # is safe to do BEFORE locking — we only race on the audit-file
412
+ # tail, not on .rea/ creation.
413
+ mkdir -p "$proj/.rea" 2>/dev/null || true
414
+ # Stale-lock recovery: a process killed mid-write (SIGKILL, OOM,
415
+ # operator ^C through the wrong window) leaves the lockdir
416
+ # hanging. Anything older than 1 minute is unambiguously stale —
417
+ # the locked region holds for ~100ms typical, ~5s pathological.
418
+ # `find -mmin` is non-POSIX but supported by macOS find, GNU find
419
+ # (Linux), and Busybox find (Alpine). Failure to detect staleness
420
+ # is acceptable: we still fall back to the existing CLI-missing
421
+ # refusal path after the acquire window times out.
422
+ if [ -d "$lockdir" ]; then
423
+ if find "$lockdir" -maxdepth 0 -mmin +1 -type d 2>/dev/null | grep -q .; then
424
+ rmdir "$lockdir" 2>/dev/null || true
425
+ fi
426
+ fi
427
+ while [ "$lock_i" -lt "$lock_max_iter" ]; do
428
+ if mkdir "$lockdir" 2>/dev/null; then
429
+ lock_acquired=1
430
+ break
431
+ fi
432
+ sleep "$lock_sleep" 2>/dev/null || true
433
+ lock_i=$((lock_i + 1))
434
+ done
435
+ if [ "$lock_acquired" -eq 0 ]; then
436
+ # Lock starvation — refuse rather than fork the chain.
437
+ return 1
438
+ fi
439
+
440
+ # Build the metadata sub-object via node so JSON escaping is
441
+ # bulletproof — Bash's printf does not escape control characters
442
+ # the way JSON requires.
443
+ local record=""
444
+ record=$(node -e '
445
+ const crypto = require("crypto");
446
+ const fs = require("fs");
447
+ const path = require("path");
448
+ const args = process.argv.slice(1);
449
+ const auditFile = args[0];
450
+ const timestamp = args[1];
451
+ const shim = args[2];
452
+ const pm = args[3];
453
+ const argvShape = args[4];
454
+ const argvSha = args[5];
455
+ const pjSha = args[6];
456
+ const declaresRea = args[7] === "true";
457
+ const declaredRange = args[8];
458
+ const policyEnabled = args[9] === "true";
459
+ // Read prev_hash from the last line of the existing audit file.
460
+ //
461
+ // R4-P1 (codex round 4): tail-validation must distinguish three
462
+ // states:
463
+ //
464
+ // 1. File absent OR zero bytes → GENESIS (prev_hash = all-zeros)
465
+ // 2. Last line parses as JSON with a valid 64-hex `hash` field
466
+ // → NORMAL (prev_hash = that hash)
467
+ // 3. File exists with bytes but the last non-empty line is
468
+ // partial / not-JSON / missing the hash field / wrong
469
+ // hash shape → CORRUPTION
470
+ //
471
+ // Pre-fix, case (3) silently fell back to genesis — the next
472
+ // bootstrap allow appended a record whose prev_hash pointed at
473
+ // the genesis sentinel instead of the real tail, permanently
474
+ // forking the chain. The "every allow is auditable" invariant
475
+ // requires us to refuse rather than fork. On corruption we
476
+ // emit empty stdout + a stderr explainer; the bash caller
477
+ // already refuses when stdout is empty (see "if [ -z \"$record\" ]"
478
+ // a few lines down).
479
+ const HEX64 = /^[0-9a-f]{64}$/;
480
+ const GENESIS = "0000000000000000000000000000000000000000000000000000000000000000";
481
+ let prevHash = null;
482
+ let exists = false;
483
+ try {
484
+ const st = fs.statSync(auditFile);
485
+ exists = st.isFile();
486
+ } catch (e) { /* ENOENT or perms → treat as not-present */ }
487
+ if (!exists) {
488
+ prevHash = GENESIS;
489
+ } else {
490
+ let raw = "";
491
+ try { raw = fs.readFileSync(auditFile, "utf8"); }
492
+ catch (e) {
493
+ // File present but unreadable. Distinguish-able from
494
+ // genesis: refuse.
495
+ process.stderr.write("rea: bootstrap-allowlist refused — audit file " + auditFile + " is unreadable.\n");
496
+ process.exit(0);
497
+ }
498
+ if (raw.length === 0) {
499
+ prevHash = GENESIS;
500
+ } else {
501
+ // Find the LAST non-empty line. If the file does not end
502
+ // with "\n", the trailing partial line is a crash-mid-write
503
+ // signal — that is corruption.
504
+ const endsWithNewline = raw.endsWith("\n");
505
+ const lines = raw.split("\n");
506
+ // After split, a trailing newline produces a final "" entry.
507
+ // A missing trailing newline leaves the partial tail as the
508
+ // final entry — and we refuse on that.
509
+ if (!endsWithNewline) {
510
+ process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " is missing a trailing newline (partial write detected). Repair the chain before retrying.\n");
511
+ process.exit(0);
512
+ }
513
+ // Strip the trailing empty entry and find the last non-blank line.
514
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
515
+ if (lines.length === 0) {
516
+ // File contained only newlines — refuse, this is corruption.
517
+ process.stderr.write("rea: bootstrap-allowlist refused — audit file " + auditFile + " contains only whitespace.\n");
518
+ process.exit(0);
519
+ }
520
+ const lastLine = lines[lines.length - 1];
521
+ let lastObj = null;
522
+ try { lastObj = JSON.parse(lastLine); }
523
+ catch (e) {
524
+ process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is not valid JSON.\n");
525
+ process.exit(0);
526
+ }
527
+ if (lastObj === null || typeof lastObj !== "object" || Array.isArray(lastObj)) {
528
+ process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is not a JSON object.\n");
529
+ process.exit(0);
530
+ }
531
+ if (typeof lastObj.hash !== "string" || !HEX64.test(lastObj.hash)) {
532
+ process.stderr.write("rea: bootstrap-allowlist refused — audit tail at " + auditFile + " line " + lines.length + " is missing a valid 64-hex `hash` field.\n");
533
+ process.exit(0);
534
+ }
535
+ prevHash = lastObj.hash;
536
+ }
537
+ }
538
+ if (prevHash === null) {
539
+ // Defensive — should be unreachable; every branch above either
540
+ // sets prevHash or exits.
541
+ process.exit(0);
542
+ }
543
+ // PINNED key order — matches the canonical AuditRecord shape.
544
+ const recordBase = {
545
+ timestamp: timestamp,
546
+ session_id: "bash-tier",
547
+ tool_name: "rea.bash.bootstrap_allow",
548
+ server_name: "rea",
549
+ tier: "write",
550
+ status: "allowed",
551
+ autonomy_level: "unknown",
552
+ duration_ms: 0,
553
+ prev_hash: prevHash,
554
+ emission_source: "rea-cli",
555
+ metadata: {
556
+ shim: shim,
557
+ pm: pm,
558
+ argv_shape: argvShape,
559
+ argv_segments_sha256: argvSha,
560
+ package_json_sha256: pjSha,
561
+ package_json_declares_rea: declaresRea,
562
+ declared_version_range: declaredRange,
563
+ cli_resolution: "missing",
564
+ policy_enabled: policyEnabled,
565
+ },
566
+ };
567
+ const canonical = JSON.stringify(recordBase);
568
+ const hash = crypto.createHash("sha256").update(canonical).digest("hex");
569
+ const record = Object.assign({}, recordBase, { hash: hash });
570
+ process.stdout.write(JSON.stringify(record));
571
+ ' -- "$audit_file" "$timestamp" "$shim" "$pm" "$argv_shape" "$argv_sha" "$pj_sha" "$declares_rea" "$declared_range" "$policy_enabled" || true)
572
+ # R4-P1 (codex round 4): keep stderr UN-redirected on the
573
+ # record-build call. The corruption-refusal branch writes a
574
+ # human-readable explainer to stderr, and operators investigating
575
+ # a refused bootstrap allow need to see WHY the chain refused.
576
+ # Any other node-side failure (OOM, missing crypto module, etc.)
577
+ # is also legitimately operator-actionable — silencing those
578
+ # was hiding signal, not noise.
579
+
580
+ if [ -z "$record" ]; then
581
+ # Audit-emit unavailable — security-architect mandate: every
582
+ # bootstrap_allow MUST be auditable. Refuse rather than allow
583
+ # silently. The caller treats stderr-only-refuse as a fallthrough
584
+ # to the existing CLI-missing path (banner + exit 2).
585
+ rmdir "$lockdir" 2>/dev/null || true
586
+ return 1
587
+ fi
588
+
589
+ # Append + fsync via a single node call.
590
+ node -e '
591
+ const fs = require("fs");
592
+ const path = require("path");
593
+ const auditFile = process.argv[1];
594
+ const line = process.argv[2] + "\n";
595
+ try {
596
+ fs.mkdirSync(path.dirname(auditFile), { recursive: true });
597
+ const fd = fs.openSync(auditFile, "a");
598
+ fs.writeSync(fd, line);
599
+ try { fs.fsyncSync(fd); } catch (e) {}
600
+ fs.closeSync(fd);
601
+ } catch (e) {
602
+ process.exit(1);
603
+ }
604
+ ' -- "$audit_file" "$record" 2>/dev/null
605
+ local append_rc=$?
606
+ rmdir "$lockdir" 2>/dev/null || true
607
+ return $append_rc
608
+ }
609
+
610
+ # Match the package-spec argument of a `pm add` invocation against the
611
+ # bare-only shape for `@bookedsolid/rea`.
612
+ #
613
+ # R6-P2 (codex round 6): version-pinned forms are REFUSED.
614
+ #
615
+ # Accepted: @bookedsolid/rea (bare — install whatever the consumer's
616
+ # existing self-pin admits)
617
+ # Rejected: @bookedsolid/rea@<anything> (incl. dist-tags `@latest`,
618
+ # `@next`, exact versions
619
+ # `@0.48.0`, ranges, etc.)
620
+ # every other spec (different package, malformed scope).
621
+ #
622
+ # Rationale: the bootstrap allowlist's stated job is to recover a
623
+ # CLI-missing repo. Version selection is `rea init` (caret pin at
624
+ # install time) and `rea upgrade` (managed-caret bump via the TS path,
625
+ # under audit) territory — NOT the Bash-tier bootstrap path. Allowing
626
+ # `@bookedsolid/rea@<ver>` here let a Bash-only session retarget the
627
+ # trusted gate binary mid-bootstrap by pinning to an older or
628
+ # attacker-controlled version, defeating the new `package.json` blocked-
629
+ # path protection in bst-internal*. Stripping the version branch closes
630
+ # that retarget surface entirely. See THREAT_MODEL.md §5.23.
631
+ #
632
+ # Returns 0 if matched (bare spec), 1 otherwise.
633
+ _bootstrap_match_rea_spec() {
634
+ [ "$1" = '@bookedsolid/rea' ]
635
+ }
636
+
637
+ # Classify the argv array (already split) against the per-PM allowed
638
+ # shape lists. Echoes the shape token (e.g. "install" / "ci" /
639
+ # "add-rea") on stdout when matched, OR empty stdout on no match.
640
+ # Always returns 0.
641
+ #
642
+ # argv[0] is the PM name (basename-exact-matched by the caller).
643
+ _bootstrap_classify_pnpm() {
644
+ # $@ = full argv (including pnpm)
645
+ local argv0="${1:-}"
646
+ shift || true
647
+ case "${argv0}" in
648
+ pnpm) ;;
649
+ *) return 0 ;;
650
+ esac
651
+ # Shapes we accept (post-pnpm). R6-P2: bare `@bookedsolid/rea` only;
652
+ # version-pinned `@bookedsolid/rea@<ver>` is refused.
653
+ # install
654
+ # i
655
+ # install --frozen-lockfile
656
+ # install --no-frozen-lockfile
657
+ # i --frozen-lockfile
658
+ # i --no-frozen-lockfile
659
+ # add -D @bookedsolid/rea
660
+ # add --save-dev @bookedsolid/rea
661
+ local first="${1:-}"
662
+ case "$first" in
663
+ install|i)
664
+ shift
665
+ if [ "$#" -eq 0 ]; then
666
+ printf 'install'
667
+ return 0
668
+ fi
669
+ if [ "$#" -eq 1 ]; then
670
+ case "$1" in
671
+ '--frozen-lockfile'|'--no-frozen-lockfile')
672
+ printf 'install-locked'
673
+ return 0
674
+ ;;
675
+ esac
676
+ fi
677
+ return 0
678
+ ;;
679
+ add)
680
+ shift
681
+ # Expect: -D|--save-dev <pkg-spec>
682
+ if [ "$#" -ne 2 ]; then
683
+ return 0
684
+ fi
685
+ case "$1" in
686
+ '-D'|'--save-dev') ;;
687
+ *) return 0 ;;
688
+ esac
689
+ if _bootstrap_match_rea_spec "$2"; then
690
+ printf 'add-rea'
691
+ return 0
692
+ fi
693
+ return 0
694
+ ;;
695
+ esac
696
+ return 0
697
+ }
698
+
699
+ _bootstrap_classify_npm() {
700
+ local argv0="${1:-}"
701
+ shift || true
702
+ case "${argv0}" in
703
+ npm) ;;
704
+ *) return 0 ;;
705
+ esac
706
+ local first="${1:-}"
707
+ case "$first" in
708
+ install|i)
709
+ shift
710
+ if [ "$#" -eq 0 ]; then
711
+ printf 'install'
712
+ return 0
713
+ fi
714
+ # npm install -D @bookedsolid/rea
715
+ # npm install --save-dev @bookedsolid/rea
716
+ # R6-P2: version-pinned forms refuse.
717
+ if [ "$#" -eq 2 ]; then
718
+ case "$1" in
719
+ '-D'|'--save-dev') ;;
720
+ *) return 0 ;;
721
+ esac
722
+ if _bootstrap_match_rea_spec "$2"; then
723
+ printf 'add-rea'
724
+ return 0
725
+ fi
726
+ fi
727
+ return 0
728
+ ;;
729
+ ci)
730
+ shift
731
+ if [ "$#" -eq 0 ]; then
732
+ printf 'ci'
733
+ return 0
734
+ fi
735
+ return 0
736
+ ;;
737
+ esac
738
+ return 0
739
+ }
740
+
741
+ _bootstrap_classify_yarn() {
742
+ local argv0="${1:-}"
743
+ shift || true
744
+ case "${argv0}" in
745
+ yarn) ;;
746
+ *) return 0 ;;
747
+ esac
748
+ local first="${1:-}"
749
+ if [ -z "$first" ]; then
750
+ # Bare `yarn` (yarn classic install).
751
+ printf 'install'
752
+ return 0
753
+ fi
754
+ case "$first" in
755
+ install)
756
+ shift
757
+ if [ "$#" -eq 0 ]; then
758
+ printf 'install'
759
+ return 0
760
+ fi
761
+ return 0
762
+ ;;
763
+ add)
764
+ shift
765
+ # yarn add -D @bookedsolid/rea
766
+ # yarn add --dev @bookedsolid/rea
767
+ # R6-P2: version-pinned forms refuse.
768
+ if [ "$#" -eq 2 ]; then
769
+ case "$1" in
770
+ '-D'|'--dev') ;;
771
+ *) return 0 ;;
772
+ esac
773
+ if _bootstrap_match_rea_spec "$2"; then
774
+ printf 'add-rea'
775
+ return 0
776
+ fi
777
+ fi
778
+ return 0
779
+ ;;
780
+ esac
781
+ return 0
782
+ }
783
+
784
+ _bootstrap_classify_corepack() {
785
+ local argv0="${1:-}"
786
+ shift || true
787
+ case "${argv0}" in
788
+ corepack) ;;
789
+ *) return 0 ;;
790
+ esac
791
+ local first="${1:-}"
792
+ case "$first" in
793
+ enable)
794
+ shift
795
+ if [ "$#" -eq 0 ]; then
796
+ printf 'corepack-enable'
797
+ return 0
798
+ fi
799
+ # `corepack enable pnpm` (or yarn/npm)
800
+ if [ "$#" -eq 1 ]; then
801
+ case "$1" in
802
+ pnpm|yarn|npm)
803
+ printf 'corepack-enable-pm'
804
+ return 0
805
+ ;;
806
+ esac
807
+ fi
808
+ return 0
809
+ ;;
810
+ prepare)
811
+ shift
812
+ # `corepack prepare pnpm@<ver> --activate`
813
+ if [ "$#" -eq 2 ]; then
814
+ case "$1" in
815
+ pnpm@*|yarn@*|npm@*)
816
+ local pmver="${1%%@*}"
817
+ local ver="${1#*@}"
818
+ # Ver charset same as rea-spec (semver-like).
819
+ if [ -z "$ver" ] || [ "${#ver}" -gt 64 ]; then
820
+ return 0
821
+ fi
822
+ local i=0
823
+ while [ $i -lt ${#ver} ]; do
824
+ local c="${ver:$i:1}"
825
+ case "$c" in
826
+ [A-Za-z0-9._~+\-]|'^') ;;
827
+ *) return 0 ;;
828
+ esac
829
+ i=$((i + 1))
830
+ done
831
+ if [ "$2" = '--activate' ]; then
832
+ # Avoid unused-var warning for pmver (purely documentary
833
+ # at this point — already constrained by the case above).
834
+ : "$pmver"
835
+ printf 'corepack-prepare'
836
+ return 0
837
+ fi
838
+ ;;
839
+ esac
840
+ fi
841
+ return 0
842
+ ;;
843
+ esac
844
+ return 0
845
+ }
846
+
847
+ # Entrypoint.
848
+ # Args:
849
+ # $1 = shim name (used in audit event)
850
+ # $2 = the extracted single-segment Bash command
851
+ # $3 = path to package.json (canonical: $CLAUDE_PROJECT_DIR/package.json)
852
+ # $4 = path to policy.yaml (canonical: $CLAUDE_PROJECT_DIR/.rea/policy.yaml)
853
+ # $5 = $proj (realpath-resolved project dir for the audit log)
854
+ #
855
+ # Echoes "allow" or "refuse" on stdout. Always exits 0.
856
+ bootstrap_allowlist_check() {
857
+ # ast-parser-specialist required: declare local IFS so a hostile
858
+ # caller cannot reshape word-splitting via IFS leakage.
859
+ local IFS=$' \t\n'
860
+
861
+ local shim="$1"
862
+ local cmd="$2"
863
+ local pj="$3"
864
+ local policy_file="$4"
865
+ local proj="$5"
866
+
867
+ # Policy precondition: enabled?
868
+ if ! _bootstrap_allowlist_policy_enabled "$policy_file"; then
869
+ printf 'refuse'
870
+ return 0
871
+ fi
872
+
873
+ # Precondition: package.json declares @bookedsolid/rea?
874
+ local declared_range=""
875
+ declared_range=$(_bootstrap_check_package_json "$pj")
876
+ if [ -z "$declared_range" ]; then
877
+ printf 'refuse'
878
+ return 0
879
+ fi
880
+
881
+ # Trim leading/trailing whitespace from the command.
882
+ local trimmed="$cmd"
883
+ trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
884
+ trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
885
+ if [ -z "$trimmed" ]; then
886
+ printf 'refuse'
887
+ return 0
888
+ fi
889
+
890
+ # Defense: refuse multi-segment payloads. The shim helper passes us
891
+ # the extracted command; the caller is responsible for routing
892
+ # multi-segment commands away from the allowlist, but if it didn't,
893
+ # we hard-refuse here. _rea_split_segments outputs one segment per
894
+ # line; we count via a single subprocess.
895
+ local seg_count=0
896
+ while IFS= read -r _seg; do
897
+ if [ -n "$_seg" ]; then
898
+ seg_count=$((seg_count + 1))
899
+ fi
900
+ done < <(_rea_split_segments "$trimmed")
901
+ if [ "$seg_count" -gt 1 ]; then
902
+ printf 'refuse'
903
+ return 0
904
+ fi
905
+
906
+ # Split argv via `read -ra`. Quoted forms refuse-fallthrough — a
907
+ # quoted argv token does not match the bare-string allowlist
908
+ # patterns, which is intentional: an attacker laundering through
909
+ # quotes is the kind of payload we want to refuse.
910
+ local -a ARGV
911
+ read -ra ARGV <<<"$trimmed"
912
+ if [ "${#ARGV[@]}" -eq 0 ]; then
913
+ printf 'refuse'
914
+ return 0
915
+ fi
916
+
917
+ local argv0="${ARGV[0]}"
918
+ # Reject path-form argv[0] (anything containing a slash).
919
+ case "$argv0" in
920
+ */*) printf 'refuse'; return 0 ;;
921
+ esac
922
+ # Reject anything containing characters outside [A-Za-z0-9._-].
923
+ case "$argv0" in
924
+ *[!A-Za-z0-9._-]*) printf 'refuse'; return 0 ;;
925
+ esac
926
+
927
+ local pm=""
928
+ local shape=""
929
+ case "$argv0" in
930
+ pnpm) pm="pnpm"; shape=$(_bootstrap_classify_pnpm "${ARGV[@]}") ;;
931
+ npm) pm="npm"; shape=$(_bootstrap_classify_npm "${ARGV[@]}") ;;
932
+ yarn) pm="yarn"; shape=$(_bootstrap_classify_yarn "${ARGV[@]}") ;;
933
+ corepack)pm="corepack"; shape=$(_bootstrap_classify_corepack "${ARGV[@]}") ;;
934
+ *)
935
+ printf 'refuse'
936
+ return 0
937
+ ;;
938
+ esac
939
+
940
+ if [ -z "$shape" ]; then
941
+ printf 'refuse'
942
+ return 0
943
+ fi
944
+
945
+ # Build audit fields.
946
+ # Hash the argv segments (space-joined for a stable canonical form).
947
+ local argv_canonical=""
948
+ argv_canonical=$(printf '%s' "${ARGV[*]}")
949
+ local argv_sha=""
950
+ argv_sha=$(_bootstrap_sha256 "$argv_canonical")
951
+ local pj_sha=""
952
+ pj_sha=$(_bootstrap_sha256_file "$pj")
953
+ if [ -z "$argv_sha" ] || [ -z "$pj_sha" ]; then
954
+ # R7-P1 (codex round 7): hasher unavailable — every allow MUST
955
+ # be auditable, so we refuse-HARD. The `refuse-hard` stdout token
956
+ # tells the shim caller to refuse via banner regardless of the
957
+ # substring-scan verdict; collapsing this into the plain `refuse`
958
+ # token would let the shim fall through to "no-substring →
959
+ # silent allow" and break the auditability invariant.
960
+ printf 'refuse-hard'
961
+ return 0
962
+ fi
963
+
964
+ # Emit audit. R7-P1: emit-failure is also refuse-hard for the same
965
+ # auditability reason.
966
+ if ! _bootstrap_emit_audit "$shim" "$pm" "$shape" "$argv_sha" \
967
+ "$pj_sha" "true" "$declared_range" "true" "$proj"; then
968
+ printf 'refuse-hard'
969
+ return 0
970
+ fi
971
+
972
+ printf 'allow'
973
+ return 0
974
+ }
975
+
976
+ # P1-2 (codex round 2) / R5-P1 (codex round 5) / R7-P1 (codex round 7):
977
+ # shim-side helper. Consults the allowlist for a CLI-missing Bash
978
+ # payload when argv[0] is a recognized PM (pnpm/npm/yarn/corepack),
979
+ # keeping the shim integration small enough to stay under its
980
+ # ≤120-LOC budget.
981
+ #
982
+ # # Return-code contract
983
+ #
984
+ # The allowlist OPENS gates, it does NOT CLOSE them — EXCEPT for the
985
+ # audit-integrity gate, which is fail-CLOSED. The shim's substring
986
+ # scan stays determinative for refusal in the ordinary case; the
987
+ # allowlist provides the audit trail when it permits an otherwise-
988
+ # suspicious (substring-matched) command. But when audit emission
989
+ # itself fails (`.rea/audit.jsonl` corrupted, hasher unavailable,
990
+ # disk full, etc.), every PM payload — substring-matched or not —
991
+ # must refuse, because allowing without an audit trail breaks the
992
+ # "every bootstrap allow is auditable" invariant.
993
+ #
994
+ # Return codes:
995
+ # - EXIT 0: PM payload, allowlist allows (audit event emitted by
996
+ # the helper itself). Caller should `exit 0` IMMEDIATELY — the
997
+ # auditable-allow path, valid whether or not the substring scan
998
+ # matched.
999
+ # - EXIT 1: refuse-FALLTHROUGH. argv[0] not a PM, OR shape didn't
1000
+ # match, OR precondition failed (no rea declaration). Caller
1001
+ # decides what to do based on its own substring-scan result:
1002
+ # * substring matched → caller emits CLI-missing banner.
1003
+ # * no substring match → caller preserves the documented
1004
+ # "no-policy / no-match => allow" posture (silent allow,
1005
+ # no audit).
1006
+ # - EXIT 2: refuse-HARD. Audit-integrity failure (R7-P1 / codex
1007
+ # round 7). Caller MUST refuse via banner regardless of the
1008
+ # substring-scan verdict — silently allowing would violate the
1009
+ # auditability invariant and let a corrupted audit chain go
1010
+ # unenforced. The helper has already printed an operator-
1011
+ # actionable explainer to stderr (e.g. "audit tail at <path>
1012
+ # line <N> is not valid JSON").
1013
+ #
1014
+ # Args:
1015
+ # $1 = shim name (passed verbatim to bootstrap_allowlist_check)
1016
+ # $2 = the extracted Bash command string
1017
+ # $3 = REA_ROOT (resolved by the shim's halt-check.sh sourcing)
1018
+ _bootstrap_shim_pm_route() {
1019
+ # R3-P1 (codex round 3): pin a local IFS so a hostile parent
1020
+ # environment that exported `IFS=X` cannot reshape the `read -ra`
1021
+ # below. Matches the same defense `bootstrap_allowlist_check`
1022
+ # applies (see line ~736).
1023
+ local IFS=$' \t\n'
1024
+
1025
+ local shim_name="$1"
1026
+ local cmd="$2"
1027
+ local rea_root="$3"
1028
+
1029
+ # Extract argv0 basename via `read -ra` (R3-P1: tab-separator and
1030
+ # leading-whitespace shapes get parsed identically to what the
1031
+ # allowlist itself does).
1032
+ local -a _PM_ARGV
1033
+ read -ra _PM_ARGV <<<"$cmd"
1034
+ if [ "${#_PM_ARGV[@]}" -eq 0 ]; then
1035
+ return 1
1036
+ fi
1037
+ local argv0="${_PM_ARGV[0]}"
1038
+ argv0="${argv0##*/}"
1039
+ case "$argv0" in
1040
+ pnpm|npm|yarn|corepack) ;;
1041
+ *) return 1 ;;
1042
+ esac
1043
+
1044
+ # Resolve project root with realpath. CLAUDE_PROJECT_DIR wins when
1045
+ # available; fall back to REA_ROOT. ast-parser-specialist locked
1046
+ # the cd-pwd-P idiom for realpath resolution on bash 3.2.
1047
+ local proj_root="$rea_root"
1048
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
1049
+ if proj_root=$(cd "$CLAUDE_PROJECT_DIR" 2>/dev/null && pwd -P 2>/dev/null); then
1050
+ :
1051
+ else
1052
+ proj_root="$rea_root"
1053
+ fi
1054
+ fi
1055
+
1056
+ local policy_file="$rea_root/.rea/policy.yaml"
1057
+ if [ ! -f "$policy_file" ]; then
1058
+ policy_file="$proj_root/.rea/policy.yaml"
1059
+ fi
1060
+ local pj="$proj_root/package.json"
1061
+
1062
+ local verdict
1063
+ verdict=$(bootstrap_allowlist_check "$shim_name" "$cmd" "$pj" "$policy_file" "$proj_root")
1064
+ case "$verdict" in
1065
+ allow) return 0 ;;
1066
+ # R7-P1: audit-integrity failure — propagate refuse-hard to the
1067
+ # shim caller so it refuses via banner regardless of substring
1068
+ # scan. `bootstrap_allowlist_check` already printed the explainer
1069
+ # to stderr (preserved through the un-suppressed pipe in
1070
+ # `_bootstrap_emit_audit`).
1071
+ refuse-hard) return 2 ;;
1072
+ *) return 1 ;;
1073
+ esac
1074
+ }
1075
+