@bookedsolid/rea 0.33.0 → 0.34.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,573 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: local-review-gate.sh
3
+ # 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
4
+ #
5
+ # Pre-0.34.0 the gate's full body lived here as bash (460 LOC,
6
+ # including the per-trigger inline-bypass walker, multi-segment
7
+ # laundering defense, and the friendly refusal banner). The migration
8
+ # to the Node binary moves the per-segment trigger detection +
9
+ # preflight call into `src/hooks/local-review-gate/index.ts`. This
10
+ # shim is the Claude Code dispatcher's view of the hook — it
11
+ # forwards stdin to the CLI and exits with whatever the CLI returns.
12
+ #
13
+ # Behavioral contract is preserved byte-for-byte: exit 0 on
14
+ # pass-through / mode=off / bypassed / preflight-allow, exit 2 on
15
+ # HALT / preflight-refuse / malformed payload.
16
+ #
17
+ # # Shim short-circuits (codex round-1 P1+P2 fixes)
18
+ #
19
+ # The 0.34.0 round-0 shim deferred ALL decisions to the CLI, including
20
+ # `mode: off` and the bypass env-var. That regressed two documented
21
+ # workflows on fresh/unbuilt installs:
22
+ # - codex-less teams with `policy.review.local_review.mode: off` must
23
+ # still be able to `git push` even when the rea CLI isn't built.
24
+ # - operators with the audited bypass env-var set (default
25
+ # `REA_SKIP_LOCAL_REVIEW=<reason>`) must still be able to push.
26
+ # Round-1 P1 fix: read the mode + bypass env-var INLINE in the shim
27
+ # BEFORE any CLI resolution. These two short-circuits exit 0 cleanly
28
+ # without spawning node. The full enforcement (multi-trigger sweep,
29
+ # inline-bypass evaluation, preflight call) still lives in the CLI.
30
+ #
31
+ # # CLI-resolution trust boundary
32
+ #
33
+ # Mirrors the 0.32.0 final shim shape. The resolved CLI MUST live
34
+ # INSIDE realpath(CLAUDE_PROJECT_DIR) AND have an ancestor
35
+ # `package.json` whose `name` is `@bookedsolid/rea`.
36
+ #
37
+ # # Fail-closed posture
38
+ #
39
+ # local-review-gate is BLOCKING-tier — the pre-0.34.0 bash body
40
+ # refused `git push` (and optionally `git commit`) without a recent
41
+ # audit entry. The early-exit branches (CLI missing, node missing,
42
+ # sandbox failed, version skew) fail closed AFTER the relevance
43
+ # pre-gate passes AND AFTER the mode/bypass short-circuits.
44
+ #
45
+ # # Relevance pre-gate
46
+ #
47
+ # Round-1 P2 fix: the substring scan must NOT mark commands as
48
+ # relevant when `git push`/`git commit` only appears inside a quoted
49
+ # argument body (`echo "remember git push later"`,
50
+ # `git commit -m "doc: explain git push --force"`). Pre-fix the
51
+ # substring scan saw these as relevant → entered fail-closed branch
52
+ # when CLI was missing. Fix: anchor the substring scan on segment
53
+ # heads via a stripped-prefix check, matching the CLI's segment-aware
54
+ # detector.
55
+
56
+ set -uo pipefail
57
+
58
+ # 1. HALT check.
59
+ # shellcheck source=_lib/halt-check.sh
60
+ source "$(dirname "$0")/_lib/halt-check.sh"
61
+ check_halt
62
+ REA_ROOT=$(rea_root)
63
+
64
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
65
+
66
+ # 2. Read stdin once. Used by the relevance pre-gate, the bypass
67
+ # short-circuit, AND the CLI forward.
68
+ INPUT=$(cat)
69
+
70
+ # 2b. Early bypass-env-var short-circuit (round-7 P2 fix). The
71
+ # pre-0.34.0 bash body honored the operator-exported bypass var
72
+ # BEFORE any policy read. The round-1+ shim deferred the bypass
73
+ # check to section 6, which sits AFTER the policy-reader spawns
74
+ # the CLI for mode/refuse_at lookups (section 4 + section 5). On
75
+ # unbuilt installs OR when the CLI fails the sandbox check, those
76
+ # policy reads can no-op silently — but the audited bypass should
77
+ # STILL short-circuit so operators can push through the gate.
78
+ #
79
+ # We can only check the DEFAULT var name (REA_SKIP_LOCAL_REVIEW)
80
+ # this early because the policy-renamed `bypass_env_var` requires
81
+ # a policy read. The policy-aware re-check at section 6 still runs
82
+ # for renamed vars when the CLI is reachable. Operators who rename
83
+ # the var AND have a broken CLI fall back to the section-6 awk
84
+ # parser (block-form only) — same posture as pre-fix; this early
85
+ # gate only adds coverage for the default-var case.
86
+ EARLY_BYPASS_VALUE="${REA_SKIP_LOCAL_REVIEW:-}"
87
+ if [ -n "$EARLY_BYPASS_VALUE" ]; then
88
+ exit 0
89
+ fi
90
+
91
+ # 3. Resolve the rea CLI path early — used (a) by the policy reader
92
+ # fallback below to honor inline `local_review: { mode: ... }`
93
+ # mappings, and (b) by the forward step at the bottom. Stored as
94
+ # REA_ARGV so the same array drives both calls.
95
+ POLICY_FILE="$proj/.rea/policy.yaml"
96
+ REA_ARGV=()
97
+ RESOLVED_CLI_PATH=""
98
+ if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
99
+ REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
100
+ RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
101
+ elif [ -f "$proj/dist/cli/index.js" ]; then
102
+ REA_ARGV=(node "$proj/dist/cli/index.js")
103
+ RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
104
+ fi
105
+
106
+ # Round-5 P1 fix: sandbox-check the resolved CLI BEFORE any policy-get
107
+ # invocation. Pre-fix `_lrg_read_policy()` could spawn the resolved CLI
108
+ # (section 4 mode-off check, section 5 refuse_at) BEFORE the section-7
109
+ # sandbox validation — a symlinked or swapped `dist/cli/index.js`
110
+ # would execute during policy lookup, defeating the realpath /
111
+ # package.json trust boundary that the shim is supposed to enforce.
112
+ # We now validate the CLI's realpath sits inside CLAUDE_PROJECT_DIR
113
+ # AND has an ancestor `package.json` with name `@bookedsolid/rea`
114
+ # BEFORE the policy reader is allowed to spawn it. On failure we
115
+ # zero out REA_ARGV so the policy reader falls through to the awk
116
+ # block-form parser (which never spawns anything), and the eventual
117
+ # CLI-forward step at section 7 will refuse with the sandbox banner.
118
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && command -v node >/dev/null 2>&1; then
119
+ sandbox_check_early=$(node -e '
120
+ const fs = require("fs");
121
+ const path = require("path");
122
+ const cli = process.argv[1];
123
+ const projDir = process.argv[2];
124
+ let real, realProj;
125
+ try { real = fs.realpathSync(cli); } catch (e) {
126
+ process.stdout.write("bad:realpath"); process.exit(1);
127
+ }
128
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
129
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
130
+ }
131
+ const sep = path.sep;
132
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
133
+ if (!(real === realProj || real.startsWith(projWithSep))) {
134
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
135
+ }
136
+ let cur = path.dirname(path.dirname(path.dirname(real)));
137
+ let found = false;
138
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
139
+ const pj = path.join(cur, "package.json");
140
+ if (fs.existsSync(pj)) {
141
+ try {
142
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
143
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
144
+ } catch (e) { /* keep walking */ }
145
+ }
146
+ cur = path.dirname(cur);
147
+ }
148
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
149
+ process.stdout.write("ok");
150
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
151
+ if [ "$sandbox_check_early" != "ok" ]; then
152
+ # Sandbox failed. Stash the failure reason and clear REA_ARGV so
153
+ # the policy reader falls through to awk. The section-7 forward
154
+ # step will re-run the sandbox check and emit the canonical
155
+ # refusal banner to stderr.
156
+ SANDBOX_EARLY_FAILURE="$sandbox_check_early"
157
+ REA_ARGV=()
158
+ fi
159
+ fi
160
+
161
+ # Helper: read a `local_review.<leaf>` policy key. Tries
162
+ # `rea hook policy-get review.local_review --json` (one node-spawn for
163
+ # the whole subtree) first — that path handles inline + block YAML
164
+ # identically since it goes through the canonical `yaml.parse()`.
165
+ # Falls back to a block-form awk parser when the CLI isn't available
166
+ # or jq isn't installed. Empty stdout → "default applies".
167
+ #
168
+ # 0.34.0 round-2 P2 fix: pre-fix the shim only ran the block-form awk
169
+ # parser, so inline-form mappings like
170
+ # `local_review: { mode: off, refuse_at: commit }` silently no-op'd on
171
+ # stale-CLI installs (the canonical loader DOES handle them — only the
172
+ # shim was block-only). Hybrid policy reader mirrors the pattern used
173
+ # by prepare-commit-msg's augmenter.
174
+ #
175
+ # The subtree JSON is fetched ONCE per Bash event (cached in
176
+ # `_lrg_subtree_json`) so we don't pay 3x node-spawn cost. The cache
177
+ # variable is "" until first call, "<none>" if the CLI / jq path
178
+ # returned no usable JSON (so awk fallback runs), or the JSON body.
179
+ _lrg_subtree_json=""
180
+ _lrg_read_policy() {
181
+ # $1 = dotted key (e.g. `review.local_review.mode`)
182
+ local key="$1"
183
+ local leaf="${key##*.}"
184
+ # 1. First call: try `rea hook policy-get review.local_review --json`.
185
+ # Subsequent calls reuse the cached subtree.
186
+ if [ -z "$_lrg_subtree_json" ]; then
187
+ if [ "${#REA_ARGV[@]}" -gt 0 ] && command -v jq >/dev/null 2>&1; then
188
+ local json
189
+ json=$("${REA_ARGV[@]}" hook policy-get review.local_review --json 2>/dev/null || true)
190
+ # `null` indicates the path was unset — leaves jq to print
191
+ # `null` for any leaf, which we treat as "default applies".
192
+ if [ -n "$json" ]; then
193
+ _lrg_subtree_json="$json"
194
+ else
195
+ _lrg_subtree_json="<none>"
196
+ fi
197
+ else
198
+ _lrg_subtree_json="<none>"
199
+ fi
200
+ fi
201
+ # 2. If we have JSON, ask jq for the leaf.
202
+ if [ "$_lrg_subtree_json" != "<none>" ]; then
203
+ local out
204
+ out=$(printf '%s' "$_lrg_subtree_json" | jq -r --arg k "$leaf" '
205
+ if type == "object" and has($k) and (.[$k] != null) then .[$k] | tostring else "" end
206
+ ' 2>/dev/null || true)
207
+ if [ -n "$out" ]; then
208
+ printf '%s' "$out"
209
+ return 0
210
+ fi
211
+ # JSON path present but leaf unset → fall through to default. Do
212
+ # NOT also try the awk parser; the canonical loader is the source
213
+ # of truth here.
214
+ return 0
215
+ fi
216
+ # 3. Fallback: block-form awk parser (legacy 0.34.0 round-1 path).
217
+ # Only covers `review.local_review.<leaf>`. Inline-form mappings
218
+ # fall through to "" → defaults — which is the SAME posture as the
219
+ # pre-0.34.0 bash hook, but now with the CLI path above providing
220
+ # inline-form support whenever the CLI is reachable.
221
+ if [ ! -f "$POLICY_FILE" ] || ! command -v awk >/dev/null 2>&1; then
222
+ return 0
223
+ fi
224
+ case "$key" in
225
+ review.local_review.mode)
226
+ awk '
227
+ /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
228
+ /^[^[:space:]]/ { in_review=0; in_lr=0; next }
229
+ in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
230
+ in_lr && /^[[:space:]]{2,}[a-zA-Z]/ {
231
+ if ($1 ~ /^mode:/) { print $2; exit }
232
+ }
233
+ in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ { in_lr=0 }
234
+ ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
235
+ ;;
236
+ review.local_review.refuse_at)
237
+ awk '
238
+ /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
239
+ /^[^[:space:]]/ { in_review=0; in_lr=0; next }
240
+ in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
241
+ in_lr && /^[[:space:]]{2,}refuse_at[[:space:]]*:/ { print $2; exit }
242
+ in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ && !/^[[:space:]]+(mode|refuse_at|bypass_env_var|max_age_seconds)/ { in_lr=0 }
243
+ ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
244
+ ;;
245
+ review.local_review.bypass_env_var)
246
+ awk '
247
+ /^review[[:space:]]*:[[:space:]]*$/ { in_review=1; next }
248
+ /^[^[:space:]]/ { in_review=0; in_lr=0; next }
249
+ in_review && /^[[:space:]]+local_review[[:space:]]*:[[:space:]]*$/ { in_lr=1; next }
250
+ in_lr && /^[[:space:]]{2,}bypass_env_var[[:space:]]*:/ { print $2; exit }
251
+ in_lr && /^[[:space:]]{0,2}[a-zA-Z]/ && !/^[[:space:]]+local_review/ && !/^[[:space:]]+(mode|refuse_at|bypass_env_var|max_age_seconds)/ { in_lr=0 }
252
+ ' "$POLICY_FILE" 2>/dev/null | tr -d '"' | tr -d "'" | head -1
253
+ ;;
254
+ esac
255
+ }
256
+
257
+ # 4. Mode-off short-circuit. Mirrors the bash hook's
258
+ # `policy_get_local_review_mode` check at the top — `off` → silent
259
+ # no-op BEFORE any other work.
260
+ LOCAL_REVIEW_MODE=$(_lrg_read_policy review.local_review.mode)
261
+ if [ "$LOCAL_REVIEW_MODE" = "off" ]; then
262
+ exit 0
263
+ fi
264
+
265
+ # 5. Read `refuse_at` to scope the relevance pre-gate. Under the
266
+ # default `refuse_at: push`, a `git commit` segment is NOT refused
267
+ # by the CLI — so when the CLI is missing, the shim should let
268
+ # `git commit -m "..."` pass without hitting fail-closed. Mirrors
269
+ # the bash hook's posture: a non-refused git op does not enter
270
+ # the preflight-refuse branch.
271
+ REFUSE_AT="push"
272
+ POLICY_REFUSE=$(_lrg_read_policy review.local_review.refuse_at)
273
+ case "$POLICY_REFUSE" in push|commit|both) REFUSE_AT="$POLICY_REFUSE" ;; esac
274
+ # Build trigger-head alternation based on refuse_at.
275
+ case "$REFUSE_AT" in
276
+ push) TRIGGER_RE='git[[:space:]]+push' ;;
277
+ commit) TRIGGER_RE='git[[:space:]]+commit' ;;
278
+ both) TRIGGER_RE='git[[:space:]]+(push|commit)' ;;
279
+ esac
280
+
281
+ # Relevance pre-gate. Anchor on the trigger regex at the head of each
282
+ # ;/&&/||/| separated segment — this matches the CLI's segment-aware
283
+ # detector and avoids false-positives on quoted arguments like
284
+ # `git commit -m "doc: git push later"`.
285
+ #
286
+ # The check is approximate (it uses a coarse quote masker that the CLI
287
+ # does properly via mvdan-sh) because if it errs on the side of
288
+ # relevant→true, the CLI's real segment walker will sort it out. We
289
+ # only want to short-circuit confidently-non-relevant cases (where
290
+ # there's NO trigger head in any segment) so unbuilt installs don't
291
+ # fail closed on benign Bash calls.
292
+ #
293
+ # 0.34.0 round-2 P1 fix: the env-prefix-strip MUST accept quoted
294
+ # values. Pre-fix the strip pattern was
295
+ # `[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+`, which silently
296
+ # missed shapes like `GIT_SSH_COMMAND="ssh -i ~/.ssh/id" git push`
297
+ # because the `[^[:space:]]+` value group stops at the first space
298
+ # inside the quotes. We mirror the segments.ts `matchEnvAssignLength`
299
+ # helper — accept value shapes `"..."`, `'...'`, `\S*` (zero-or-more
300
+ # so bare `FOO= cmd` resolves too). The strip runs ITERATIVELY so
301
+ # stacked env prefixes (`A="x" B='y' C=z git push`) all get peeled.
302
+ RELEVANT=0
303
+ PROBE=""
304
+ JQ_PARSE_FAILED=0
305
+ # 0.34.0 round-4 P2 fix: capture jq's exit code SEPARATELY rather than
306
+ # swallowing it with `|| true`. Malformed PreToolUse payload (invalid
307
+ # JSON, schema mismatch) pre-fix → empty PROBE → RELEVANT=0 fast path
308
+ # → silent bypass. Post-fix we distinguish:
309
+ # - jq exit 0 + non-empty stdout → use as PROBE (the normal path)
310
+ # - jq exit 0 + empty stdout → non-Bash payload / empty cmd, RELEVANT=0
311
+ # - jq exit != 0 (parse failure) → JQ_PARSE_FAILED=1, force RELEVANT=1
312
+ # so we skip the awk pre-gate and
313
+ # forward straight to the CLI body
314
+ # which fails closed on malformed
315
+ # payloads via Zod. Substring-only
316
+ # fallback was insufficient because
317
+ # raw JSON often won't contain
318
+ # `git push` literally and would
319
+ # still short-circuit to exit 0.
320
+ if command -v jq >/dev/null 2>&1; then
321
+ PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
322
+ jq_status=$?
323
+ if [ "$jq_status" -ne 0 ]; then
324
+ JQ_PARSE_FAILED=1
325
+ fi
326
+ else
327
+ # 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (the
328
+ # raw JSON payload) when jq was missing, then ran the awk relevance
329
+ # scan over JSON instead of a bare command. A payload containing
330
+ # `git push origin main` came through as e.g.
331
+ # `{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}`
332
+ # → the `^git push` anchor never matched → RELEVANT=0 → silent
333
+ # bypass on every jq-less machine. Fix: treat jq-missing the same
334
+ # as a parse failure — force RELEVANT=1 and let the CLI body decide.
335
+ # The CLI uses native Node JSON parsing so jq is not required for
336
+ # the actual enforcement.
337
+ JQ_PARSE_FAILED=1
338
+ fi
339
+ # Split on shell separators then look for a segment whose head is
340
+ # the configured trigger. The awk here masks chars inside `"..."`
341
+ # and `'...'` spans before splitting — same posture as the CLI's
342
+ # `splitSegments` but coarser (no nested-shell unwrap; the CLI handles
343
+ # that). For relevance-pre-gate purposes the masker is sufficient.
344
+ #
345
+ # IMPORTANT: the env-prefix strip runs on the UNMASKED `seg` (post
346
+ # substring split) so the value's original quote characters are still
347
+ # present. Strip patterns accept quoted (`"..."`, `'...'`) AND
348
+ # unquoted (`\S*`) values so quoted env prefixes don't hide the
349
+ # trigger.
350
+ # Round-4 P2: if jq couldn't parse the payload, skip the awk pre-gate
351
+ # entirely and force RELEVANT=1 so the CLI body decides. The CLI's Zod
352
+ # parser fails closed on schema violations.
353
+ if [ "$JQ_PARSE_FAILED" -eq 1 ]; then
354
+ RELEVANT=1
355
+ elif [ -n "$PROBE" ]; then
356
+ RELEVANT=$(printf '%s' "$PROBE" | awk '
357
+ BEGIN {
358
+ mode = 0 # 0=plain, 1=dquote, 2=squote
359
+ }
360
+ {
361
+ line = $0
362
+ out = ""
363
+ i = 1
364
+ n = length(line)
365
+ while (i <= n) {
366
+ ch = substr(line, i, 1)
367
+ if (mode == 0) {
368
+ if (ch == "\\" && i < n) { out = out " "; i += 2; continue }
369
+ if (ch == "\"") { mode = 1; out = out ch; i++; continue }
370
+ if (ch == "\047") { mode = 2; out = out ch; i++; continue }
371
+ out = out ch
372
+ i++
373
+ } else if (mode == 1) {
374
+ if (ch == "\\" && i < n) { out = out "x"; i += 2; continue }
375
+ if (ch == "\"") { mode = 0; out = out ch; i++; continue }
376
+ out = out "x"
377
+ i++
378
+ } else {
379
+ if (ch == "\047") { mode = 0; out = out ch; i++; continue }
380
+ out = out "x"
381
+ i++
382
+ }
383
+ }
384
+ print out
385
+ }
386
+ ' | tr ';|&' '\n\n\n' | awk -v trigger="^${TRIGGER_RE}([[:space:]]|$)" '
387
+ {
388
+ seg = $0
389
+ # Strip leading whitespace and common prefixes (sudo, exec,
390
+ # time, VAR=value). Coarse — the CLI does this properly.
391
+ sub(/^[[:space:]]+/, "", seg)
392
+ # Iteratively strip env-var assignment prefix VAR=<value> +
393
+ # one-or-more spaces. <value> may be a double-quoted string,
394
+ # a single-quoted string, or a bare token (zero-or-more
395
+ # non-space chars). Quote characters in this comment are
396
+ # intentionally avoided — see round-4 P1 fix: a literal
397
+ # single-quote inside an awk comment inside a single-quoted
398
+ # shell heredoc terminates the bash string and causes
399
+ # "awk: syntax error" at runtime, swallowed by `|| true`.
400
+ # Try quoted shapes first; bare last. Run until no more prefixes
401
+ # match (POSIX-legal stacked-env-prefix support).
402
+ changed = 1
403
+ while (changed) {
404
+ changed = 0
405
+ if (match(seg, /^[A-Za-z_][A-Za-z0-9_]*="[^"]*"[[:space:]]+/)) {
406
+ seg = substr(seg, RLENGTH + 1); changed = 1; continue
407
+ }
408
+ if (match(seg, /^[A-Za-z_][A-Za-z0-9_]*='\''[^'\'']*'\''[[:space:]]+/)) {
409
+ seg = substr(seg, RLENGTH + 1); changed = 1; continue
410
+ }
411
+ if (match(seg, /^[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*[[:space:]]+/)) {
412
+ seg = substr(seg, RLENGTH + 1); changed = 1; continue
413
+ }
414
+ }
415
+ # Iteratively strip keyword prefixes. Round-5 P1 fix: the pre-
416
+ # fix `sub` only stripped ONE keyword, so `time sudo git push`
417
+ # left `sudo git push` and missed the trigger. Loop until no
418
+ # more keyword prefixes match. Coarse — the CLI does this
419
+ # properly with full builtin-tokenization.
420
+ kchanged = 1
421
+ while (kchanged) {
422
+ kchanged = 0
423
+ if (sub(/^(sudo|exec|time|then|do|else|fi|nice|nohup|stdbuf|env)[[:space:]]+/, "", seg)) {
424
+ kchanged = 1
425
+ }
426
+ }
427
+ # Round-5 P1 fix: if the (post-strip) segment head is a known
428
+ # shell wrapper WITH a `-c`-class flag (so there IS a payload
429
+ # to inspect), FORCE relevance and let the CLI walk it. Pre-
430
+ # round-5-P1 `bash -c "git push ..."` had its payload masked
431
+ # by the quote masker → no trigger at head → exit 0 silent
432
+ # bypass. The CLI does full nested-shell unwrapping via
433
+ # mvdan-sh; the shim should not try to compete.
434
+ #
435
+ # Round-6 P2 fix: the round-5 pattern matched ANY segment
436
+ # whose head started with a shell name, including benign
437
+ # bash-script-execution like `bash scripts/setup.sh`. That
438
+ # hit the fail-closed branch on unbuilt installs with "rea
439
+ # CLI is not built", even though the pre-0.34 hook only
440
+ # gated actual git push / git commit commands. Fix: require
441
+ # a -c-class flag (combined form -c, -lc, -lic, -cl, -cli,
442
+ # -li, -il, -ic — the bash WRAP pattern set) OR a separated
443
+ # --c flag, before forcing relevance.
444
+ # IMPORTANT: comments here avoid bare single-quote characters
445
+ # to prevent terminating the surrounding bash single-quoted
446
+ # string at runtime — see round-4 P1 lesson (awk: syntax
447
+ # error swallowed by `|| true`).
448
+ if (match(seg, /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)[[:space:]]+(-([a-z]*c[a-z]*)|--c)([[:space:]]|$)/)) {
449
+ print "1"
450
+ exit
451
+ }
452
+ # Pre-flag variants: bash -l -c PAYLOAD, bash --noprofile -c
453
+ # PAYLOAD. Match shell then one-or-more flags then a -c-class
454
+ # flag. Comments deliberately have no inline quotes (round-4
455
+ # P1 lesson).
456
+ if (match(seg, /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)([[:space:]]+(-[a-z]+|--[a-z]+))+[[:space:]]+(-([a-z]*c[a-z]*)|--c)([[:space:]]|$)/)) {
457
+ print "1"
458
+ exit
459
+ }
460
+ if (seg ~ trigger) {
461
+ print "1"
462
+ exit
463
+ }
464
+ }
465
+ END { print "0" }
466
+ ' | head -1)
467
+ # Fallback for environments without awk (vanishingly rare on the
468
+ # platforms rea supports): default to relevant=1 — over-trigger is
469
+ # safer than under-trigger.
470
+ case "$RELEVANT" in 0|1) ;; *) RELEVANT=1 ;; esac
471
+ fi
472
+ if [ "$RELEVANT" -eq 0 ]; then
473
+ exit 0
474
+ fi
475
+
476
+ # 6. Bypass env-var short-circuit. The bash hook honored the
477
+ # operator-exported `REA_SKIP_LOCAL_REVIEW` (or the policy-renamed
478
+ # var) BEFORE invoking preflight. We mirror that here so an
479
+ # audited bypass works even when the CLI isn't built.
480
+ #
481
+ # Policy-driven var name: read `policy.review.local_review.bypass_env_var`
482
+ # if present; default to `REA_SKIP_LOCAL_REVIEW`. The CLI does its
483
+ # own per-segment inline-bypass evaluation; the shim only checks
484
+ # the operator-exported (process-env) form.
485
+ BYPASS_VAR="REA_SKIP_LOCAL_REVIEW"
486
+ POLICY_VAR=$(_lrg_read_policy review.local_review.bypass_env_var)
487
+ # Only honor POSIX-identifier-shaped names. Junk falls back to default.
488
+ if printf '%s' "$POLICY_VAR" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then
489
+ BYPASS_VAR="$POLICY_VAR"
490
+ fi
491
+ # Read the configured env-var via indirect expansion (bash 3.2 compatible).
492
+ BYPASS_VALUE="${!BYPASS_VAR:-}"
493
+ if [ -n "$BYPASS_VALUE" ]; then
494
+ # Operator-exported bypass — allow. The CLI's per-segment inline
495
+ # bypass and multi-trigger laundering defense run when the CLI is
496
+ # reached; this shim short-circuit only covers the global
497
+ # process-env shape.
498
+ exit 0
499
+ fi
500
+
501
+ # 7. CLI sandbox + forward. REA_ARGV / RESOLVED_CLI_PATH were resolved
502
+ # at section 3 above (they're needed by the policy-get fallback for
503
+ # inline-form support). If they're empty, the CLI isn't built — OR
504
+ # the early sandbox check (round-5 P1) cleared them. Distinguish.
505
+ if [ "${#REA_ARGV[@]}" -eq 0 ]; then
506
+ if [ -n "${SANDBOX_EARLY_FAILURE:-}" ]; then
507
+ printf 'rea: local-review-gate FAILED sandbox check (%s) — refusing.\n' "$SANDBOX_EARLY_FAILURE" >&2
508
+ exit 2
509
+ fi
510
+ printf 'rea: local-review-gate cannot run — the rea CLI is not built.\n' >&2
511
+ printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
512
+ printf 'This shim fails closed because the pre-0.34.0 bash body enforced local-first review without a CLI.\n' >&2
513
+ exit 2
514
+ fi
515
+
516
+ # 8. Realpath sandbox check.
517
+ if ! command -v node >/dev/null 2>&1; then
518
+ printf 'rea: local-review-gate cannot run — `node` is not on PATH.\n' >&2
519
+ printf 'Install Node 22+ (engines.node) to restore local-first review enforcement.\n' >&2
520
+ exit 2
521
+ fi
522
+
523
+ sandbox_check=$(node -e '
524
+ const fs = require("fs");
525
+ const path = require("path");
526
+ const cli = process.argv[1];
527
+ const projDir = process.argv[2];
528
+ let real, realProj;
529
+ try { real = fs.realpathSync(cli); } catch (e) {
530
+ process.stdout.write("bad:realpath"); process.exit(1);
531
+ }
532
+ try { realProj = fs.realpathSync(projDir); } catch (e) {
533
+ process.stdout.write("bad:realpath-proj"); process.exit(1);
534
+ }
535
+ const sep = path.sep;
536
+ const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
537
+ if (!(real === realProj || real.startsWith(projWithSep))) {
538
+ process.stdout.write("bad:cli-escapes-project"); process.exit(1);
539
+ }
540
+ let cur = path.dirname(path.dirname(path.dirname(real)));
541
+ let found = false;
542
+ for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
543
+ const pj = path.join(cur, "package.json");
544
+ if (fs.existsSync(pj)) {
545
+ try {
546
+ const data = JSON.parse(fs.readFileSync(pj, "utf8"));
547
+ if (data && data.name === "@bookedsolid/rea") { found = true; break; }
548
+ } catch (e) { /* keep walking */ }
549
+ }
550
+ cur = path.dirname(cur);
551
+ }
552
+ if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
553
+ process.stdout.write("ok");
554
+ ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
555
+
556
+ if [ "$sandbox_check" != "ok" ]; then
557
+ printf 'rea: local-review-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
558
+ exit 2
559
+ fi
560
+
561
+ # 9. Version-probe.
562
+ probe_out=$("${REA_ARGV[@]}" hook local-review-gate --help 2>&1)
563
+ probe_status=$?
564
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'local-review-gate'; then
565
+ printf 'rea: this shim requires the `rea hook local-review-gate` subcommand (introduced in 0.34.0).\n' >&2
566
+ printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
567
+ printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
568
+ exit 2
569
+ fi
570
+
571
+ # 10. Forward stdin (already captured up-front).
572
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook local-review-gate
573
+ exit $?