@bookedsolid/rea 0.37.0 → 0.38.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/hooks/_lib/shim-runtime.sh +405 -0
- package/hooks/architecture-review-gate.sh +11 -103
- package/hooks/attribution-advisory.sh +38 -209
- package/hooks/blocked-paths-bash-gate.sh +32 -146
- package/hooks/blocked-paths-enforcer.sh +32 -137
- package/hooks/changeset-security-gate.sh +26 -119
- package/hooks/dangerous-bash-interceptor.sh +46 -170
- package/hooks/delegation-advisory.sh +26 -144
- package/hooks/delegation-capture.sh +33 -139
- package/hooks/dependency-audit-gate.sh +29 -121
- package/hooks/env-file-protection.sh +30 -141
- package/hooks/local-review-gate.sh +117 -352
- package/hooks/pr-issue-link-gate.sh +16 -118
- package/hooks/protected-paths-bash-gate.sh +53 -152
- package/hooks/secret-scanner.sh +90 -213
- package/hooks/security-disclosure-gate.sh +32 -155
- package/hooks/settings-protection.sh +56 -176
- package/package.json +1 -1
- package/templates/_lib_shim-runtime.dogfood-staged.sh +405 -0
- package/templates/architecture-review-gate.dogfood-staged.sh +11 -103
- package/templates/attribution-advisory.dogfood-staged.sh +38 -209
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +32 -146
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +32 -137
- package/templates/changeset-security-gate.dogfood-staged.sh +26 -119
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +46 -170
- package/templates/delegation-advisory.dogfood-staged.sh +44 -0
- package/templates/delegation-capture.dogfood-staged.sh +52 -0
- package/templates/dependency-audit-gate.dogfood-staged.sh +29 -121
- package/templates/env-file-protection.dogfood-staged.sh +30 -141
- package/templates/local-review-gate.dogfood-staged.sh +117 -352
- package/templates/pr-issue-link-gate.dogfood-staged.sh +16 -118
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +53 -152
- package/templates/secret-scanner.dogfood-staged.sh +90 -213
- package/templates/security-disclosure-gate.dogfood-staged.sh +32 -155
- package/templates/settings-protection.dogfood-staged.sh +56 -176
|
@@ -1,57 +1,30 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: local-review-gate.sh
|
|
3
3
|
# 0.34.0+ — Node-binary shim for `rea hook local-review-gate`.
|
|
4
|
+
# 0.38.0+ — uses helpers from `_lib/shim-runtime.sh` (shared
|
|
5
|
+
# CLI-resolution, sandbox, and banners). Cannot use the
|
|
6
|
+
# full `shim_run` orchestrator because the hot-path policy
|
|
7
|
+
# reads need to happen AFTER an early sandbox check (round-5
|
|
8
|
+
# P1) and the relevance scan is policy-driven on
|
|
9
|
+
# `review.local_review.refuse_at` (round-1 P2).
|
|
4
10
|
#
|
|
5
|
-
# Pre-0.34.0 the gate's full body lived here as bash (460 LOC
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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.
|
|
11
|
+
# Pre-0.34.0 the gate's full body lived here as bash (460 LOC). The
|
|
12
|
+
# migration moves per-segment trigger detection + preflight call into
|
|
13
|
+
# `src/hooks/local-review-gate/index.ts`. This shim:
|
|
12
14
|
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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.
|
|
15
|
+
# 1. HALT check
|
|
16
|
+
# 2. Read stdin
|
|
17
|
+
# 2b. Early default-bypass-env-var short-circuit (round-7 P2)
|
|
18
|
+
# 3. Resolve CLI + EARLY sandbox check (round-5 P1: prevent
|
|
19
|
+
# unsandboxed CLI from running during policy lookup)
|
|
20
|
+
# 3b. Subtree-cached policy reads via `_lib/policy-reader.sh`
|
|
21
|
+
# 4. Mode-off short-circuit
|
|
22
|
+
# 5. Refuse_at + relevance scan
|
|
23
|
+
# 6. Policy-driven bypass env-var short-circuit
|
|
24
|
+
# 7. CLI-missing handling
|
|
25
|
+
# 8. Sandbox check (idempotent re-run; emit banner on failure)
|
|
26
|
+
# 9. Version probe
|
|
27
|
+
# 10. Forward
|
|
55
28
|
|
|
56
29
|
set -uo pipefail
|
|
57
30
|
|
|
@@ -63,131 +36,67 @@ REA_ROOT=$(rea_root)
|
|
|
63
36
|
|
|
64
37
|
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
65
38
|
|
|
66
|
-
#
|
|
67
|
-
|
|
39
|
+
# SHIM_* metadata for shared banner helpers.
|
|
40
|
+
SHIM_NAME="local-review-gate"
|
|
41
|
+
SHIM_INTRODUCED_IN="0.34.0"
|
|
42
|
+
SHIM_FAIL_OPEN=0
|
|
43
|
+
SHIM_ENFORCE_CLI_SHAPE=0
|
|
44
|
+
SHIM_REFUSAL_NOUN="local-first review enforcement"
|
|
45
|
+
SHIM_NODE_MISSING_NOUN="local-first review enforcement"
|
|
46
|
+
SHIM_SKIP_VERSION_PROBE=0
|
|
47
|
+
# shellcheck source=_lib/shim-runtime.sh
|
|
48
|
+
source "$(dirname "$0")/_lib/shim-runtime.sh"
|
|
49
|
+
_shim_apply_defaults
|
|
50
|
+
|
|
51
|
+
# 2. Read stdin once.
|
|
68
52
|
INPUT=$(cat)
|
|
69
53
|
|
|
70
|
-
# 2b. Early bypass-env-var short-circuit
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
# check
|
|
74
|
-
#
|
|
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.
|
|
54
|
+
# 2b. Early default-bypass-env-var short-circuit. We can only check the
|
|
55
|
+
# DEFAULT var name (REA_SKIP_LOCAL_REVIEW) this early because the
|
|
56
|
+
# policy-renamed var requires a policy read. The policy-aware
|
|
57
|
+
# re-check at section 6 still runs for renamed vars when the CLI
|
|
58
|
+
# is reachable.
|
|
86
59
|
EARLY_BYPASS_VALUE="${REA_SKIP_LOCAL_REVIEW:-}"
|
|
87
60
|
if [ -n "$EARLY_BYPASS_VALUE" ]; then
|
|
88
61
|
exit 0
|
|
89
62
|
fi
|
|
90
63
|
|
|
91
|
-
# 3. Resolve
|
|
92
|
-
|
|
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
|
|
64
|
+
# 3. Resolve CLI early (used by policy reader Tier 1 + final forward).
|
|
65
|
+
shim_resolve_cli
|
|
105
66
|
|
|
106
|
-
# Round-5 P1 fix: sandbox-check the
|
|
67
|
+
# Round-5 P1 fix: sandbox-check the CLI BEFORE any policy-get
|
|
107
68
|
# invocation. Pre-fix `_lrg_read_policy()` could spawn the resolved CLI
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
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.
|
|
69
|
+
# for mode-off / refuse_at reads BEFORE the sandbox guard fired — a
|
|
70
|
+
# symlinked or swapped dist/cli/index.js would execute during policy
|
|
71
|
+
# lookup, defeating the realpath / package.json trust boundary.
|
|
72
|
+
SANDBOX_EARLY_FAILURE=""
|
|
118
73
|
if [ "${#REA_ARGV[@]}" -gt 0 ] && command -v node >/dev/null 2>&1; then
|
|
119
|
-
sandbox_check_early=$(
|
|
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)
|
|
74
|
+
sandbox_check_early=$(shim_sandbox_check "$RESOLVED_CLI_PATH" "$proj" "$SHIM_ENFORCE_CLI_SHAPE")
|
|
151
75
|
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
76
|
SANDBOX_EARLY_FAILURE="$sandbox_check_early"
|
|
157
77
|
REA_ARGV=()
|
|
158
78
|
fi
|
|
159
79
|
fi
|
|
160
80
|
|
|
161
81
|
# 0.37.0: route policy reads through the unified policy-reader. The
|
|
162
|
-
# pre-0.37.0 helper
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
# forms entirely — silent no-op on stale-CLI installs).
|
|
169
|
-
#
|
|
170
|
-
# Behavior preserved: empty stdout → "default applies"; the helper
|
|
171
|
-
# returns 0 even when the key is unset, so the existing callers'
|
|
172
|
-
# `case` statements work unchanged.
|
|
82
|
+
# pre-0.37.0 helper was a hand-rolled dual-tier (CLI subtree JSON +
|
|
83
|
+
# per-leaf awk block-form parser). The new helper consolidates CLI +
|
|
84
|
+
# python3 + awk into a single 4-tier ladder so inline-form mappings
|
|
85
|
+
# like `local_review: { mode: off, refuse_at: commit }` work on
|
|
86
|
+
# installs where the CLI is unreachable AND python3 + PyYAML are
|
|
87
|
+
# available.
|
|
173
88
|
#
|
|
174
89
|
# Codex round 4 P2 (2026-05-16): local-review-gate fires on EVERY Bash
|
|
175
|
-
# PreToolUse
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
# Tier 3 awk can't serve subtree — that's documented and the per-leaf
|
|
183
|
-
# block-form parser handles those cases via the unified reader's
|
|
184
|
-
# fall-through ladder).
|
|
90
|
+
# PreToolUse and reads three leaves from `review.local_review`. The
|
|
91
|
+
# unified reader's CLI tier spawns a fresh `rea hook policy-get` per
|
|
92
|
+
# leaf, so the hot path went from 1 CLI startup (pre-0.37.0 subtree
|
|
93
|
+
# call) to 4. We restore the subtree-cache shape: fetch
|
|
94
|
+
# `review.local_review` as JSON once, then extract leaves locally.
|
|
95
|
+
# Falls back to per-leaf reads when the subtree call returns null /
|
|
96
|
+
# empty (e.g. Tier 3 awk can't serve subtree).
|
|
185
97
|
# shellcheck source=_lib/policy-reader.sh
|
|
186
98
|
source "$(dirname "$0")/_lib/policy-reader.sh"
|
|
187
99
|
|
|
188
|
-
# Subtree cache: populated lazily on first read. Empty string means
|
|
189
|
-
# "not yet attempted"; "null" means "attempted, key unset"; any other
|
|
190
|
-
# value is the JSON object.
|
|
191
100
|
_LRG_LR_SUBTREE_JSON=""
|
|
192
101
|
|
|
193
102
|
_lrg_load_local_review_subtree() {
|
|
@@ -203,18 +112,11 @@ _lrg_load_local_review_subtree() {
|
|
|
203
112
|
fi
|
|
204
113
|
}
|
|
205
114
|
|
|
206
|
-
# Extract a leaf from the cached subtree JSON. When subtree retrieval
|
|
207
|
-
# failed (e.g. Tier 3 awk fallback), or the leaf isn't present in the
|
|
208
|
-
# JSON, returns empty + non-zero so the caller can fall back to a
|
|
209
|
-
# per-leaf read.
|
|
210
115
|
_lrg_subtree_leaf() {
|
|
211
116
|
local leaf="$1"
|
|
212
117
|
if [ -z "$_LRG_LR_SUBTREE_JSON" ] || [ "$_LRG_LR_SUBTREE_JSON" = "null" ]; then
|
|
213
118
|
return 1
|
|
214
119
|
fi
|
|
215
|
-
# Try jq first; fall back to a python3 one-liner. Same hardened
|
|
216
|
-
# invocation shape as policy-reader.sh's no-jq fallback (env -u +
|
|
217
|
-
# PYTHONSAFEPATH + sys.path scrub).
|
|
218
120
|
if command -v jq >/dev/null 2>&1; then
|
|
219
121
|
local out
|
|
220
122
|
out=$(printf '%s' "$_LRG_LR_SUBTREE_JSON" | jq -r --arg k "$leaf" '
|
|
@@ -262,13 +164,6 @@ if isinstance(doc, dict) and leaf in doc:
|
|
|
262
164
|
}
|
|
263
165
|
|
|
264
166
|
_lrg_read_policy() {
|
|
265
|
-
# $1 = dotted key (e.g. `review.local_review.mode`)
|
|
266
|
-
#
|
|
267
|
-
# For `review.local_review.*` leaves, try the subtree cache first
|
|
268
|
-
# (one CLI startup serves all three leaves). Fall back to a per-key
|
|
269
|
-
# read for everything else — and for leaves that the subtree cache
|
|
270
|
-
# couldn't produce (e.g. Tier 3 awk fallback where subtree mode is
|
|
271
|
-
# unsupported).
|
|
272
167
|
local key="$1"
|
|
273
168
|
case "$key" in
|
|
274
169
|
review.local_review.*)
|
|
@@ -284,69 +179,30 @@ _lrg_read_policy() {
|
|
|
284
179
|
policy_reader_get "$key" 2>/dev/null
|
|
285
180
|
}
|
|
286
181
|
|
|
287
|
-
# 4. Mode-off short-circuit.
|
|
288
|
-
# `policy_get_local_review_mode` check at the top — `off` → silent
|
|
289
|
-
# no-op BEFORE any other work.
|
|
182
|
+
# 4. Mode-off short-circuit.
|
|
290
183
|
LOCAL_REVIEW_MODE=$(_lrg_read_policy review.local_review.mode)
|
|
291
184
|
if [ "$LOCAL_REVIEW_MODE" = "off" ]; then
|
|
292
185
|
exit 0
|
|
293
186
|
fi
|
|
294
187
|
|
|
295
|
-
# 5. Read
|
|
296
|
-
# default `refuse_at: push`, a `git commit` segment is NOT refused
|
|
297
|
-
# by the CLI — so when the CLI is missing, the shim should let
|
|
298
|
-
# `git commit -m "..."` pass without hitting fail-closed. Mirrors
|
|
299
|
-
# the bash hook's posture: a non-refused git op does not enter
|
|
300
|
-
# the preflight-refuse branch.
|
|
188
|
+
# 5. Read refuse_at to scope the relevance pre-gate.
|
|
301
189
|
REFUSE_AT="push"
|
|
302
190
|
POLICY_REFUSE=$(_lrg_read_policy review.local_review.refuse_at)
|
|
303
191
|
case "$POLICY_REFUSE" in push|commit|both) REFUSE_AT="$POLICY_REFUSE" ;; esac
|
|
304
|
-
# Build trigger-head alternation based on refuse_at.
|
|
305
192
|
case "$REFUSE_AT" in
|
|
306
193
|
push) TRIGGER_RE='git[[:space:]]+push' ;;
|
|
307
194
|
commit) TRIGGER_RE='git[[:space:]]+commit' ;;
|
|
308
195
|
both) TRIGGER_RE='git[[:space:]]+(push|commit)' ;;
|
|
309
196
|
esac
|
|
310
197
|
|
|
311
|
-
#
|
|
312
|
-
#
|
|
313
|
-
#
|
|
314
|
-
#
|
|
315
|
-
#
|
|
316
|
-
# The check is approximate (it uses a coarse quote masker that the CLI
|
|
317
|
-
# does properly via mvdan-sh) because if it errs on the side of
|
|
318
|
-
# relevant→true, the CLI's real segment walker will sort it out. We
|
|
319
|
-
# only want to short-circuit confidently-non-relevant cases (where
|
|
320
|
-
# there's NO trigger head in any segment) so unbuilt installs don't
|
|
321
|
-
# fail closed on benign Bash calls.
|
|
322
|
-
#
|
|
323
|
-
# 0.34.0 round-2 P1 fix: the env-prefix-strip MUST accept quoted
|
|
324
|
-
# values. Pre-fix the strip pattern was
|
|
325
|
-
# `[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+`, which silently
|
|
326
|
-
# missed shapes like `GIT_SSH_COMMAND="ssh -i ~/.ssh/id" git push`
|
|
327
|
-
# because the `[^[:space:]]+` value group stops at the first space
|
|
328
|
-
# inside the quotes. We mirror the segments.ts `matchEnvAssignLength`
|
|
329
|
-
# helper — accept value shapes `"..."`, `'...'`, `\S*` (zero-or-more
|
|
330
|
-
# so bare `FOO= cmd` resolves too). The strip runs ITERATIVELY so
|
|
331
|
-
# stacked env prefixes (`A="x" B='y' C=z git push`) all get peeled.
|
|
198
|
+
# 0.34.0 round-4 P2 fix: capture jq exit code separately rather than
|
|
199
|
+
# swallowing with `|| true`. Malformed payload pre-fix → empty PROBE →
|
|
200
|
+
# RELEVANT=0 → silent bypass. Post-fix: jq parse failure forces
|
|
201
|
+
# RELEVANT=1 so the CLI body decides (Zod fails closed on schema
|
|
202
|
+
# violations).
|
|
332
203
|
RELEVANT=0
|
|
333
204
|
PROBE=""
|
|
334
205
|
JQ_PARSE_FAILED=0
|
|
335
|
-
# 0.34.0 round-4 P2 fix: capture jq's exit code SEPARATELY rather than
|
|
336
|
-
# swallowing it with `|| true`. Malformed PreToolUse payload (invalid
|
|
337
|
-
# JSON, schema mismatch) pre-fix → empty PROBE → RELEVANT=0 fast path
|
|
338
|
-
# → silent bypass. Post-fix we distinguish:
|
|
339
|
-
# - jq exit 0 + non-empty stdout → use as PROBE (the normal path)
|
|
340
|
-
# - jq exit 0 + empty stdout → non-Bash payload / empty cmd, RELEVANT=0
|
|
341
|
-
# - jq exit != 0 (parse failure) → JQ_PARSE_FAILED=1, force RELEVANT=1
|
|
342
|
-
# so we skip the awk pre-gate and
|
|
343
|
-
# forward straight to the CLI body
|
|
344
|
-
# which fails closed on malformed
|
|
345
|
-
# payloads via Zod. Substring-only
|
|
346
|
-
# fallback was insufficient because
|
|
347
|
-
# raw JSON often won't contain
|
|
348
|
-
# `git push` literally and would
|
|
349
|
-
# still short-circuit to exit 0.
|
|
350
206
|
if command -v jq >/dev/null 2>&1; then
|
|
351
207
|
PROBE=$(printf '%s' "$INPUT" | jq -r '.tool_input.command // ""' 2>/dev/null)
|
|
352
208
|
jq_status=$?
|
|
@@ -354,32 +210,28 @@ if command -v jq >/dev/null 2>&1; then
|
|
|
354
210
|
JQ_PARSE_FAILED=1
|
|
355
211
|
fi
|
|
356
212
|
else
|
|
357
|
-
# 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
#
|
|
361
|
-
# `{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}`
|
|
362
|
-
# → the `^git push` anchor never matched → RELEVANT=0 → silent
|
|
363
|
-
# bypass on every jq-less machine. Fix: treat jq-missing the same
|
|
364
|
-
# as a parse failure — force RELEVANT=1 and let the CLI body decide.
|
|
365
|
-
# The CLI uses native Node JSON parsing so jq is not required for
|
|
366
|
-
# the actual enforcement.
|
|
213
|
+
# 0.34.0 round-6 P1 fix: pre-fix the shim set `PROBE="$INPUT"` (raw
|
|
214
|
+
# JSON payload) when jq was missing, then ran the awk relevance scan
|
|
215
|
+
# over JSON instead of a bare command. Fix: treat jq-missing the
|
|
216
|
+
# same as a parse failure — force RELEVANT=1 and let the CLI decide.
|
|
367
217
|
JQ_PARSE_FAILED=1
|
|
368
218
|
fi
|
|
369
|
-
# Split on shell separators then look for a segment whose head is
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
219
|
+
# Split on shell separators then look for a segment whose head is the
|
|
220
|
+
# configured trigger. The awk here masks chars inside "..." and '...'
|
|
221
|
+
# spans before splitting — same posture as the CLI splitSegments but
|
|
222
|
+
# coarser (no nested-shell unwrap; the CLI handles that). For
|
|
223
|
+
# relevance-pre-gate purposes the masker is sufficient.
|
|
224
|
+
#
|
|
225
|
+
# IMPORTANT: the env-prefix strip runs on the UNMASKED `seg` so the
|
|
226
|
+
# value's original quote characters are still present. Strip patterns
|
|
227
|
+
# accept quoted ("...", '...') AND unquoted (\S*) values so quoted env
|
|
228
|
+
# prefixes don't hide the trigger.
|
|
374
229
|
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
# Round-4 P2: if jq couldn't parse the payload, skip the awk pre-gate
|
|
381
|
-
# entirely and force RELEVANT=1 so the CLI body decides. The CLI's Zod
|
|
382
|
-
# parser fails closed on schema violations.
|
|
230
|
+
# 0.34.0 round-2 P1: env-prefix strip MUST accept quoted values.
|
|
231
|
+
# 0.34.0 round-5 P1: iteratively strip stacked env prefixes AND
|
|
232
|
+
# keyword prefixes (sudo / time / etc).
|
|
233
|
+
# 0.34.0 round-6 P2: only force relevance on shell-wrappers when a
|
|
234
|
+
# -c-class flag is present (so `bash scripts/setup.sh` doesn't trip).
|
|
383
235
|
if [ "$JQ_PARSE_FAILED" -eq 1 ]; then
|
|
384
236
|
RELEVANT=1
|
|
385
237
|
elif [ -n "$PROBE" ]; then
|
|
@@ -416,19 +268,14 @@ elif [ -n "$PROBE" ]; then
|
|
|
416
268
|
' | tr ';|&' '\n\n\n' | awk -v trigger="^${TRIGGER_RE}([[:space:]]|$)" '
|
|
417
269
|
{
|
|
418
270
|
seg = $0
|
|
419
|
-
# Strip leading whitespace and common prefixes (sudo, exec,
|
|
420
|
-
# time, VAR=value). Coarse — the CLI does this properly.
|
|
421
271
|
sub(/^[[:space:]]+/, "", seg)
|
|
422
272
|
# Iteratively strip env-var assignment prefix VAR=<value> +
|
|
423
|
-
# one-or-more spaces. <value> may be a double-quoted string,
|
|
424
|
-
#
|
|
425
|
-
#
|
|
426
|
-
#
|
|
427
|
-
#
|
|
428
|
-
#
|
|
429
|
-
# "awk: syntax error" at runtime, swallowed by `|| true`.
|
|
430
|
-
# Try quoted shapes first; bare last. Run until no more prefixes
|
|
431
|
-
# match (POSIX-legal stacked-env-prefix support).
|
|
273
|
+
# one-or-more spaces. <value> may be a double-quoted string, a
|
|
274
|
+
# single-quoted string, or a bare token (zero-or-more non-space
|
|
275
|
+
# chars). Quote characters in this comment are intentionally
|
|
276
|
+
# avoided — see round-4 P1 fix: a literal single-quote inside an
|
|
277
|
+
# awk comment inside a single-quoted shell heredoc terminates
|
|
278
|
+
# the bash string and causes "awk: syntax error" at runtime.
|
|
432
279
|
changed = 1
|
|
433
280
|
while (changed) {
|
|
434
281
|
changed = 0
|
|
@@ -442,11 +289,6 @@ elif [ -n "$PROBE" ]; then
|
|
|
442
289
|
seg = substr(seg, RLENGTH + 1); changed = 1; continue
|
|
443
290
|
}
|
|
444
291
|
}
|
|
445
|
-
# Iteratively strip keyword prefixes. Round-5 P1 fix: the pre-
|
|
446
|
-
# fix `sub` only stripped ONE keyword, so `time sudo git push`
|
|
447
|
-
# left `sudo git push` and missed the trigger. Loop until no
|
|
448
|
-
# more keyword prefixes match. Coarse — the CLI does this
|
|
449
|
-
# properly with full builtin-tokenization.
|
|
450
292
|
kchanged = 1
|
|
451
293
|
while (kchanged) {
|
|
452
294
|
kchanged = 0
|
|
@@ -454,35 +296,15 @@ elif [ -n "$PROBE" ]; then
|
|
|
454
296
|
kchanged = 1
|
|
455
297
|
}
|
|
456
298
|
}
|
|
457
|
-
# Round-5 P1
|
|
458
|
-
#
|
|
459
|
-
#
|
|
460
|
-
#
|
|
461
|
-
#
|
|
462
|
-
# bypass. The CLI does full nested-shell unwrapping via
|
|
463
|
-
# mvdan-sh; the shim should not try to compete.
|
|
464
|
-
#
|
|
465
|
-
# Round-6 P2 fix: the round-5 pattern matched ANY segment
|
|
466
|
-
# whose head started with a shell name, including benign
|
|
467
|
-
# bash-script-execution like `bash scripts/setup.sh`. That
|
|
468
|
-
# hit the fail-closed branch on unbuilt installs with "rea
|
|
469
|
-
# CLI is not built", even though the pre-0.34 hook only
|
|
470
|
-
# gated actual git push / git commit commands. Fix: require
|
|
471
|
-
# a -c-class flag (combined form -c, -lc, -lic, -cl, -cli,
|
|
472
|
-
# -li, -il, -ic — the bash WRAP pattern set) OR a separated
|
|
473
|
-
# --c flag, before forcing relevance.
|
|
474
|
-
# IMPORTANT: comments here avoid bare single-quote characters
|
|
475
|
-
# to prevent terminating the surrounding bash single-quoted
|
|
476
|
-
# string at runtime — see round-4 P1 lesson (awk: syntax
|
|
477
|
-
# error swallowed by `|| true`).
|
|
299
|
+
# Round-5 P1 + round-6 P2: if the head is a shell wrapper WITH a
|
|
300
|
+
# -c-class flag, FORCE relevance and let the CLI walk the payload.
|
|
301
|
+
# Comments here avoid bare single-quote characters to prevent
|
|
302
|
+
# terminating the surrounding bash single-quoted string at
|
|
303
|
+
# runtime — see round-4 P1 lesson.
|
|
478
304
|
if (match(seg, /^(bash|sh|zsh|dash|ksh|mksh|oksh|posh|yash|csh|tcsh|fish)[[:space:]]+(-([a-z]*c[a-z]*)|--c)([[:space:]]|$)/)) {
|
|
479
305
|
print "1"
|
|
480
306
|
exit
|
|
481
307
|
}
|
|
482
|
-
# Pre-flag variants: bash -l -c PAYLOAD, bash --noprofile -c
|
|
483
|
-
# PAYLOAD. Match shell then one-or-more flags then a -c-class
|
|
484
|
-
# flag. Comments deliberately have no inline quotes (round-4
|
|
485
|
-
# P1 lesson).
|
|
486
308
|
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:]]|$)/)) {
|
|
487
309
|
print "1"
|
|
488
310
|
exit
|
|
@@ -494,110 +316,53 @@ elif [ -n "$PROBE" ]; then
|
|
|
494
316
|
}
|
|
495
317
|
END { print "0" }
|
|
496
318
|
' | head -1)
|
|
497
|
-
# Fallback for environments without awk (vanishingly rare on the
|
|
498
|
-
# platforms rea supports): default to relevant=1 — over-trigger is
|
|
499
|
-
# safer than under-trigger.
|
|
500
319
|
case "$RELEVANT" in 0|1) ;; *) RELEVANT=1 ;; esac
|
|
501
320
|
fi
|
|
502
321
|
if [ "$RELEVANT" -eq 0 ]; then
|
|
503
322
|
exit 0
|
|
504
323
|
fi
|
|
505
324
|
|
|
506
|
-
# 6. Bypass env-var short-circuit.
|
|
507
|
-
#
|
|
508
|
-
# var) BEFORE invoking preflight. We mirror that here so an
|
|
509
|
-
# audited bypass works even when the CLI isn't built.
|
|
510
|
-
#
|
|
511
|
-
# Policy-driven var name: read `policy.review.local_review.bypass_env_var`
|
|
512
|
-
# if present; default to `REA_SKIP_LOCAL_REVIEW`. The CLI does its
|
|
513
|
-
# own per-segment inline-bypass evaluation; the shim only checks
|
|
514
|
-
# the operator-exported (process-env) form.
|
|
325
|
+
# 6. Bypass env-var short-circuit. Policy-driven var name; default
|
|
326
|
+
# REA_SKIP_LOCAL_REVIEW. Only honor POSIX-identifier-shaped names.
|
|
515
327
|
BYPASS_VAR="REA_SKIP_LOCAL_REVIEW"
|
|
516
328
|
POLICY_VAR=$(_lrg_read_policy review.local_review.bypass_env_var)
|
|
517
|
-
# Only honor POSIX-identifier-shaped names. Junk falls back to default.
|
|
518
329
|
if printf '%s' "$POLICY_VAR" | grep -qE '^[A-Za-z_][A-Za-z0-9_]*$'; then
|
|
519
330
|
BYPASS_VAR="$POLICY_VAR"
|
|
520
331
|
fi
|
|
521
|
-
# Read the configured env-var via indirect expansion (bash 3.2 compatible).
|
|
522
332
|
BYPASS_VALUE="${!BYPASS_VAR:-}"
|
|
523
333
|
if [ -n "$BYPASS_VALUE" ]; then
|
|
524
|
-
# Operator-exported bypass — allow. The CLI's per-segment inline
|
|
525
|
-
# bypass and multi-trigger laundering defense run when the CLI is
|
|
526
|
-
# reached; this shim short-circuit only covers the global
|
|
527
|
-
# process-env shape.
|
|
528
334
|
exit 0
|
|
529
335
|
fi
|
|
530
336
|
|
|
531
|
-
# 7. CLI
|
|
532
|
-
#
|
|
533
|
-
#
|
|
534
|
-
# the early sandbox check (round-5 P1) cleared them. Distinguish.
|
|
337
|
+
# 7. CLI required. If REA_ARGV is empty either (a) the CLI wasn't
|
|
338
|
+
# built/installed, OR (b) the early sandbox check cleared it.
|
|
339
|
+
# Distinguish.
|
|
535
340
|
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
536
|
-
if [ -n "$
|
|
537
|
-
|
|
341
|
+
if [ -n "$SANDBOX_EARLY_FAILURE" ]; then
|
|
342
|
+
shim_emit_sandbox_failure_banner "$SANDBOX_EARLY_FAILURE"
|
|
538
343
|
exit 2
|
|
539
344
|
fi
|
|
540
|
-
|
|
541
|
-
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
542
|
-
printf 'This shim fails closed because the pre-0.34.0 bash body enforced local-first review without a CLI.\n' >&2
|
|
345
|
+
shim_emit_cli_missing_banner
|
|
543
346
|
exit 2
|
|
544
347
|
fi
|
|
545
348
|
|
|
546
|
-
# 8.
|
|
349
|
+
# 8. (Redundant on the success path — the early sandbox already passed
|
|
350
|
+
# and cleared REA_ARGV on failure — but we re-emit the node-missing
|
|
351
|
+
# banner explicitly because node could have disappeared between
|
|
352
|
+
# section 3 and now in pathological setups.)
|
|
547
353
|
if ! command -v node >/dev/null 2>&1; then
|
|
548
|
-
|
|
549
|
-
printf 'Install Node 22+ (engines.node) to restore local-first review enforcement.\n' >&2
|
|
550
|
-
exit 2
|
|
551
|
-
fi
|
|
552
|
-
|
|
553
|
-
sandbox_check=$(node -e '
|
|
554
|
-
const fs = require("fs");
|
|
555
|
-
const path = require("path");
|
|
556
|
-
const cli = process.argv[1];
|
|
557
|
-
const projDir = process.argv[2];
|
|
558
|
-
let real, realProj;
|
|
559
|
-
try { real = fs.realpathSync(cli); } catch (e) {
|
|
560
|
-
process.stdout.write("bad:realpath"); process.exit(1);
|
|
561
|
-
}
|
|
562
|
-
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
563
|
-
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
564
|
-
}
|
|
565
|
-
const sep = path.sep;
|
|
566
|
-
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
567
|
-
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
568
|
-
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
569
|
-
}
|
|
570
|
-
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
571
|
-
let found = false;
|
|
572
|
-
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
573
|
-
const pj = path.join(cur, "package.json");
|
|
574
|
-
if (fs.existsSync(pj)) {
|
|
575
|
-
try {
|
|
576
|
-
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
577
|
-
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
578
|
-
} catch (e) { /* keep walking */ }
|
|
579
|
-
}
|
|
580
|
-
cur = path.dirname(cur);
|
|
581
|
-
}
|
|
582
|
-
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
583
|
-
process.stdout.write("ok");
|
|
584
|
-
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
585
|
-
|
|
586
|
-
if [ "$sandbox_check" != "ok" ]; then
|
|
587
|
-
printf 'rea: local-review-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
354
|
+
shim_emit_node_missing_banner
|
|
588
355
|
exit 2
|
|
589
356
|
fi
|
|
590
357
|
|
|
591
|
-
# 9. Version
|
|
592
|
-
probe_out=$("${REA_ARGV[@]}" hook
|
|
358
|
+
# 9. Version probe.
|
|
359
|
+
probe_out=$("${REA_ARGV[@]}" hook "$SHIM_NAME" --help 2>&1)
|
|
593
360
|
probe_status=$?
|
|
594
|
-
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e
|
|
595
|
-
|
|
596
|
-
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
597
|
-
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
361
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e "$SHIM_NAME"; then
|
|
362
|
+
shim_emit_version_skew_banner_blocking
|
|
598
363
|
exit 2
|
|
599
364
|
fi
|
|
600
365
|
|
|
601
|
-
# 10. Forward stdin
|
|
602
|
-
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook
|
|
366
|
+
# 10. Forward stdin.
|
|
367
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook "$SHIM_NAME"
|
|
603
368
|
exit $?
|