@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
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: local-review-gate.sh
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
|
|
4
|
+
#
|
|
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.
|
|
12
|
+
#
|
|
13
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on
|
|
14
|
+
# pass-through / mode=off / bypassed / preflight-allow, exit 2 on
|
|
15
|
+
# HALT / preflight-refuse / malformed payload.
|
|
16
|
+
#
|
|
17
|
+
# # Shim short-circuits (codex round-1 P1+P2 fixes)
|
|
18
|
+
#
|
|
19
|
+
# The 0.34.0 round-0 shim deferred ALL decisions to the CLI, including
|
|
20
|
+
# `mode: off` and the bypass env-var. That regressed two documented
|
|
21
|
+
# workflows on fresh/unbuilt installs:
|
|
22
|
+
# - codex-less teams with `policy.review.local_review.mode: off` must
|
|
23
|
+
# still be able to `git push` even when the rea CLI isn't built.
|
|
24
|
+
# - operators with the audited bypass env-var set (default
|
|
25
|
+
# `REA_SKIP_LOCAL_REVIEW=<reason>`) must still be able to push.
|
|
26
|
+
# Round-1 P1 fix: read the mode + bypass env-var INLINE in the shim
|
|
27
|
+
# BEFORE any CLI resolution. These two short-circuits exit 0 cleanly
|
|
28
|
+
# without spawning node. The full enforcement (multi-trigger sweep,
|
|
29
|
+
# inline-bypass evaluation, preflight call) still lives in the CLI.
|
|
30
|
+
#
|
|
31
|
+
# # CLI-resolution trust boundary
|
|
32
|
+
#
|
|
33
|
+
# Mirrors the 0.32.0 final shim shape. The resolved CLI MUST live
|
|
34
|
+
# INSIDE realpath(CLAUDE_PROJECT_DIR) AND have an ancestor
|
|
35
|
+
# `package.json` whose `name` is `@bookedsolid/rea`.
|
|
36
|
+
#
|
|
37
|
+
# # Fail-closed posture
|
|
38
|
+
#
|
|
39
|
+
# local-review-gate is BLOCKING-tier — the pre-0.34.0 bash body
|
|
40
|
+
# refused `git push` (and optionally `git commit`) without a recent
|
|
41
|
+
# audit entry. The early-exit branches (CLI missing, node missing,
|
|
42
|
+
# sandbox failed, version skew) fail closed AFTER the relevance
|
|
43
|
+
# pre-gate passes AND AFTER the mode/bypass short-circuits.
|
|
44
|
+
#
|
|
45
|
+
# # Relevance pre-gate
|
|
46
|
+
#
|
|
47
|
+
# Round-1 P2 fix: the substring scan must NOT mark commands as
|
|
48
|
+
# relevant when `git push`/`git commit` only appears inside a quoted
|
|
49
|
+
# argument body (`echo "remember git push later"`,
|
|
50
|
+
# `git commit -m "doc: explain git push --force"`). Pre-fix the
|
|
51
|
+
# substring scan saw these as relevant → entered fail-closed branch
|
|
52
|
+
# when CLI was missing. Fix: anchor the substring scan on segment
|
|
53
|
+
# heads via a stripped-prefix check, matching the CLI's segment-aware
|
|
54
|
+
# detector.
|
|
55
|
+
|
|
56
|
+
set -uo pipefail
|
|
57
|
+
|
|
58
|
+
# 1. HALT check.
|
|
59
|
+
# shellcheck source=_lib/halt-check.sh
|
|
60
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
61
|
+
check_halt
|
|
62
|
+
REA_ROOT=$(rea_root)
|
|
63
|
+
|
|
64
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
65
|
+
|
|
66
|
+
# 2. Read stdin once. Used by the relevance pre-gate, the bypass
|
|
67
|
+
# short-circuit, AND the CLI forward.
|
|
68
|
+
INPUT=$(cat)
|
|
69
|
+
|
|
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
|
|
88
|
+
exit 0
|
|
89
|
+
fi
|
|
90
|
+
|
|
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
|
|
105
|
+
|
|
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
|
|
160
|
+
|
|
161
|
+
# Helper: read a `local_review.<leaf>` policy key. Tries
|
|
162
|
+
# `rea hook policy-get review.local_review --json` (one node-spawn for
|
|
163
|
+
# the whole subtree) first — that path handles inline + block YAML
|
|
164
|
+
# identically since it goes through the canonical `yaml.parse()`.
|
|
165
|
+
# Falls back to a block-form awk parser when the CLI isn't available
|
|
166
|
+
# or jq isn't installed. Empty stdout → "default applies".
|
|
167
|
+
#
|
|
168
|
+
# 0.34.0 round-2 P2 fix: pre-fix the shim only ran the block-form awk
|
|
169
|
+
# parser, so inline-form mappings like
|
|
170
|
+
# `local_review: { mode: off, refuse_at: commit }` silently no-op'd on
|
|
171
|
+
# stale-CLI installs (the canonical loader DOES handle them — only the
|
|
172
|
+
# shim was block-only). Hybrid policy reader mirrors the pattern used
|
|
173
|
+
# by prepare-commit-msg's augmenter.
|
|
174
|
+
#
|
|
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.
|
|
214
|
+
return 0
|
|
215
|
+
fi
|
|
216
|
+
# 3. Fallback: block-form awk parser (legacy 0.34.0 round-1 path).
|
|
217
|
+
# Only covers `review.local_review.<leaf>`. Inline-form mappings
|
|
218
|
+
# fall through to "" → defaults — which is the SAME posture as the
|
|
219
|
+
# pre-0.34.0 bash hook, but now with the CLI path above providing
|
|
220
|
+
# inline-form support whenever the CLI is reachable.
|
|
221
|
+
if [ ! -f "$POLICY_FILE" ] || ! command -v awk >/dev/null 2>&1; then
|
|
222
|
+
return 0
|
|
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
|
|
255
|
+
}
|
|
256
|
+
|
|
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
|
|
262
|
+
exit 0
|
|
263
|
+
fi
|
|
264
|
+
|
|
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
|
|
280
|
+
|
|
281
|
+
# Relevance pre-gate. Anchor on the trigger regex at the head of each
|
|
282
|
+
# ;/&&/||/| separated segment — this matches the CLI's segment-aware
|
|
283
|
+
# detector and avoids false-positives on quoted arguments like
|
|
284
|
+
# `git commit -m "doc: git push later"`.
|
|
285
|
+
#
|
|
286
|
+
# The check is approximate (it uses a coarse quote masker that the CLI
|
|
287
|
+
# does properly via mvdan-sh) because if it errs on the side of
|
|
288
|
+
# relevant→true, the CLI's real segment walker will sort it out. We
|
|
289
|
+
# only want to short-circuit confidently-non-relevant cases (where
|
|
290
|
+
# there's NO trigger head in any segment) so unbuilt installs don't
|
|
291
|
+
# fail closed on benign Bash calls.
|
|
292
|
+
#
|
|
293
|
+
# 0.34.0 round-2 P1 fix: the env-prefix-strip MUST accept quoted
|
|
294
|
+
# values. Pre-fix the strip pattern was
|
|
295
|
+
# `[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+`, which silently
|
|
296
|
+
# missed shapes like `GIT_SSH_COMMAND="ssh -i ~/.ssh/id" git push`
|
|
297
|
+
# because the `[^[:space:]]+` value group stops at the first space
|
|
298
|
+
# inside the quotes. We mirror the segments.ts `matchEnvAssignLength`
|
|
299
|
+
# helper — accept value shapes `"..."`, `'...'`, `\S*` (zero-or-more
|
|
300
|
+
# so bare `FOO= cmd` resolves too). The strip runs ITERATIVELY so
|
|
301
|
+
# stacked env prefixes (`A="x" B='y' C=z git push`) all get peeled.
|
|
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
|
|
325
|
+
fi
|
|
326
|
+
else
|
|
327
|
+
# 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (the
|
|
328
|
+
# raw JSON payload) when jq was missing, then ran the awk relevance
|
|
329
|
+
# scan over JSON instead of a bare command. A payload containing
|
|
330
|
+
# `git push origin main` came through as e.g.
|
|
331
|
+
# `{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}`
|
|
332
|
+
# → the `^git push` anchor never matched → RELEVANT=0 → silent
|
|
333
|
+
# bypass on every jq-less machine. Fix: treat jq-missing the same
|
|
334
|
+
# as a parse failure — force RELEVANT=1 and let the CLI body decide.
|
|
335
|
+
# The CLI uses native Node JSON parsing so jq is not required for
|
|
336
|
+
# the actual enforcement.
|
|
337
|
+
JQ_PARSE_FAILED=1
|
|
338
|
+
fi
|
|
339
|
+
# Split on shell separators then look for a segment whose head is
|
|
340
|
+
# the configured trigger. The awk here masks chars inside `"..."`
|
|
341
|
+
# and `'...'` spans before splitting — same posture as the CLI's
|
|
342
|
+
# `splitSegments` but coarser (no nested-shell unwrap; the CLI handles
|
|
343
|
+
# that). For relevance-pre-gate purposes the masker is sufficient.
|
|
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
|
|
471
|
+
fi
|
|
472
|
+
if [ "$RELEVANT" -eq 0 ]; then
|
|
473
|
+
exit 0
|
|
474
|
+
fi
|
|
475
|
+
|
|
476
|
+
# 6. Bypass env-var short-circuit. The bash hook honored the
|
|
477
|
+
# operator-exported `REA_SKIP_LOCAL_REVIEW` (or the policy-renamed
|
|
478
|
+
# var) BEFORE invoking preflight. We mirror that here so an
|
|
479
|
+
# audited bypass works even when the CLI isn't built.
|
|
480
|
+
#
|
|
481
|
+
# Policy-driven var name: read `policy.review.local_review.bypass_env_var`
|
|
482
|
+
# if present; default to `REA_SKIP_LOCAL_REVIEW`. The CLI does its
|
|
483
|
+
# own per-segment inline-bypass evaluation; the shim only checks
|
|
484
|
+
# the operator-exported (process-env) form.
|
|
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"
|
|
490
|
+
fi
|
|
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.
|
|
498
|
+
exit 0
|
|
499
|
+
fi
|
|
500
|
+
|
|
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
|
|
515
|
+
|
|
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
|
|
569
|
+
fi
|
|
570
|
+
|
|
571
|
+
# 10. Forward stdin (already captured up-front).
|
|
572
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook local-review-gate
|
|
573
|
+
exit $?
|