@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.
- package/dist/cli/hook.js +21 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
- package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
- package/dist/hooks/local-review-gate/index.d.ts +145 -0
- package/dist/hooks/local-review-gate/index.js +374 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/secret-scanner.sh +210 -200
- package/package.json +1 -1
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
|
@@ -1,460 +1,573 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: local-review-gate.sh
|
|
3
|
-
# 0.
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
|
|
4
4
|
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
26
|
-
# allows the command and `rea preflight` audits the bypass.
|
|
31
|
+
# # CLI-resolution trust boundary
|
|
27
32
|
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
#
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
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
|
-
#
|
|
96
|
-
#
|
|
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
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
#
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
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
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
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
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
#
|
|
254
|
-
#
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
#
|
|
260
|
-
#
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
#
|
|
335
|
-
#
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
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
|
-
#
|
|
401
|
-
#
|
|
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
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
#
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
#
|
|
429
|
-
#
|
|
430
|
-
|
|
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
|
-
#
|
|
435
|
-
|
|
436
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
#
|
|
443
|
-
|
|
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 $?
|