@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.
@@ -1,460 +1,573 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: local-review-gate.sh
3
- # 0.26.0+ — forceful local-first delegation enforcement.
3
+ # 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
4
4
  #
5
- # Fires BEFORE every Bash tool call. Detects `git push` (and optionally
6
- # `git commit` per policy) and refuses the command unless a recent
7
- # `rea.local_review` audit entry covers HEAD.
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.
8
12
  #
9
- # This is the AGENT-SPECIFIC enforcement layer — Claude Code's Bash
10
- # tool fires PreToolUse hooks BEFORE the command runs, so an agent
11
- # trying `git push` is stopped HERE, before husky even sees it. Husky
12
- # is the second layer (terminal users + CI), `rea preflight` is the
13
- # workhorse both layers call.
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.
14
16
  #
15
- # The forceful aspect is exactly what CTO directive 2026-05-05 asked
16
- # for: "an agent driving rea via Bash tool literally cannot push
17
- # without first creating a `rea.local_review` audit entry, OR
18
- # explicitly invoking the override, OR having the policy set to `off`
19
- # for the team."
17
+ # # Shim short-circuits (codex round-1 P1+P2 fixes)
20
18
  #
21
- # Off-switch (FIRST-class concern): `policy.review.local_review.mode: off`
22
- # the gate becomes a silent no-op. Teams without codex/claude opt out
23
- # cleanly via policy.
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.
24
30
  #
25
- # Per-invocation override: REA_SKIP_LOCAL_REVIEW="<reason>" — the gate
26
- # allows the command and `rea preflight` audits the bypass.
31
+ # # CLI-resolution trust boundary
27
32
  #
28
- # Exit codes:
29
- # 0 = allow (mode=off, override set, recent review found, non-git command)
30
- # 2 = refuse (no recent review covering HEAD)
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.
31
55
 
32
56
  set -uo pipefail
33
57
 
34
- # Source shared command segmenter — same parser the dangerous-bash and
35
- # protected-paths hooks use. Lets us detect `git push`/`git commit` even
36
- # when nested inside `bash -c "..."`, behind env-var prefixes, or chained
37
- # with `&&` / `;`.
38
- # shellcheck source=_lib/cmd-segments.sh
39
- source "$(dirname "$0")/_lib/cmd-segments.sh"
40
-
41
- # 1. Read stdin (Claude Code hook payload).
42
- INPUT=$(cat)
43
-
44
- # 2. Dependency check.
45
- if ! command -v jq >/dev/null 2>&1; then
46
- printf 'REA ERROR: jq is required but not installed.\n' >&2
47
- exit 2
48
- fi
49
-
50
- # 3. HALT check (kill-switch wins over everything).
58
+ # 1. HALT check.
51
59
  # shellcheck source=_lib/halt-check.sh
52
60
  source "$(dirname "$0")/_lib/halt-check.sh"
53
61
  check_halt
54
62
  REA_ROOT=$(rea_root)
55
63
 
56
- # 4. Source policy reader (needed to read mode + refuse_at + bypass_env_var).
57
- # shellcheck source=_lib/policy-read.sh
58
- source "$(dirname "$0")/_lib/policy-read.sh"
64
+ proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
59
65
 
60
- # 5. Off-switch silent no-op when policy says so.
61
- LOCAL_REVIEW_MODE=$(policy_get_local_review_mode)
62
- if [[ "$LOCAL_REVIEW_MODE" == "off" ]]; then
63
- exit 0
64
- fi
66
+ # 2. Read stdin once. Used by the relevance pre-gate, the bypass
67
+ # short-circuit, AND the CLI forward.
68
+ INPUT=$(cat)
65
69
 
66
- # 6. Parse `tool_input.command` from the hook payload.
67
- CMD=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
68
- if [[ -z "$CMD" ]]; then
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
69
88
  exit 0
70
89
  fi
71
90
 
72
- # 7. Determine which git ops to refuse from policy.review.local_review.refuse_at
73
- # (default 'push').
74
- REFUSE_AT=$(policy_get_local_review_refuse_at)
75
- [[ -z "$REFUSE_AT" ]] && REFUSE_AT='push'
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
76
105
 
77
- REFUSE_PUSH=0
78
- REFUSE_COMMIT=0
79
- case "$REFUSE_AT" in
80
- push) REFUSE_PUSH=1 ;;
81
- commit) REFUSE_COMMIT=1 ;;
82
- both) REFUSE_PUSH=1; REFUSE_COMMIT=1 ;;
83
- *) REFUSE_PUSH=1 ;; # Unknown value falls back to safest default.
84
- esac
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
85
160
 
86
- # 8. Detect git push / git commit in any segment of the command.
87
- #
88
- # We use `any_segment_starts_with` so:
89
- # - `git push origin main` → matches push
90
- # - `git commit -m "msg"` → matches commit
91
- # - `cd /tmp && git push` → matches push (segment after &&)
92
- # - `echo "git push later"` → does NOT match (echo, not git)
93
- # - `git log --oneline | git push` → matches push (last segment)
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".
94
167
  #
95
- # We don't try to match `git commit --amend` separately an amend
96
- # rewrites HEAD, so it's the same coverage problem as a fresh commit.
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.
97
174
  #
98
- # 0.26.0 codex round-23 P2 fix: `any_segment_starts_with` strips env-var
99
- # prefixes via `_rea_strip_prefix`, whose regex `^NAME=[^[:space:]]+[[:space:]]+`
100
- # stops at the first space inside a quoted value. For
101
- # `REA_SKIP_LOCAL_REVIEW="urgent fix" git push origin main` the stripper
102
- # bails halfway and the segment never starts with `git`, so the original
103
- # detector returned false → NEEDS_PREFLIGHT=0 → hook exits 0 BEFORE the
104
- # bypass-detection block ever ran (broke the documented "agent literally
105
- # cannot push without an audit entry" guarantee).
106
- #
107
- # Fix: add an `any_segment_raw_matches` fallback whose pattern requires
108
- # one or more env-var assignments (with quoted-value support) BEFORE the
109
- # `git push`/`git commit` token. This anchors strictly on shapes the
110
- # stripper would have eaten if values were unquoted, so it cannot
111
- # false-positive on `echo "git push later"` (segment doesn't start with
112
- # `NAME=...`) or on a quoted-mention inside a body.
113
- NEEDS_PREFLIGHT=0
114
- GIT_OP_LABEL=''
115
- # 0.26.0 round-25 P1-B fix: capture EVERY trigger segment, not just the
116
- # first. Pre-fix `find_first_segment_starting_with` returned only the
117
- # first matching segment; if a multi-push command contained two pushes
118
- # (e.g. `BYPASS=fake git push fake-remote --dry-run; git push origin main`),
119
- # the bypass on segment 1 was honored globally and segment 2 (the real
120
- # push to origin/main) went through ungated. Round-25 fix: collect every
121
- # trigger segment into a newline-delimited list, then in step 9b validate
122
- # each one independently. Bypass succeeds only if EVERY trigger segment
123
- # carries its own bypass (process-env or inline). Any trigger without a
124
- # bypass forces preflight invocation.
125
- #
126
- # Newline-delimited; empty when NEEDS_PREFLIGHT=0.
127
- TRIGGER_SEGMENTS=''
128
-
129
- # Raw-fallback regex shared between push and commit detection — anchors
130
- # `^(NAME=value...)+git[[:space:]]+(push|commit)` at segment start. The
131
- # prefix-stripper bails on quoted-value-with-spaces, so this fallback is
132
- # the path that catches `REA_SKIP="urgent fix" git push`.
133
- #
134
- # 0.26.0 round-25 P2-A fix: extend the value-shape alternation to accept
135
- # ANSI-C form `$'...'` (literal `$` followed by single-quoted body). Pre-
136
- # fix `FOO=$'a b' git push` matched no shape — `_REA_RAW_INLINE_RE_PUSH`
137
- # failed AND `_rea_strip_prefix` bailed — so detection silently dropped
138
- # and the gate exited 0 BEFORE the bypass-detection block, defeating the
139
- # documented "agent literally cannot push without an audit entry"
140
- # guarantee under `refuse_at: commit/both` (ANSI-C form is rare for
141
- # commits but covered for symmetry).
142
- _REA_RAW_INLINE_RE_PUSH='^([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'"'"'[^'"'"']*'"'"'|\$'"'"'[^'"'"']*'"'"'|[^[:space:]]+)[[:space:]]+)+git[[:space:]]+push([[:space:]]|$)'
143
- _REA_RAW_INLINE_RE_COMMIT='^([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'"'"'[^'"'"']*'"'"'|\$'"'"'[^'"'"']*'"'"'|[^[:space:]]+)[[:space:]]+)+git[[:space:]]+commit([[:space:]]|$)'
144
-
145
- # Helper: append a segment list to TRIGGER_SEGMENTS (newline-delimited),
146
- # preserving order and skipping empties.
147
- _rea_append_triggers() {
148
- local list="$1"
149
- if [[ -z "$list" ]]; then
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.
150
214
  return 0
151
215
  fi
152
- if [[ -z "$TRIGGER_SEGMENTS" ]]; then
153
- TRIGGER_SEGMENTS="$list"
154
- else
155
- TRIGGER_SEGMENTS="${TRIGGER_SEGMENTS}"$'\n'"${list}"
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
156
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
157
255
  }
158
256
 
159
- if [[ $REFUSE_PUSH -eq 1 ]]; then
160
- # Sweep ALL push trigger segments. A multi-push command must validate
161
- # bypass on EACH trigger; first-only capture leaks the laundering class.
162
- _push_segs_stripped=$(find_all_segments_starting_with "$CMD" 'git[[:space:]]+push([[:space:]]|$)' || true)
163
- if [[ -n "$_push_segs_stripped" ]]; then
164
- NEEDS_PREFLIGHT=1
165
- GIT_OP_LABEL='git push'
166
- _rea_append_triggers "$_push_segs_stripped"
167
- fi
168
- # ALSO sweep raw-form push trigger segments (env-prefix shapes the
169
- # stripper bails on). Combined with the stripped sweep this gives full
170
- # coverage. Note: a segment matched by the stripped sweep may ALSO
171
- # match the raw sweep — that's fine, we de-dupe in the bypass loop.
172
- _push_segs_raw=$(find_all_segments_raw_matches "$CMD" "$_REA_RAW_INLINE_RE_PUSH" || true)
173
- if [[ -n "$_push_segs_raw" ]]; then
174
- NEEDS_PREFLIGHT=1
175
- GIT_OP_LABEL='git push'
176
- _rea_append_triggers "$_push_segs_raw"
177
- fi
178
- fi
179
-
180
- if [[ $REFUSE_COMMIT -eq 1 ]]; then
181
- # `git commit` alone (interactive editor) is also covered — once committed,
182
- # HEAD moves and any subsequent push would refuse anyway. Catching it here
183
- # prevents the agent from doing N commits and only discovering the gate
184
- # at push time.
185
- _commit_segs_stripped=$(find_all_segments_starting_with "$CMD" 'git[[:space:]]+commit([[:space:]]|$)' || true)
186
- if [[ -n "$_commit_segs_stripped" ]]; then
187
- NEEDS_PREFLIGHT=1
188
- [[ -z "$GIT_OP_LABEL" ]] && GIT_OP_LABEL='git commit'
189
- _rea_append_triggers "$_commit_segs_stripped"
190
- fi
191
- _commit_segs_raw=$(find_all_segments_raw_matches "$CMD" "$_REA_RAW_INLINE_RE_COMMIT" || true)
192
- if [[ -n "$_commit_segs_raw" ]]; then
193
- NEEDS_PREFLIGHT=1
194
- [[ -z "$GIT_OP_LABEL" ]] && GIT_OP_LABEL='git commit'
195
- _rea_append_triggers "$_commit_segs_raw"
196
- fi
197
- fi
198
-
199
- if [[ $NEEDS_PREFLIGHT -eq 0 ]]; then
200
- # Not a git push or git commit — let it through.
201
- if [[ "${REA_LOCAL_REVIEW_DEBUG_TRACE:-}" == "1" ]]; then
202
- printf 'rea-local-review-trace: detect=none\n' >&2
203
- fi
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
204
262
  exit 0
205
263
  fi
206
264
 
207
- # 9. Per-invocation override env-var. Default REA_SKIP_LOCAL_REVIEW; the
208
- # policy can rename the var (e.g. for organizations that want a
209
- # bespoke audit signature). When set with a non-empty value the gate
210
- # allows the command `rea preflight` itself will audit the bypass
211
- # when invoked downstream.
212
- BYPASS_VAR=$(policy_get_local_review_bypass_env_var)
213
- [[ -z "$BYPASS_VAR" ]] && BYPASS_VAR='REA_SKIP_LOCAL_REVIEW'
214
-
215
- # 9a. Read the configured env-var from the hook's PROCESS env (indirect
216
- # expansion, bash 3.2 compatible). This catches the case where the
217
- # operator exported the var BEFORE invoking Claude Code.
218
- BYPASS_VALUE="${!BYPASS_VAR:-}"
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
219
280
 
220
- # 9b. Detect inline `VAR=value [VAR=value...] git ...` assignment for
221
- # EACH trigger segment. POSIX shells parse `VAR=value cmd` as a
222
- # single-call env override the variable lives in the spawned cmd's
223
- # env only, never in the hook's process env. ${!BYPASS_VAR} therefore
224
- # returns empty for the override form
225
- # `REA_SKIP_LOCAL_REVIEW="reason" git push` and the gate would
226
- # silently refuse a documented escape hatch. Detect the inline
227
- # assignment so the hook honors it.
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"`.
228
285
  #
229
- # 0.26.0 round-25 P1-B fix: pre-fix the gate captured only the FIRST
230
- # trigger segment and validated bypass against it. Multi-push
231
- # laundering PoCs:
232
- # BYPASS=fake git push fake-remote --dry-run; git push origin main
233
- # bypass on segment 1 honored, segment 2 (real push) ungated.
234
- # Round-25 fix: iterate over EVERY trigger segment in TRIGGER_SEGMENTS.
235
- # Bypass succeeds globally only if EVERY trigger segment carries its
236
- # own bypass (process-env covers all uniformly; otherwise each
237
- # trigger segment must have an inline bypass). Any trigger segment
238
- # without bypass forces preflight invocation.
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.
239
292
  #
240
- # Empty values MUST NOT bypass (REA_SKIP_LOCAL_REVIEW="" must refuse,
241
- # same as missing). The value-capture group requires at least one
242
- # non-quote / non-whitespace char inside whatever quoting form was
243
- # used; explicit length-check after match also enforces non-empty.
244
-
245
- # Validate bypass_env_var is a POSIX env-var name. If the policy returns
246
- # junk (regex metachars, empty), skip inline detection (the gate then
247
- # requires preflight unless process-env BYPASS_VALUE is set).
248
- _BYPASS_VAR_VALID=0
249
- if [[ "$BYPASS_VAR" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
250
- _BYPASS_VAR_VALID=1
251
- fi
252
-
253
- # Three accepted value shapes for inline bypass:
254
- # VAR=word (no quotes; value = chars up to whitespace)
255
- # VAR="quoted" (double-quoted; value between the quotes)
256
- # VAR='quoted' (single-quoted; value between the quotes)
257
- # (ANSI-C `VAR=$'a b'` is also recognized via the prefix-stripper in
258
- # round-25 P2-A, but bypass detection still anchors on the conventional
259
- # three quote forms ANSI-C as a bypass value is not a documented
260
- # escape hatch, only as an env-prefix shape.)
261
- # The trailing `git` anchor (with optional intervening env assignments)
262
- # prevents echo / commit-message false-positives.
263
- _INLINE_TAIL_RE='([[:space:]]+([A-Za-z_][A-Za-z0-9_]*=([^[:space:]"'"'"']*|"[^"]*"|'"'"'[^'"'"']*'"'"')[[:space:]]+)*git([[:space:]]|$))'
264
-
265
- # Round-30 F1 sibling-sweep: allow ZERO-or-more LEADING env-var prefixes
266
- # at segment start before the bypass var. POSIX-legal shapes like
267
- # `GIT_TRACE=1 REA_SKIP_LOCAL_REVIEW="reason" git push` were rejected by
268
- # the round-27 F1 anchor tightening (`^[[:space:]]*${BYPASS_VAR}=`).
269
- # This sub-pattern matches the same env-prefix shapes as
270
- # `_REA_RAW_INLINE_RE_PUSH` so the comment-tail safety property
271
- # round-27 F1 added is preserved (comments don't start at segment
272
- # start).
273
- _INLINE_LEAD_PREFIX_RE='^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'"'"'[^'"'"']*'"'"'|\$'"'"'[^'"'"']*'"'"'|[^[:space:]]+)[[:space:]]+)*'
274
-
275
- # Per-segment bypass evaluator. Echoes the inline bypass value (if any)
276
- # on stdout for the supplied segment. Empty stdout means no inline bypass
277
- # was detected for that segment.
278
- _rea_evaluate_inline_bypass() {
279
- local seg="$1"
280
- if [[ $_BYPASS_VAR_VALID -eq 0 || -z "$seg" ]]; then
281
- return 0
282
- fi
283
- local masked
284
- masked=$(quote_masked_cmd "$seg")
285
- # Round-27 F1 fix: anchor at SEGMENT START (post-mask, post-strip).
286
- # Pre-round-27 the alternation `(^|[[:space:]])` allowed the bypass
287
- # shape to appear anywhere in the segment — including inside a `#`
288
- # shell-comment tail. PoC: `git push origin main # see PR —
289
- # REA_SKIP_LOCAL_REVIEW=fake git push`. The `# REA_SKIP_LOCAL_REVIEW=fake`
290
- # portion was whitespace-prefixed and matched the unquoted alternative,
291
- # yielding val=fake and authorizing the real `git push origin main`.
292
- #
293
- # Round-27 F1 anchored at `^[[:space:]]*` — segment start after leading
294
- # whitespace. Comment tails are not segment start (they sit AFTER a
295
- # `git push` or other primary command), so the anchor refuses them.
296
- # Round-30 F1 sibling-sweep extends the anchor to also accept leading
297
- # env-var prefix shapes (`GIT_TRACE=1 BAR=baz REA_SKIP=...`) since
298
- # those ALSO sit at segment start by construction. Comment-tail safety
299
- # is preserved because `#` is not part of the env-prefix grammar.
300
- local val=""
301
- # _INLINE_LEAD_PREFIX_RE adds 2 capture groups (outer iteration body +
302
- # inner value-shape). The bypass value capture is the 3rd group:
303
- # BASH_REMATCH[3].
304
- if [[ "$masked" =~ ${_INLINE_LEAD_PREFIX_RE}${BYPASS_VAR}=\"([^\"]*)\"${_INLINE_TAIL_RE} ]]; then
305
- val="${BASH_REMATCH[3]}"
306
- elif [[ "$masked" =~ ${_INLINE_LEAD_PREFIX_RE}${BYPASS_VAR}=\'([^\']*)\'${_INLINE_TAIL_RE} ]]; then
307
- val="${BASH_REMATCH[3]}"
308
- elif [[ "$masked" =~ ${_INLINE_LEAD_PREFIX_RE}${BYPASS_VAR}=([^[:space:]\"\']+)${_INLINE_TAIL_RE} ]]; then
309
- val="${BASH_REMATCH[3]}"
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
310
325
  fi
311
- # Non-empty value only — empty string from any of the three regexes
312
- # (e.g. VAR="") MUST NOT bypass.
313
- if [[ -n "$val" ]]; then
314
- printf '%s' "$val"
315
- fi
316
- }
317
-
318
- # Round-25 P1-B sweep: every trigger segment must independently authorize
319
- # the bypass. Process-env is global (a single non-empty value covers all
320
- # trigger segments); inline is per-segment.
321
- ALL_BYPASSED=1
322
- INLINE_BYPASS_VALUE=""
323
- ANY_INLINE_VALUE=""
324
- # Track first-failed segment for refusal trace (debug only).
325
- FIRST_UNCOVERED_SEGMENT=""
326
-
327
- # When the operator's process env carries a non-empty bypass, that single
328
- # value covers every trigger segment uniformly — process-env is a
329
- # session-wide override, not a per-segment one. Skip the per-segment
330
- # inline scan entirely in that case.
331
- if [[ -n "$BYPASS_VALUE" ]]; then
332
- ALL_BYPASSED=1
333
326
  else
334
- # Iterate trigger segments via process-substitution to preserve the
335
- # newline-delimited list. Empty/duplicate entries are silently skipped.
336
- _seen_segments=""
337
- while IFS= read -r _seg; do
338
- [[ -z "$_seg" ]] && continue
339
- # De-dupe: a segment matched by both the stripped and raw sweeps
340
- # appears twice. Compare against a delimited concatenation of seen
341
- # segments to avoid re-evaluating the same one.
342
- if [[ "$_seen_segments" == *$'\x1f'"$_seg"$'\x1f'* ]]; then
343
- continue
344
- fi
345
- _seen_segments="${_seen_segments}"$'\x1f'"${_seg}"$'\x1f'
346
- _seg_inline=$(_rea_evaluate_inline_bypass "$_seg")
347
- if [[ -z "$_seg_inline" ]]; then
348
- ALL_BYPASSED=0
349
- [[ -z "$FIRST_UNCOVERED_SEGMENT" ]] && FIRST_UNCOVERED_SEGMENT="$_seg"
350
- # Don't break — keep scanning so trace can report the count below.
351
- else
352
- # Capture the FIRST observed inline bypass value for the trace
353
- # message (so legitimate single-trigger flows still report
354
- # `reason=...`). Not load-bearing for the decision itself — the
355
- # ALL_BYPASSED gate is what governs the exit.
356
- [[ -z "$ANY_INLINE_VALUE" ]] && ANY_INLINE_VALUE="$_seg_inline"
357
- fi
358
- done <<< "$TRIGGER_SEGMENTS"
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
359
338
  fi
360
-
361
- # 9c. Allow ONLY when every trigger segment authorized bypass (process-env
362
- # covers globally; inline must be present on each segment). Failure
363
- # of any single trigger segment forces preflight invocation.
364
- if [[ $ALL_BYPASSED -eq 1 ]]; then
365
- if [[ -n "$BYPASS_VALUE" ]]; then
366
- INLINE_BYPASS_VALUE=""
367
- else
368
- INLINE_BYPASS_VALUE="$ANY_INLINE_VALUE"
369
- fi
370
- # Override active — allow. The downstream `rea preflight` (in husky
371
- # or otherwise) will write the audit override entry. We do NOT write
372
- # one here because that would double-audit any push that crosses both
373
- # the bash-tier and the husky tier.
374
- #
375
- # Test-only debug trace: when REA_LOCAL_REVIEW_DEBUG_TRACE=1 the gate
376
- # emits a structured marker on stderr identifying the branch taken
377
- # (bypass-process-env, bypass-inline, or refuse). Production never
378
- # sets this env var; the trace is silent by default. The trace lets
379
- # the codex round-23 P2 regression test distinguish "honored as
380
- # bypass" from "command shape unrecognized → silent exit" — both
381
- # exit 0 and produce no other output.
382
- if [[ "${REA_LOCAL_REVIEW_DEBUG_TRACE:-}" == "1" ]]; then
383
- if [[ -n "$INLINE_BYPASS_VALUE" ]]; then
384
- printf 'rea-local-review-trace: bypass=inline reason=%q op=%s\n' \
385
- "$INLINE_BYPASS_VALUE" "$GIT_OP_LABEL" >&2
386
- else
387
- printf 'rea-local-review-trace: bypass=process-env reason=%q op=%s\n' \
388
- "$BYPASS_VALUE" "$GIT_OP_LABEL" >&2
389
- fi
390
- fi
391
- exit 0
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
392
471
  fi
393
- # Round-25 P1-B trace: surface that at least one trigger segment lacked
394
- # a bypass (the laundering-class signal). Production stays silent.
395
- if [[ "${REA_LOCAL_REVIEW_DEBUG_TRACE:-}" == "1" ]]; then
396
- printf 'rea-local-review-trace: refuse op=%s reason=trigger-without-bypass\n' \
397
- "$GIT_OP_LABEL" >&2
472
+ if [ "$RELEVANT" -eq 0 ]; then
473
+ exit 0
398
474
  fi
399
475
 
400
- # 10. Resolve the rea binary the same way the husky pre-push template
401
- # does local node_modules first, dogfood dist next, PATH, then npx.
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.
402
480
  #
403
- # Round-30 F1 fix: align this 4-branch ladder with
404
- # templates/pre-push.local-first.sh:55-61 and the canonical husky body in
405
- # src/cli/install/pre-push.ts. Pre-fix the gate stopped at PATH and fell
406
- # open with the "could not locate" advisory whenever the operator only
407
- # had npx available (pnpm dlx-style installs, npx --no-install cache
408
- # hits, CI nodes that don't `npm i`). Adding the `npx --no-install`
409
- # branch closes that drift.
410
- REA_BIN=()
411
- if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
412
- REA_BIN=("${REA_ROOT}/node_modules/.bin/rea")
413
- elif [ -f "${REA_ROOT}/dist/cli/index.js" ] \
414
- && [ -f "${REA_ROOT}/package.json" ] \
415
- && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
416
- REA_BIN=(node "${REA_ROOT}/dist/cli/index.js")
417
- elif command -v rea >/dev/null 2>&1; then
418
- REA_BIN=(rea)
419
- elif command -v npx >/dev/null 2>&1; then
420
- # Last resort: npx will resolve the package from npm or the cache.
421
- # Pass `--no-install` so a rare cache-cold machine surfaces a clear
422
- # error instead of silently downloading at hook time.
423
- REA_BIN=(npx --no-install @bookedsolid/rea)
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"
424
490
  fi
425
-
426
- if [[ ${#REA_BIN[@]} -eq 0 ]]; then
427
- # Fail OPEN when rea itself can't be found — the agent's bash command
428
- # would have failed downstream too, and refusing here would be a
429
- # confusing error. Log to stderr so the operator sees the gap.
430
- printf 'rea: local-review-gate skipped could not locate rea CLI. Install: pnpm add -D @bookedsolid/rea\n' >&2
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.
431
498
  exit 0
432
499
  fi
433
500
 
434
- # 11. Run `rea preflight --strict` and use its exit code.
435
- "${REA_BIN[@]}" preflight --strict
436
- PREFLIGHT_STATUS=$?
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
437
515
 
438
- if [[ $PREFLIGHT_STATUS -eq 0 ]]; then
439
- exit 0
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
440
569
  fi
441
570
 
442
- # Refuse print a friendly explanation tied to the git op the agent
443
- # tried to run. Exit 2 so Claude Code refuses the Bash command.
444
- {
445
- printf 'BASH BLOCKED: %s — local-first review required\n' "$GIT_OP_LABEL"
446
- printf '\n'
447
- printf ' rea preflight refused (exit %d). The local-first guardrail (CTO directive\n' "$PREFLIGHT_STATUS"
448
- printf ' 2026-05-05) requires a recent codex review of the working tree before any\n'
449
- printf ' push or commit.\n'
450
- printf '\n'
451
- printf ' To unblock, do ONE of:\n'
452
- printf ' 1. Run `rea review` first — writes the canonical audit entry.\n'
453
- printf ' 2. Set %s="<reason>" — per-invocation override (audited).\n' "$BYPASS_VAR"
454
- printf ' 3. Edit .rea/policy.yaml — set:\n'
455
- printf ' review:\n'
456
- printf ' local_review:\n'
457
- printf ' mode: off\n'
458
- printf ' (use this if your team does not have codex/claude installed)\n'
459
- } >&2
460
- exit 2
571
+ # 10. Forward stdin (already captured up-front).
572
+ printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook local-review-gate
573
+ exit $?