@bookedsolid/rea 0.37.0 → 0.38.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.
Files changed (35) hide show
  1. package/hooks/_lib/shim-runtime.sh +405 -0
  2. package/hooks/architecture-review-gate.sh +11 -103
  3. package/hooks/attribution-advisory.sh +38 -209
  4. package/hooks/blocked-paths-bash-gate.sh +32 -146
  5. package/hooks/blocked-paths-enforcer.sh +32 -137
  6. package/hooks/changeset-security-gate.sh +26 -119
  7. package/hooks/dangerous-bash-interceptor.sh +46 -170
  8. package/hooks/delegation-advisory.sh +26 -144
  9. package/hooks/delegation-capture.sh +33 -139
  10. package/hooks/dependency-audit-gate.sh +29 -121
  11. package/hooks/env-file-protection.sh +30 -141
  12. package/hooks/local-review-gate.sh +117 -352
  13. package/hooks/pr-issue-link-gate.sh +16 -118
  14. package/hooks/protected-paths-bash-gate.sh +53 -152
  15. package/hooks/secret-scanner.sh +90 -213
  16. package/hooks/security-disclosure-gate.sh +32 -155
  17. package/hooks/settings-protection.sh +56 -176
  18. package/package.json +1 -1
  19. package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
  20. package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
  21. package/templates/attribution-advisory.dogfood-staged.sh +38 -209
  22. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +32 -146
  23. package/templates/blocked-paths-enforcer.dogfood-staged.sh +32 -137
  24. package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
  25. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
  26. package/templates/delegation-advisory.dogfood-staged.sh +44 -0
  27. package/templates/delegation-capture.dogfood-staged.sh +52 -0
  28. package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
  29. package/templates/env-file-protection.dogfood-staged.sh +30 -141
  30. package/templates/local-review-gate.dogfood-staged.sh +117 -352
  31. package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
  32. package/templates/protected-paths-bash-gate.dogfood-staged.sh +53 -152
  33. package/templates/secret-scanner.dogfood-staged.sh +90 -213
  34. package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
  35. package/templates/settings-protection.dogfood-staged.sh +56 -176
@@ -1,57 +1,30 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: local-review-gate.sh
3
3
  # 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
4
+ # 0.38.0+ — uses helpers from `_lib/shim-runtime.sh` (shared
5
+ # CLI-resolution, sandbox, and banners). Cannot use the
6
+ # full `shim_run` orchestrator because the hot-path policy
7
+ # reads need to happen AFTER an early sandbox check (round-5
8
+ # P1) and the relevance scan is policy-driven on
9
+ # `review.local_review.refuse_at` (round-1 P2).
4
10
  #
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.
11
+ # Pre-0.34.0 the gate's full body lived here as bash (460 LOC). The
12
+ # migration moves per-segment trigger detection + preflight call into
13
+ # `src/hooks/local-review-gate/index.ts`. This shim:
12
14
  #
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.
15
+ # 1. HALT check
16
+ # 2. Read stdin
17
+ # 2b. Early default-bypass-env-var short-circuit (round-7 P2)
18
+ # 3. Resolve CLI + EARLY sandbox check (round-5 P1: prevent
19
+ # unsandboxed CLI from running during policy lookup)
20
+ # 3b. Subtree-cached policy reads via `_lib/policy-reader.sh`
21
+ # 4. Mode-off short-circuit
22
+ # 5. Refuse_at + relevance scan
23
+ # 6. Policy-driven bypass env-var short-circuit
24
+ # 7. CLI-missing handling
25
+ # 8. Sandbox check (idempotent re-run; emit banner on failure)
26
+ # 9. Version probe
27
+ # 10. Forward
55
28
 
56
29
  set -uo pipefail
57
30
 
@@ -63,131 +36,67 @@ REA_ROOT=$(rea_root)
63
36
 
64
37
  proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
65
38
 
66
- # 2. Read stdin once. Used by the relevance pre-gate, the bypass
67
- # short-circuit, AND the CLI forward.
39
+ # SHIM_* metadata for shared banner helpers.
40
+ SHIM_NAME="local-review-gate"
41
+ SHIM_INTRODUCED_IN="0.34.0"
42
+ SHIM_FAIL_OPEN=0
43
+ SHIM_ENFORCE_CLI_SHAPE=0
44
+ SHIM_REFUSAL_NOUN="local-first review enforcement"
45
+ SHIM_NODE_MISSING_NOUN="local-first review enforcement"
46
+ SHIM_SKIP_VERSION_PROBE=0
47
+ # shellcheck source=_lib/shim-runtime.sh
48
+ source "$(dirname "$0")/_lib/shim-runtime.sh"
49
+ _shim_apply_defaults
50
+
51
+ # 2. Read stdin once.
68
52
  INPUT=$(cat)
69
53
 
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.
54
+ # 2b. Early default-bypass-env-var short-circuit. We can only check the
55
+ # DEFAULT var name (REA_SKIP_LOCAL_REVIEW) this early because the
56
+ # policy-renamed var requires a policy read. The policy-aware
57
+ # re-check at section 6 still runs for renamed vars when the CLI
58
+ # is reachable.
86
59
  EARLY_BYPASS_VALUE="${REA_SKIP_LOCAL_REVIEW:-}"
87
60
  if [ -n "$EARLY_BYPASS_VALUE" ]; then
88
61
  exit 0
89
62
  fi
90
63
 
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
64
+ # 3. Resolve CLI early (used by policy reader Tier 1 + final forward).
65
+ shim_resolve_cli
105
66
 
106
- # Round-5 P1 fix: sandbox-check the resolved CLI BEFORE any policy-get
67
+ # Round-5 P1 fix: sandbox-check the CLI BEFORE any policy-get
107
68
  # 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.
69
+ # for mode-off / refuse_at reads BEFORE the sandbox guard fired — a
70
+ # symlinked or swapped dist/cli/index.js would execute during policy
71
+ # lookup, defeating the realpath / package.json trust boundary.
72
+ SANDBOX_EARLY_FAILURE=""
118
73
  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)
74
+ sandbox_check_early=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
151
75
  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
76
  SANDBOX_EARLY_FAILURE="$sandbox_check_early"
157
77
  REA_ARGV=()
158
78
  fi
159
79
  fi
160
80
 
161
81
  # 0.37.0: route policy reads through the unified policy-reader. The
162
- # pre-0.37.0 helper here was a hand-rolled dual-tier (CLI subtree
163
- # JSON + per-leaf awk block-form parser). The new helper consolidates
164
- # CLI + python3 + awk into a single 4-tier ladder, so inline-form
165
- # mappings like `local_review: { mode: off, refuse_at: commit }` now
166
- # work even on installs where the CLI is unreachable AND python3 +
167
- # PyYAML are available (the previous bash awk fallback missed inline
168
- # forms entirely — silent no-op on stale-CLI installs).
169
- #
170
- # Behavior preserved: empty stdout → "default applies"; the helper
171
- # returns 0 even when the key is unset, so the existing callers'
172
- # `case` statements work unchanged.
82
+ # pre-0.37.0 helper was a hand-rolled dual-tier (CLI subtree JSON +
83
+ # per-leaf awk block-form parser). The new helper consolidates CLI +
84
+ # python3 + awk into a single 4-tier ladder so inline-form mappings
85
+ # like `local_review: { mode: off, refuse_at: commit }` work on
86
+ # installs where the CLI is unreachable AND python3 + PyYAML are
87
+ # available.
173
88
  #
174
89
  # Codex round 4 P2 (2026-05-16): local-review-gate fires on EVERY Bash
175
- # PreToolUse event and reads three leaves from `review.local_review`
176
- # (mode + refuse_at + bypass_env_var). The unified reader's CLI tier
177
- # spawns a fresh `rea hook policy-get` per leaf, so the hot path went
178
- # from 1 CLI startup (the pre-0.37.0 subtree call) to 4 (version probe
179
- # + 3 leaves). Restore the subtree-cache shape: fetch
180
- # `review.local_review` as JSON once, then extract leaves locally. Falls
181
- # back to per-leaf reads when the subtree call returns null/empty (e.g.
182
- # Tier 3 awk can't serve subtree — that's documented and the per-leaf
183
- # block-form parser handles those cases via the unified reader's
184
- # fall-through ladder).
90
+ # PreToolUse and reads three leaves from `review.local_review`. The
91
+ # unified reader's CLI tier spawns a fresh `rea hook policy-get` per
92
+ # leaf, so the hot path went from 1 CLI startup (pre-0.37.0 subtree
93
+ # call) to 4. We restore the subtree-cache shape: fetch
94
+ # `review.local_review` as JSON once, then extract leaves locally.
95
+ # Falls back to per-leaf reads when the subtree call returns null /
96
+ # empty (e.g. Tier 3 awk can't serve subtree).
185
97
  # shellcheck source=_lib/policy-reader.sh
186
98
  source "$(dirname "$0")/_lib/policy-reader.sh"
187
99
 
188
- # Subtree cache: populated lazily on first read. Empty string means
189
- # "not yet attempted"; "null" means "attempted, key unset"; any other
190
- # value is the JSON object.
191
100
  _LRG_LR_SUBTREE_JSON=""
192
101
 
193
102
  _lrg_load_local_review_subtree() {
@@ -203,18 +112,11 @@ _lrg_load_local_review_subtree() {
203
112
  fi
204
113
  }
205
114
 
206
- # Extract a leaf from the cached subtree JSON. When subtree retrieval
207
- # failed (e.g. Tier 3 awk fallback), or the leaf isn't present in the
208
- # JSON, returns empty + non-zero so the caller can fall back to a
209
- # per-leaf read.
210
115
  _lrg_subtree_leaf() {
211
116
  local leaf="$1"
212
117
  if [ -z "$_LRG_LR_SUBTREE_JSON" ] || [ "$_LRG_LR_SUBTREE_JSON" = "null" ]; then
213
118
  return 1
214
119
  fi
215
- # Try jq first; fall back to a python3 one-liner. Same hardened
216
- # invocation shape as policy-reader.sh's no-jq fallback (env -u +
217
- # PYTHONSAFEPATH + sys.path scrub).
218
120
  if command -v jq >/dev/null 2>&1; then
219
121
  local out
220
122
  out=$(printf '%s' "$_LRG_LR_SUBTREE_JSON" | jq -r --arg k "$leaf" '
@@ -262,13 +164,6 @@ if isinstance(doc, dict) and leaf in doc:
262
164
  }
263
165
 
264
166
  _lrg_read_policy() {
265
- # $1 = dotted key (e.g. `review.local_review.mode`)
266
- #
267
- # For `review.local_review.*` leaves, try the subtree cache first
268
- # (one CLI startup serves all three leaves). Fall back to a per-key
269
- # read for everything else — and for leaves that the subtree cache
270
- # couldn't produce (e.g. Tier 3 awk fallback where subtree mode is
271
- # unsupported).
272
167
  local key="$1"
273
168
  case "$key" in
274
169
  review.local_review.*)
@@ -284,69 +179,30 @@ _lrg_read_policy() {
284
179
  policy_reader_get "$key" 2>/dev/null
285
180
  }
286
181
 
287
- # 4. Mode-off short-circuit. Mirrors the bash hook's
288
- # `policy_get_local_review_mode` check at the top — `off` → silent
289
- # no-op BEFORE any other work.
182
+ # 4. Mode-off short-circuit.
290
183
  LOCAL_REVIEW_MODE=$(_lrg_read_policy review.local_review.mode)
291
184
  if [ "$LOCAL_REVIEW_MODE" = "off" ]; then
292
185
  exit 0
293
186
  fi
294
187
 
295
- # 5. Read `refuse_at` to scope the relevance pre-gate. Under the
296
- # default `refuse_at: push`, a `git commit` segment is NOT refused
297
- # by the CLI — so when the CLI is missing, the shim should let
298
- # `git commit -m "..."` pass without hitting fail-closed. Mirrors
299
- # the bash hook's posture: a non-refused git op does not enter
300
- # the preflight-refuse branch.
188
+ # 5. Read refuse_at to scope the relevance pre-gate.
301
189
  REFUSE_AT="push"
302
190
  POLICY_REFUSE=$(_lrg_read_policy review.local_review.refuse_at)
303
191
  case "$POLICY_REFUSE" in push|commit|both) REFUSE_AT="$POLICY_REFUSE" ;; esac
304
- # Build trigger-head alternation based on refuse_at.
305
192
  case "$REFUSE_AT" in
306
193
  push) TRIGGER_RE='git[[:space:]]+push' ;;
307
194
  commit) TRIGGER_RE='git[[:space:]]+commit' ;;
308
195
  both) TRIGGER_RE='git[[:space:]]+(push|commit)' ;;
309
196
  esac
310
197
 
311
- # Relevance pre-gate. Anchor on the trigger regex at the head of each
312
- # ;/&&/||/| separated segment this matches the CLI's segment-aware
313
- # detector and avoids false-positives on quoted arguments like
314
- # `git commit -m "doc: git push later"`.
315
- #
316
- # The check is approximate (it uses a coarse quote masker that the CLI
317
- # does properly via mvdan-sh) because if it errs on the side of
318
- # relevant→true, the CLI's real segment walker will sort it out. We
319
- # only want to short-circuit confidently-non-relevant cases (where
320
- # there's NO trigger head in any segment) so unbuilt installs don't
321
- # fail closed on benign Bash calls.
322
- #
323
- # 0.34.0 round-2 P1 fix: the env-prefix-strip MUST accept quoted
324
- # values. Pre-fix the strip pattern was
325
- # `[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+`, which silently
326
- # missed shapes like `GIT_SSH_COMMAND="ssh -i ~/.ssh/id" git push`
327
- # because the `[^[:space:]]+` value group stops at the first space
328
- # inside the quotes. We mirror the segments.ts `matchEnvAssignLength`
329
- # helper — accept value shapes `"..."`, `'...'`, `\S*` (zero-or-more
330
- # so bare `FOO= cmd` resolves too). The strip runs ITERATIVELY so
331
- # stacked env prefixes (`A="x" B='y' C=z git push`) all get peeled.
198
+ # 0.34.0 round-4 P2 fix: capture jq exit code separately rather than
199
+ # swallowing with `|| true`. Malformed payload pre-fix empty PROBE →
200
+ # RELEVANT=0 silent bypass. Post-fix: jq parse failure forces
201
+ # RELEVANT=1 so the CLI body decides (Zod fails closed on schema
202
+ # violations).
332
203
  RELEVANT=0
333
204
  PROBE=""
334
205
  JQ_PARSE_FAILED=0
335
- # 0.34.0 round-4 P2 fix: capture jq's exit code SEPARATELY rather than
336
- # swallowing it with `|| true`. Malformed PreToolUse payload (invalid
337
- # JSON, schema mismatch) pre-fix → empty PROBE → RELEVANT=0 fast path
338
- # → silent bypass. Post-fix we distinguish:
339
- # - jq exit 0 + non-empty stdout → use as PROBE (the normal path)
340
- # - jq exit 0 + empty stdout → non-Bash payload / empty cmd, RELEVANT=0
341
- # - jq exit != 0 (parse failure) → JQ_PARSE_FAILED=1, force RELEVANT=1
342
- # so we skip the awk pre-gate and
343
- # forward straight to the CLI body
344
- # which fails closed on malformed
345
- # payloads via Zod. Substring-only
346
- # fallback was insufficient because
347
- # raw JSON often won't contain
348
- # `git push` literally and would
349
- # still short-circuit to exit 0.
350
206
  if command -v jq >/dev/null 2>&1; then
351
207
  PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
352
208
  jq_status=$?
@@ -354,32 +210,28 @@ if command -v jq >/dev/null 2>&1; then
354
210
  JQ_PARSE_FAILED=1
355
211
  fi
356
212
  else
357
- # 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (the
358
- # raw JSON payload) when jq was missing, then ran the awk relevance
359
- # scan over JSON instead of a bare command. A payload containing
360
- # `git push origin main` came through as e.g.
361
- # `{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}`
362
- # → the `^git push` anchor never matched → RELEVANT=0 → silent
363
- # bypass on every jq-less machine. Fix: treat jq-missing the same
364
- # as a parse failure — force RELEVANT=1 and let the CLI body decide.
365
- # The CLI uses native Node JSON parsing so jq is not required for
366
- # the actual enforcement.
213
+ # 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (raw
214
+ # JSON payload) when jq was missing, then ran the awk relevance scan
215
+ # over JSON instead of a bare command. Fix: treat jq-missing the
216
+ # same as a parse failure force RELEVANT=1 and let the CLI decide.
367
217
  JQ_PARSE_FAILED=1
368
218
  fi
369
- # Split on shell separators then look for a segment whose head is
370
- # the configured trigger. The awk here masks chars inside `"..."`
371
- # and `'...'` spans before splitting — same posture as the CLI's
372
- # `splitSegments` but coarser (no nested-shell unwrap; the CLI handles
373
- # that). For relevance-pre-gate purposes the masker is sufficient.
219
+ # Split on shell separators then look for a segment whose head is the
220
+ # configured trigger. The awk here masks chars inside "..." and '...'
221
+ # spans before splitting — same posture as the CLI splitSegments but
222
+ # coarser (no nested-shell unwrap; the CLI handles that). For
223
+ # relevance-pre-gate purposes the masker is sufficient.
224
+ #
225
+ # IMPORTANT: the env-prefix strip runs on the UNMASKED `seg` so the
226
+ # value's original quote characters are still present. Strip patterns
227
+ # accept quoted ("...", '...') AND unquoted (\S*) values so quoted env
228
+ # prefixes don't hide the trigger.
374
229
  #
375
- # IMPORTANT: the env-prefix strip runs on the UNMASKED `seg` (post
376
- # substring split) so the value's original quote characters are still
377
- # present. Strip patterns accept quoted (`"..."`, `'...'`) AND
378
- # unquoted (`\S*`) values so quoted env prefixes don't hide the
379
- # trigger.
380
- # Round-4 P2: if jq couldn't parse the payload, skip the awk pre-gate
381
- # entirely and force RELEVANT=1 so the CLI body decides. The CLI's Zod
382
- # parser fails closed on schema violations.
230
+ # 0.34.0 round-2 P1: env-prefix strip MUST accept quoted values.
231
+ # 0.34.0 round-5 P1: iteratively strip stacked env prefixes AND
232
+ # keyword prefixes (sudo / time / etc).
233
+ # 0.34.0 round-6 P2: only force relevance on shell-wrappers when a
234
+ # -c-class flag is present (so `bash scripts/setup.sh` doesn't trip).
383
235
  if [ "$JQ_PARSE_FAILED" -eq 1 ]; then
384
236
  RELEVANT=1
385
237
  elif [ -n "$PROBE" ]; then
@@ -416,19 +268,14 @@ elif [ -n "$PROBE" ]; then
416
268
  ' | tr ';|&' '\n\n\n' | awk -v trigger="^${TRIGGER_RE}([[:space:]]|$)" '
417
269
  {
418
270
  seg = $0
419
- # Strip leading whitespace and common prefixes (sudo, exec,
420
- # time, VAR=value). Coarse — the CLI does this properly.
421
271
  sub(/^[[:space:]]+/, "", seg)
422
272
  # Iteratively strip env-var assignment prefix VAR=<value> +
423
- # one-or-more spaces. <value> may be a double-quoted string,
424
- # a single-quoted string, or a bare token (zero-or-more
425
- # non-space chars). Quote characters in this comment are
426
- # intentionally avoided — see round-4 P1 fix: a literal
427
- # single-quote inside an awk comment inside a single-quoted
428
- # shell heredoc terminates the bash string and causes
429
- # "awk: syntax error" at runtime, swallowed by `|| true`.
430
- # Try quoted shapes first; bare last. Run until no more prefixes
431
- # match (POSIX-legal stacked-env-prefix support).
273
+ # one-or-more spaces. <value> may be a double-quoted string, a
274
+ # single-quoted string, or a bare token (zero-or-more non-space
275
+ # chars). Quote characters in this comment are intentionally
276
+ # avoided — see round-4 P1 fix: a literal single-quote inside an
277
+ # awk comment inside a single-quoted shell heredoc terminates
278
+ # the bash string and causes "awk: syntax error" at runtime.
432
279
  changed = 1
433
280
  while (changed) {
434
281
  changed = 0
@@ -442,11 +289,6 @@ elif [ -n "$PROBE" ]; then
442
289
  seg = substr(seg, RLENGTH + 1); changed = 1; continue
443
290
  }
444
291
  }
445
- # Iteratively strip keyword prefixes. Round-5 P1 fix: the pre-
446
- # fix `sub` only stripped ONE keyword, so `time sudo git push`
447
- # left `sudo git push` and missed the trigger. Loop until no
448
- # more keyword prefixes match. Coarse — the CLI does this
449
- # properly with full builtin-tokenization.
450
292
  kchanged = 1
451
293
  while (kchanged) {
452
294
  kchanged = 0
@@ -454,35 +296,15 @@ elif [ -n "$PROBE" ]; then
454
296
  kchanged = 1
455
297
  }
456
298
  }
457
- # Round-5 P1 fix: if the (post-strip) segment head is a known
458
- # shell wrapper WITH a `-c`-class flag (so there IS a payload
459
- # to inspect), FORCE relevance and let the CLI walk it. Pre-
460
- # round-5-P1 `bash -c "git push ..."` had its payload masked
461
- # by the quote masker no trigger at head → exit 0 silent
462
- # bypass. The CLI does full nested-shell unwrapping via
463
- # mvdan-sh; the shim should not try to compete.
464
- #
465
- # Round-6 P2 fix: the round-5 pattern matched ANY segment
466
- # whose head started with a shell name, including benign
467
- # bash-script-execution like `bash scripts/setup.sh`. That
468
- # hit the fail-closed branch on unbuilt installs with "rea
469
- # CLI is not built", even though the pre-0.34 hook only
470
- # gated actual git push / git commit commands. Fix: require
471
- # a -c-class flag (combined form -c, -lc, -lic, -cl, -cli,
472
- # -li, -il, -ic — the bash WRAP pattern set) OR a separated
473
- # --c flag, before forcing relevance.
474
- # IMPORTANT: comments here avoid bare single-quote characters
475
- # to prevent terminating the surrounding bash single-quoted
476
- # string at runtime — see round-4 P1 lesson (awk: syntax
477
- # error swallowed by `|| true`).
299
+ # Round-5 P1 + round-6 P2: if the head is a shell wrapper WITH a
300
+ # -c-class flag, FORCE relevance and let the CLI walk the payload.
301
+ # Comments here avoid bare single-quote characters to prevent
302
+ # terminating the surrounding bash single-quoted string at
303
+ # runtime see round-4 P1 lesson.
478
304
  if (match(seg, /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)[[:space:]]+(-([a-z]*c[a-z]*)|--c)([[:space:]]|$)/)) {
479
305
  print "1"
480
306
  exit
481
307
  }
482
- # Pre-flag variants: bash -l -c PAYLOAD, bash --noprofile -c
483
- # PAYLOAD. Match shell then one-or-more flags then a -c-class
484
- # flag. Comments deliberately have no inline quotes (round-4
485
- # P1 lesson).
486
308
  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:]]|$)/)) {
487
309
  print "1"
488
310
  exit
@@ -494,110 +316,53 @@ elif [ -n "$PROBE" ]; then
494
316
  }
495
317
  END { print "0" }
496
318
  ' | head -1)
497
- # Fallback for environments without awk (vanishingly rare on the
498
- # platforms rea supports): default to relevant=1 — over-trigger is
499
- # safer than under-trigger.
500
319
  case "$RELEVANT" in 0|1) ;; *) RELEVANT=1 ;; esac
501
320
  fi
502
321
  if [ "$RELEVANT" -eq 0 ]; then
503
322
  exit 0
504
323
  fi
505
324
 
506
- # 6. Bypass env-var short-circuit. The bash hook honored the
507
- # operator-exported `REA_SKIP_LOCAL_REVIEW` (or the policy-renamed
508
- # var) BEFORE invoking preflight. We mirror that here so an
509
- # audited bypass works even when the CLI isn't built.
510
- #
511
- # Policy-driven var name: read `policy.review.local_review.bypass_env_var`
512
- # if present; default to `REA_SKIP_LOCAL_REVIEW`. The CLI does its
513
- # own per-segment inline-bypass evaluation; the shim only checks
514
- # the operator-exported (process-env) form.
325
+ # 6. Bypass env-var short-circuit. Policy-driven var name; default
326
+ # REA_SKIP_LOCAL_REVIEW. Only honor POSIX-identifier-shaped names.
515
327
  BYPASS_VAR="REA_SKIP_LOCAL_REVIEW"
516
328
  POLICY_VAR=$(_lrg_read_policy review.local_review.bypass_env_var)
517
- # Only honor POSIX-identifier-shaped names. Junk falls back to default.
518
329
  if printf '%s' "$POLICY_VAR" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then
519
330
  BYPASS_VAR="$POLICY_VAR"
520
331
  fi
521
- # Read the configured env-var via indirect expansion (bash 3.2 compatible).
522
332
  BYPASS_VALUE="${!BYPASS_VAR:-}"
523
333
  if [ -n "$BYPASS_VALUE" ]; then
524
- # Operator-exported bypass — allow. The CLI's per-segment inline
525
- # bypass and multi-trigger laundering defense run when the CLI is
526
- # reached; this shim short-circuit only covers the global
527
- # process-env shape.
528
334
  exit 0
529
335
  fi
530
336
 
531
- # 7. CLI sandbox + forward. REA_ARGV / RESOLVED_CLI_PATH were resolved
532
- # at section 3 above (they're needed by the policy-get fallback for
533
- # inline-form support). If they're empty, the CLI isn't built — OR
534
- # the early sandbox check (round-5 P1) cleared them. Distinguish.
337
+ # 7. CLI required. If REA_ARGV is empty either (a) the CLI wasn't
338
+ # built/installed, OR (b) the early sandbox check cleared it.
339
+ # Distinguish.
535
340
  if [ "${#REA_ARGV[@]}" -eq 0 ]; then
536
- if [ -n "${SANDBOX_EARLY_FAILURE:-}" ]; then
537
- printf 'rea: local-review-gate FAILED sandbox check (%s) — refusing.\n' "$SANDBOX_EARLY_FAILURE" >&2
341
+ if [ -n "$SANDBOX_EARLY_FAILURE" ]; then
342
+ shim_emit_sandbox_failure_banner "$SANDBOX_EARLY_FAILURE"
538
343
  exit 2
539
344
  fi
540
- printf 'rea: local-review-gate cannot run — the rea CLI is not built.\n' >&2
541
- printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
542
- printf 'This shim fails closed because the pre-0.34.0 bash body enforced local-first review without a CLI.\n' >&2
345
+ shim_emit_cli_missing_banner
543
346
  exit 2
544
347
  fi
545
348
 
546
- # 8. Realpath sandbox check.
349
+ # 8. (Redundant on the success path — the early sandbox already passed
350
+ # and cleared REA_ARGV on failure — but we re-emit the node-missing
351
+ # banner explicitly because node could have disappeared between
352
+ # section 3 and now in pathological setups.)
547
353
  if ! command -v node >/dev/null 2>&1; then
548
- printf 'rea: local-review-gate cannot run — `node` is not on PATH.\n' >&2
549
- printf 'Install Node 22+ (engines.node) to restore local-first review enforcement.\n' >&2
550
- exit 2
551
- fi
552
-
553
- sandbox_check=$(node -e '
554
- const fs = require("fs");
555
- const path = require("path");
556
- const cli = process.argv[1];
557
- const projDir = process.argv[2];
558
- let real, realProj;
559
- try { real = fs.realpathSync(cli); } catch (e) {
560
- process.stdout.write("bad:realpath"); process.exit(1);
561
- }
562
- try { realProj = fs.realpathSync(projDir); } catch (e) {
563
- process.stdout.write("bad:realpath-proj"); process.exit(1);
564
- }
565
- const sep = path.sep;
566
- const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
567
- if (!(real === realProj || real.startsWith(projWithSep))) {
568
- process.stdout.write("bad:cli-escapes-project"); process.exit(1);
569
- }
570
- let cur = path.dirname(path.dirname(path.dirname(real)));
571
- let found = false;
572
- for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
573
- const pj = path.join(cur, "package.json");
574
- if (fs.existsSync(pj)) {
575
- try {
576
- const data = JSON.parse(fs.readFileSync(pj, "utf8"));
577
- if (data && data.name === "@bookedsolid/rea") { found = true; break; }
578
- } catch (e) { /* keep walking */ }
579
- }
580
- cur = path.dirname(cur);
581
- }
582
- if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
583
- process.stdout.write("ok");
584
- ' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
585
-
586
- if [ "$sandbox_check" != "ok" ]; then
587
- printf 'rea: local-review-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
354
+ shim_emit_node_missing_banner
588
355
  exit 2
589
356
  fi
590
357
 
591
- # 9. Version-probe.
592
- probe_out=$("${REA_ARGV[@]}" hook local-review-gate --help 2>&1)
358
+ # 9. Version probe.
359
+ probe_out=$("${REA_ARGV[@]}" hook "$SHIM_NAME" --help 2>&1)
593
360
  probe_status=$?
594
- if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'local-review-gate'; then
595
- printf 'rea: this shim requires the `rea hook local-review-gate` subcommand (introduced in 0.34.0).\n' >&2
596
- printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
597
- printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
361
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e "$SHIM_NAME"; then
362
+ shim_emit_version_skew_banner_blocking
598
363
  exit 2
599
364
  fi
600
365
 
601
- # 10. Forward stdin (already captured up-front).
602
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook local-review-gate
366
+ # 10. Forward stdin.
367
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook "$SHIM_NAME"
603
368
  exit $?