@bookedsolid/rea 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,460 @@
1
+ #!/bin/bash
2
+ # PreToolUse hook: local-review-gate.sh
3
+ # 0.26.0+ — forceful local-first delegation enforcement.
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.
8
+ #
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.
14
+ #
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."
20
+ #
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.
24
+ #
25
+ # Per-invocation override: REA_SKIP_LOCAL_REVIEW="<reason>" — the gate
26
+ # allows the command and `rea preflight` audits the bypass.
27
+ #
28
+ # Exit codes:
29
+ # 0 = allow (mode=off, override set, recent review found, non-git command)
30
+ # 2 = refuse (no recent review covering HEAD)
31
+
32
+ set -uo pipefail
33
+
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).
51
+ # shellcheck source=_lib/halt-check.sh
52
+ source "$(dirname "$0")/_lib/halt-check.sh"
53
+ check_halt
54
+ REA_ROOT=$(rea_root)
55
+
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"
59
+
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
65
+
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
69
+ exit 0
70
+ fi
71
+
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'
76
+
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
85
+
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)
94
+ #
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.
97
+ #
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
150
+ return 0
151
+ fi
152
+ if [[ -z "$TRIGGER_SEGMENTS" ]]; then
153
+ TRIGGER_SEGMENTS="$list"
154
+ else
155
+ TRIGGER_SEGMENTS="${TRIGGER_SEGMENTS}"$'\n'"${list}"
156
+ fi
157
+ }
158
+
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
204
+ exit 0
205
+ fi
206
+
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:-}"
219
+
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.
228
+ #
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.
239
+ #
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]}"
310
+ 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
+ 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"
359
+ 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
392
+ 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
398
+ fi
399
+
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.
402
+ #
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)
424
+ 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
431
+ exit 0
432
+ fi
433
+
434
+ # 11. Run `rea preflight --strict` and use its exit code.
435
+ "${REA_BIN[@]}" preflight --strict
436
+ PREFLIGHT_STATUS=$?
437
+
438
+ if [[ $PREFLIGHT_STATUS -eq 0 ]]; then
439
+ exit 0
440
+ fi
441
+
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bookedsolid/rea",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
4
4
  "description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
5
5
  "license": "MIT",
6
6
  "author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
@@ -0,0 +1,87 @@
1
+ # Local-first delegation (0.26.0+)
2
+
3
+ > Section merged into consumer `CLAUDE.md` by `rea init`. Do not edit
4
+ > by hand here — re-run `rea init` / `rea upgrade` to refresh.
5
+
6
+ ## The rule
7
+
8
+ Every change goes through this loop BEFORE commit:
9
+
10
+ 1. **Edit** the working tree.
11
+ 2. **`rea review`** — runs codex against the working tree, writes a
12
+ `rea.local_review` audit entry recording the verdict.
13
+ 3. **Address** any blocking findings in-tree, re-review until pass
14
+ or only-P3.
15
+ 4. **Commit** (one squashed commit ideally) and **push** — the husky
16
+ pre-push hook calls `rea preflight --strict`, which checks the
17
+ audit log for a recent matching entry. The push-gate is the
18
+ BACKUP layer, not the primary review surface.
19
+
20
+ ## Why
21
+
22
+ The push-gate (`.husky/pre-push` running `codex exec review`) catches
23
+ late: by the time it fires, the diff is already committed. Fixing
24
+ findings means amending or stacking fix-commits. The result on your
25
+ PR is a chain of "fix codex finding" commits the reviewer (human or
26
+ agent) has to wade through.
27
+
28
+ Local-first review reverses the loop: codex sees the diff while it's
29
+ still in the working tree. Findings get fixed in-place. The PR lands
30
+ green-first-try, single-squashed-commit.
31
+
32
+ This is the rule for ALL rea work — OSS + enterprise — per CTO
33
+ directive 2026-05-05.
34
+
35
+ ## Commands
36
+
37
+ ```bash
38
+ rea review # run codex on working tree, write audit entry
39
+ rea preflight # check status (exit 0/1/2)
40
+ rea preflight --strict # treat warns as refusals (husky uses this)
41
+ ```
42
+
43
+ ## Escape hatches
44
+
45
+ - **Per-invocation override**: `REA_SKIP_LOCAL_REVIEW="<reason>" git push`
46
+ — audit logs the reason. Use sparingly; the override is a release
47
+ valve, not a sustained way to disable enforcement.
48
+ - **Team off-switch**: in `.rea/policy.yaml` set:
49
+
50
+ ```yaml
51
+ review:
52
+ local_review:
53
+ mode: off
54
+ ```
55
+
56
+ Use this when your team doesn't have codex/claude installed.
57
+ Every enforcement layer becomes a silent no-op; the push-gate
58
+ (governed separately by `review.codex_required`) is unaffected.
59
+
60
+ ## What gets enforced
61
+
62
+ Three layers, all calling `rea preflight`:
63
+
64
+ 1. **Bash-tier hook** (`.claude/hooks/local-review-gate.sh`) —
65
+ refuses `git push` (and optionally `git commit`) from Claude
66
+ Code's Bash tool BEFORE the command runs. This is the agent-
67
+ specific forceful layer.
68
+ 2. **Husky pre-push** (`.husky/pre-push`) — refuses `git push` at
69
+ the terminal layer. Catches CI and human pushes too.
70
+ 3. **Direct `rea preflight`** — operators run it manually to
71
+ check status before commit.
72
+
73
+ ## Debugging
74
+
75
+ ```bash
76
+ rea preflight --json # structured output
77
+ cat .rea/audit.jsonl | grep rea.local_review | tail -3
78
+ ```
79
+
80
+ A `rea.local_review` entry covers HEAD when:
81
+ - `metadata.head_sha` matches `git rev-parse HEAD`
82
+ - `metadata.verdict` is not `error` or `blocking`
83
+ - `record.timestamp` is within `policy.review.local_review.max_age_seconds`
84
+ of now (default 24h)
85
+
86
+ Pre-0.26.0 audit entries with `tool_name: codex.review` are also
87
+ accepted as covering HEAD — back-compat for upgrade.
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env bash
2
+ # rea local-first pre-push template (0.26.0+).
3
+ #
4
+ # This is the SIMPLEST possible local-first pre-push body — pure
5
+ # delegation to `rea preflight --strict`. The real `.husky/pre-push`
6
+ # that `rea init`/`rea upgrade` writes is the canonical body in
7
+ # `src/cli/install/pre-push.ts::BODY_TEMPLATE`, which ALSO runs
8
+ # `rea preflight --strict` before the push-gate dispatch.
9
+ #
10
+ # Operators who want a minimal pre-push (no codex on push, just the
11
+ # local-review audit-log check) can replace their `.husky/pre-push`
12
+ # body with this one.
13
+ #
14
+ # Behavior:
15
+ # - .rea/HALT present → exit 2 (kill-switch)
16
+ # - policy.review.local_review.mode: off → exit 0 (no-op)
17
+ # - REA_SKIP_LOCAL_REVIEW=<reason> set → exit 0 (audited)
18
+ # - recent rea.local_review covers HEAD → exit 0
19
+ # - otherwise → exit 2 with helpful msg
20
+ #
21
+ # See docs/migration/0.26.0.md for the full enforcement story.
22
+ set -euo pipefail
23
+
24
+ # Resolve REA_ROOT — the consumer repo's root, used to locate a local
25
+ # rea binary. Git pre-push hooks cd into the repo root before invoking
26
+ # the hook, so `pwd` is the right answer here. We deliberately don't
27
+ # rely on `core.hooksPath` or `git rev-parse` so this template works
28
+ # under both vanilla git and husky 9 layouts.
29
+ REA_ROOT="$(pwd)"
30
+
31
+ # Round-27 F5 fix: inline the same rea-CLI resolution ladder used by
32
+ # the canonical BODY_TEMPLATE in src/cli/install/pre-push.ts. Pre-fix
33
+ # the body was `exec rea preflight --strict`, which assumed `rea` was
34
+ # on PATH. Git hooks run with the user's interactive PATH MINUS
35
+ # `node_modules/.bin` (npm doesn't extend PATH for hook subprocesses
36
+ # the way it does for `npm run` scripts), so devDependency-only
37
+ # installs got `rea: not found` on every push.
38
+ #
39
+ # Resolution order (matches BODY_TEMPLATE exactly):
40
+ # 1. ${REA_ROOT}/node_modules/.bin/rea — local devDependency.
41
+ # 2. ${REA_ROOT}/dist/cli/index.js — rea's own dogfood repo.
42
+ # 3. PATH-resolved rea — global install.
43
+ # 4. npx --no-install — last-resort npm cache hit.
44
+ if [ -x "${REA_ROOT}/node_modules/.bin/rea" ]; then
45
+ exec "${REA_ROOT}/node_modules/.bin/rea" preflight --strict
46
+ elif [ -f "${REA_ROOT}/dist/cli/index.js" ] \
47
+ && [ -f "${REA_ROOT}/package.json" ] \
48
+ && grep -q '"name": *"@bookedsolid/rea"' "${REA_ROOT}/package.json" 2>/dev/null; then
49
+ # rea's own repo (dogfood) — the package is not installed under
50
+ # node_modules here because we ARE the package. Gate this branch on
51
+ # `package.json` declaring `@bookedsolid/rea` so a consumer repo that
52
+ # happens to ship its own `dist/cli/index.js` does not get this hook
53
+ # executing the consumer's unrelated build.
54
+ exec node "${REA_ROOT}/dist/cli/index.js" preflight --strict
55
+ elif command -v rea >/dev/null 2>&1; then
56
+ exec rea preflight --strict
57
+ elif command -v npx >/dev/null 2>&1; then
58
+ # Last resort: npx will resolve the package from npm or the cache.
59
+ # Pass `--no-install` so a rare cache-cold machine surfaces a clear
60
+ # error instead of silently downloading at push time.
61
+ exec npx --no-install @bookedsolid/rea preflight --strict
62
+ else
63
+ printf 'rea: cannot locate the rea CLI for preflight. Install locally (`pnpm add -D @bookedsolid/rea`) or set policy.review.local_review.mode=off.\n' >&2
64
+ exit 2
65
+ fi