@bookedsolid/rea 0.36.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 (37) hide show
  1. package/hooks/_lib/policy-reader.sh +948 -0
  2. package/hooks/_lib/shim-runtime.sh +405 -0
  3. package/hooks/architecture-review-gate.sh +11 -103
  4. package/hooks/attribution-advisory.sh +43 -155
  5. package/hooks/blocked-paths-bash-gate.sh +35 -149
  6. package/hooks/blocked-paths-enforcer.sh +35 -140
  7. package/hooks/changeset-security-gate.sh +26 -119
  8. package/hooks/dangerous-bash-interceptor.sh +46 -170
  9. package/hooks/delegation-advisory.sh +26 -144
  10. package/hooks/delegation-capture.sh +33 -139
  11. package/hooks/dependency-audit-gate.sh +29 -121
  12. package/hooks/env-file-protection.sh +30 -141
  13. package/hooks/local-review-gate.sh +191 -396
  14. package/hooks/pr-issue-link-gate.sh +16 -118
  15. package/hooks/protected-paths-bash-gate.sh +57 -160
  16. package/hooks/secret-scanner.sh +90 -213
  17. package/hooks/security-disclosure-gate.sh +32 -155
  18. package/hooks/settings-protection.sh +56 -179
  19. package/package.json +1 -1
  20. package/templates/_lib_policy-reader.dogfood-staged.sh +948 -0
  21. package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
  22. package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
  23. package/templates/attribution-advisory.dogfood-staged.sh +43 -155
  24. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +35 -149
  25. package/templates/blocked-paths-enforcer.dogfood-staged.sh +35 -140
  26. package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
  27. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
  28. package/templates/delegation-advisory.dogfood-staged.sh +44 -0
  29. package/templates/delegation-capture.dogfood-staged.sh +52 -0
  30. package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
  31. package/templates/env-file-protection.dogfood-staged.sh +30 -141
  32. package/templates/local-review-gate.dogfood-staged.sh +191 -396
  33. package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
  34. package/templates/protected-paths-bash-gate.dogfood-staged.sh +57 -160
  35. package/templates/secret-scanner.dogfood-staged.sh +90 -213
  36. package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
  37. package/templates/settings-protection.dogfood-staged.sh +56 -179
@@ -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,260 +36,173 @@ 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
- # 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.
81
+ # 0.37.0: route policy reads through the unified policy-reader. The
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.
174
88
  #
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
89
+ # Codex round 4 P2 (2026-05-16): local-review-gate fires on EVERY Bash
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).
97
+ # shellcheck source=_lib/policy-reader.sh
98
+ source "$(dirname "$0")/_lib/policy-reader.sh"
99
+
100
+ _LRG_LR_SUBTREE_JSON=""
101
+
102
+ _lrg_load_local_review_subtree() {
103
+ if [ -n "$_LRG_LR_SUBTREE_JSON" ]; then
104
+ return 0
105
+ fi
106
+ local sub
107
+ sub=$(policy_reader_get_subtree_json review.local_review 2>/dev/null)
108
+ if [ -z "$sub" ]; then
109
+ _LRG_LR_SUBTREE_JSON="null"
110
+ else
111
+ _LRG_LR_SUBTREE_JSON="$sub"
112
+ fi
113
+ }
114
+
115
+ _lrg_subtree_leaf() {
116
+ local leaf="$1"
117
+ if [ -z "$_LRG_LR_SUBTREE_JSON" ] || [ "$_LRG_LR_SUBTREE_JSON" = "null" ]; then
118
+ return 1
200
119
  fi
201
- # 2. If we have JSON, ask jq for the leaf.
202
- if [ "$_lrg_subtree_json" != "<none>" ]; then
120
+ if command -v jq >/dev/null 2>&1; then
203
121
  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)
122
+ out=$(printf '%s' "$_LRG_LR_SUBTREE_JSON" | jq -r --arg k "$leaf" '
123
+ .[$k] as $v
124
+ | if $v == null then empty
125
+ elif ($v|type) == "string" or ($v|type) == "number" or ($v|type) == "boolean"
126
+ then $v | tostring
127
+ else empty
128
+ end
129
+ ' 2>/dev/null)
207
130
  if [ -n "$out" ]; then
208
131
  printf '%s' "$out"
209
132
  return 0
210
133
  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
134
+ return 1
215
135
  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
136
+ if command -v python3 >/dev/null 2>&1; then
137
+ local out
138
+ out=$(env -u PYTHONPATH -u PYTHONHOME -u PYTHONSTARTUP \
139
+ PYTHONSAFEPATH=1 python3 -c '
140
+ import sys
141
+ import os
142
+ _cwd = os.getcwd()
143
+ _cwd_real = os.path.realpath(_cwd)
144
+ sys.path[:] = [p for p in sys.path if p not in ("", ".", _cwd, _cwd_real)]
145
+ import json
146
+ try:
147
+ doc = json.loads(sys.argv[1])
148
+ except Exception:
149
+ sys.exit(0)
150
+ leaf = sys.argv[2]
151
+ if isinstance(doc, dict) and leaf in doc:
152
+ v = doc[leaf]
153
+ if isinstance(v, bool):
154
+ sys.stdout.write("true" if v else "false")
155
+ elif isinstance(v, (int, float, str)):
156
+ sys.stdout.write(str(v))
157
+ ' "$_LRG_LR_SUBTREE_JSON" "$leaf" 2>/dev/null)
158
+ if [ -n "$out" ]; then
159
+ printf '%s' "$out"
160
+ return 0
161
+ fi
223
162
  fi
163
+ return 1
164
+ }
165
+
166
+ _lrg_read_policy() {
167
+ local key="$1"
224
168
  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
169
+ review.local_review.*)
170
+ _lrg_load_local_review_subtree
171
+ local leaf="${key##*.}"
172
+ local v
173
+ if v=$(_lrg_subtree_leaf "$leaf"); then
174
+ printf '%s' "$v"
175
+ return 0
176
+ fi
253
177
  ;;
254
178
  esac
179
+ policy_reader_get "$key" 2>/dev/null
255
180
  }
256
181
 
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.
182
+ # 4. Mode-off short-circuit.
260
183
  LOCAL_REVIEW_MODE=$(_lrg_read_policy review.local_review.mode)
261
184
  if [ "$LOCAL_REVIEW_MODE" = "off" ]; then
262
185
  exit 0
263
186
  fi
264
187
 
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.
188
+ # 5. Read refuse_at to scope the relevance pre-gate.
271
189
  REFUSE_AT="push"
272
190
  POLICY_REFUSE=$(_lrg_read_policy review.local_review.refuse_at)
273
191
  case "$POLICY_REFUSE" in push|commit|both) REFUSE_AT="$POLICY_REFUSE" ;; esac
274
- # Build trigger-head alternation based on refuse_at.
275
192
  case "$REFUSE_AT" in
276
193
  push) TRIGGER_RE='git[[:space:]]+push' ;;
277
194
  commit) TRIGGER_RE='git[[:space:]]+commit' ;;
278
195
  both) TRIGGER_RE='git[[:space:]]+(push|commit)' ;;
279
196
  esac
280
197
 
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.
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).
302
203
  RELEVANT=0
303
204
  PROBE=""
304
205
  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
206
  if command -v jq >/dev/null 2>&1; then
321
207
  PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
322
208
  jq_status=$?
@@ -324,32 +210,28 @@ if command -v jq >/dev/null 2>&1; then
324
210
  JQ_PARSE_FAILED=1
325
211
  fi
326
212
  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.
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.
337
217
  JQ_PARSE_FAILED=1
338
218
  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.
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.
344
229
  #
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.
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).
353
235
  if [ "$JQ_PARSE_FAILED" -eq 1 ]; then
354
236
  RELEVANT=1
355
237
  elif [ -n "$PROBE" ]; then
@@ -386,19 +268,14 @@ elif [ -n "$PROBE" ]; then
386
268
  ' | tr ';|&' '\n\n\n' | awk -v trigger="^${TRIGGER_RE}([[:space:]]|$)" '
387
269
  {
388
270
  seg = $0
389
- # Strip leading whitespace and common prefixes (sudo, exec,
390
- # time, VAR=value). Coarse — the CLI does this properly.
391
271
  sub(/^[[:space:]]+/, "", seg)
392
272
  # 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).
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.
402
279
  changed = 1
403
280
  while (changed) {
404
281
  changed = 0
@@ -412,11 +289,6 @@ elif [ -n "$PROBE" ]; then
412
289
  seg = substr(seg, RLENGTH + 1); changed = 1; continue
413
290
  }
414
291
  }
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
292
  kchanged = 1
421
293
  while (kchanged) {
422
294
  kchanged = 0
@@ -424,35 +296,15 @@ elif [ -n "$PROBE" ]; then
424
296
  kchanged = 1
425
297
  }
426
298
  }
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`).
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.
448
304
  if (match(seg, /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)[[:space:]]+(-([a-z]*c[a-z]*)|--c)([[:space:]]|$)/)) {
449
305
  print "1"
450
306
  exit
451
307
  }
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
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:]]|$)/)) {
457
309
  print "1"
458
310
  exit
@@ -464,110 +316,53 @@ elif [ -n "$PROBE" ]; then
464
316
  }
465
317
  END { print "0" }
466
318
  ' | 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
319
  case "$RELEVANT" in 0|1) ;; *) RELEVANT=1 ;; esac
471
320
  fi
472
321
  if [ "$RELEVANT" -eq 0 ]; then
473
322
  exit 0
474
323
  fi
475
324
 
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.
325
+ # 6. Bypass env-var short-circuit. Policy-driven var name; default
326
+ # REA_SKIP_LOCAL_REVIEW. Only honor POSIX-identifier-shaped names.
485
327
  BYPASS_VAR="REA_SKIP_LOCAL_REVIEW"
486
328
  POLICY_VAR=$(_lrg_read_policy review.local_review.bypass_env_var)
487
- # Only honor POSIX-identifier-shaped names. Junk falls back to default.
488
329
  if printf '%s' "$POLICY_VAR" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then
489
330
  BYPASS_VAR="$POLICY_VAR"
490
331
  fi
491
- # Read the configured env-var via indirect expansion (bash 3.2 compatible).
492
332
  BYPASS_VALUE="${!BYPASS_VAR:-}"
493
333
  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
334
  exit 0
499
335
  fi
500
336
 
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.
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.
505
340
  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
341
+ if [ -n "$SANDBOX_EARLY_FAILURE" ]; then
342
+ shim_emit_sandbox_failure_banner "$SANDBOX_EARLY_FAILURE"
508
343
  exit 2
509
344
  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
345
+ shim_emit_cli_missing_banner
513
346
  exit 2
514
347
  fi
515
348
 
516
- # 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.)
517
353
  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
354
+ shim_emit_node_missing_banner
558
355
  exit 2
559
356
  fi
560
357
 
561
- # 9. Version-probe.
562
- 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)
563
360
  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
361
+ if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e "$SHIM_NAME"; then
362
+ shim_emit_version_skew_banner_blocking
568
363
  exit 2
569
364
  fi
570
365
 
571
- # 10. Forward stdin (already captured up-front).
572
- printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook local-review-gate
366
+ # 10. Forward stdin.
367
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook "$SHIM_NAME"
573
368
  exit $?