@bookedsolid/rea 0.25.0 → 0.26.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.
- package/README.md +10 -7
- package/agents/codex-adversarial.md +4 -0
- package/agents/rea-orchestrator.md +9 -0
- package/commands/codex-review.md +4 -0
- package/dist/audit/append.d.ts +1 -0
- package/dist/audit/append.js +1 -0
- package/dist/audit/content-token.d.ts +98 -0
- package/dist/audit/content-token.js +136 -0
- package/dist/audit/local-review-event.d.ts +136 -0
- package/dist/audit/local-review-event.js +43 -0
- package/dist/cli/doctor.js +17 -0
- package/dist/cli/hook.d.ts +44 -0
- package/dist/cli/hook.js +77 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/init.js +197 -46
- package/dist/cli/install/pre-push.d.ts +15 -3
- package/dist/cli/install/pre-push.js +55 -5
- package/dist/cli/install/settings-merge.js +13 -0
- package/dist/cli/preflight.d.ts +120 -0
- package/dist/cli/preflight.js +487 -0
- package/dist/cli/review.d.ts +56 -0
- package/dist/cli/review.js +325 -0
- package/dist/policy/loader.d.ts +65 -0
- package/dist/policy/loader.js +33 -0
- package/dist/policy/types.d.ts +89 -0
- package/hooks/_lib/cmd-segments.sh +140 -2
- package/hooks/_lib/policy-read.sh +255 -0
- package/hooks/local-review-gate.sh +460 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md.local-first.md +87 -0
- package/templates/pre-push.local-first.sh +65 -0
|
@@ -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.
|
|
3
|
+
"version": "0.26.0",
|
|
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
|