@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
package/hooks/secret-scanner.sh
CHANGED
|
@@ -1,230 +1,240 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# PreToolUse hook: secret-scanner.sh
|
|
3
|
-
#
|
|
4
|
-
# Scans content about to be written for credential patterns and blocks (exit 2)
|
|
5
|
-
# if real secrets are detected — before they ever touch disk.
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook secret-scanner`.
|
|
6
4
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
5
|
+
# Pre-0.34.0 the gate's full body lived here as bash (230 LOC, the
|
|
6
|
+
# awk line filter + 17-pattern catalog + placeholder-rejection + the
|
|
7
|
+
# MultiEdit fragment join). The migration to the Node binary moves
|
|
8
|
+
# the pattern catalog + filter + placeholder evaluation into
|
|
9
|
+
# `src/hooks/secret-scanner/index.ts`. This shim is the Claude Code
|
|
10
|
+
# dispatcher's view of the hook — it forwards stdin to the CLI and
|
|
11
|
+
# exits with whatever the CLI returns.
|
|
10
12
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# for comprehensive coverage.
|
|
13
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on no-match
|
|
14
|
+
# or MEDIUM-only advisory, exit 2 on HALT / HIGH match / malformed
|
|
15
|
+
# payload.
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# # Shim short-circuits (codex round-1 P2 fix)
|
|
18
|
+
#
|
|
19
|
+
# The 0.34.0 round-0 shim deferred ALL decisions to the CLI, including
|
|
20
|
+
# empty-content and `.env.example` suffix exclusion. That regressed
|
|
21
|
+
# benign workflows on fresh/unbuilt installs: clearing a file or
|
|
22
|
+
# editing an example env file would fail closed when `dist/cli/index.js`
|
|
23
|
+
# wasn't built yet.
|
|
24
|
+
#
|
|
25
|
+
# Round-1 P2 fix: replicate the pre-0.34.0 bash body's three
|
|
26
|
+
# short-circuits in the shim BEFORE CLI resolution:
|
|
27
|
+
# - Empty content (no `content`, `new_string`, `edits[]`, or
|
|
28
|
+
# `new_source` in the payload) → exit 0 silently.
|
|
29
|
+
# - file_path / notebook_path with `.env.example` or `.env.sample`
|
|
30
|
+
# suffix → exit 0 silently.
|
|
31
|
+
# The full pattern catalog + filter + placeholder rejection still
|
|
32
|
+
# lives in the CLI.
|
|
33
|
+
#
|
|
34
|
+
# # CLI-resolution trust boundary
|
|
35
|
+
#
|
|
36
|
+
# Mirrors the 0.32.0 final shim shape.
|
|
37
|
+
#
|
|
38
|
+
# # Fail-closed posture
|
|
39
|
+
#
|
|
40
|
+
# secret-scanner is Write/Edit/MultiEdit/NotebookEdit tier — the
|
|
41
|
+
# pre-0.34.0 bash body refused credential-bearing writes without any
|
|
42
|
+
# compiled CLI. Early-exit branches fail closed AFTER the shim
|
|
43
|
+
# short-circuits.
|
|
19
44
|
|
|
20
45
|
set -uo pipefail
|
|
21
46
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
# ── Dependency check ──────────────────────────────────────────────────────────
|
|
25
|
-
if ! command -v jq >/dev/null 2>&1; then
|
|
26
|
-
printf 'REA ERROR: jq is required but not installed.\n' >&2
|
|
27
|
-
printf 'Install: brew install jq OR apt-get install -y jq\n' >&2
|
|
28
|
-
exit 2
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
# ── HALT check ────────────────────────────────────────────────────────────────
|
|
32
|
-
# 0.16.0: HALT check sourced from shared _lib/halt-check.sh.
|
|
47
|
+
# 1. HALT check.
|
|
33
48
|
# shellcheck source=_lib/halt-check.sh
|
|
34
49
|
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
35
50
|
check_halt
|
|
36
51
|
REA_ROOT=$(rea_root)
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
# helpers handle Write content / Edit new_string / MultiEdit edits[] /
|
|
40
|
-
# NotebookEdit new_source with the same defensive type-guards. Adding
|
|
41
|
-
# the next write-tier tool is a one-line edit there, not a sweep
|
|
42
|
-
# across N hooks.
|
|
43
|
-
# shellcheck source=_lib/payload-read.sh
|
|
44
|
-
source "$(dirname "$0")/_lib/payload-read.sh"
|
|
53
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
45
54
|
|
|
46
|
-
|
|
47
|
-
|
|
55
|
+
# 2. Capture stdin once.
|
|
56
|
+
INPUT=$(cat)
|
|
48
57
|
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
# 3. Short-circuit: empty-content / file-suffix exclusion. Mirrors
|
|
59
|
+
# the pre-0.34.0 bash body's `[[ -z "$CONTENT" ]] && exit 0` and
|
|
60
|
+
# the `*.env.example | *.env.sample` suffix check. We do these in
|
|
61
|
+
# the shim so unbuilt installs don't fail closed on benign writes.
|
|
62
|
+
if command -v jq >/dev/null 2>&1; then
|
|
63
|
+
# Compose content the same way `parseWriteHookPayload` does:
|
|
64
|
+
# priority content > new_string > join(edits[].new_string) > new_source.
|
|
65
|
+
# 0.34.0 round-2 fix: every value goes through `tostring` so a
|
|
66
|
+
# non-string `new_string` (object/number/null) doesn't trip jq with
|
|
67
|
+
# a "Cannot iterate" error → empty CONTENT → exit 0 bypass. Mirrors
|
|
68
|
+
# the 0.14.0 secret-scanner fix that originally closed this class.
|
|
69
|
+
#
|
|
70
|
+
# 0.34.0 round-4 P2 fix: capture jq's exit code SEPARATELY rather
|
|
71
|
+
# than swallowing it with `|| true`. Pre-fix, invalid JSON or a
|
|
72
|
+
# schema mismatch yielded empty CONTENT → exit 0 silent allow.
|
|
73
|
+
# Post-fix we distinguish:
|
|
74
|
+
# - jq exit 0 + empty CONTENT → valid payload, no content (the
|
|
75
|
+
# bash hook also exit 0'd here)
|
|
76
|
+
# - jq exit 0 + non-empty → enter suffix-check + CLI forward
|
|
77
|
+
# - jq exit != 0 (parse fail) → fall through to CLI forward;
|
|
78
|
+
# the CLI re-parses with Zod and
|
|
79
|
+
# refuses on malformed payload
|
|
80
|
+
# The third branch does NOT exit 0 — we want CLI enforcement to
|
|
81
|
+
# decide. The CLI's parser fails closed.
|
|
82
|
+
CONTENT=$(printf '%s' "$INPUT" | jq -r '
|
|
83
|
+
(.tool_input.content // .tool_input.new_string //
|
|
84
|
+
(
|
|
85
|
+
if (.tool_input.edits | type) == "array"
|
|
86
|
+
then (.tool_input.edits | map((.new_string // "") | tostring) | join("\n"))
|
|
87
|
+
else ""
|
|
88
|
+
end
|
|
89
|
+
) //
|
|
90
|
+
.tool_input.new_source // ""
|
|
91
|
+
) | tostring
|
|
92
|
+
' 2>/dev/null)
|
|
93
|
+
jq_content_status=$?
|
|
94
|
+
FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
|
|
95
|
+
.tool_input.file_path // .tool_input.notebook_path // ""
|
|
96
|
+
' 2>/dev/null)
|
|
97
|
+
jq_path_status=$?
|
|
98
|
+
# Only honor the shim short-circuits when BOTH jq probes parsed
|
|
99
|
+
# cleanly. Otherwise forward to the CLI which fails closed via Zod.
|
|
100
|
+
if [ "$jq_content_status" -eq 0 ] && [ "$jq_path_status" -eq 0 ]; then
|
|
101
|
+
if [ -z "$CONTENT" ]; then
|
|
102
|
+
exit 0
|
|
103
|
+
fi
|
|
104
|
+
# Suffix-based exclusion. Mirrors the bash hook's:
|
|
105
|
+
# if [[ "$FILE_PATH" == *.env.example || "$FILE_PATH" == *.env.sample ]]; then exit 0; fi
|
|
106
|
+
case "$FILE_PATH" in
|
|
107
|
+
*.env.example|*.env.sample) exit 0 ;;
|
|
108
|
+
esac
|
|
109
|
+
fi
|
|
110
|
+
# jq parse failure → do NOT short-circuit. Fall through to the CLI
|
|
111
|
+
# forward at section 7. The CLI will refuse on malformed payload.
|
|
112
|
+
fi
|
|
113
|
+
# When jq is unavailable, fall through — the CLI does the same parse
|
|
114
|
+
# in TypeScript-space and will short-circuit on empty content there.
|
|
115
|
+
|
|
116
|
+
# 4. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
117
|
+
REA_ARGV=()
|
|
118
|
+
RESOLVED_CLI_PATH=""
|
|
119
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
120
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
121
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
122
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
123
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
124
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
51
125
|
fi
|
|
52
126
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
127
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
128
|
+
# 4b. Relevance pre-gate (round-7 P1). The round-0 shim refused ALL
|
|
129
|
+
# writes when the CLI was missing, but the pre-0.34.0 bash body
|
|
130
|
+
# only refused writes containing credential patterns. On a fresh
|
|
131
|
+
# install (`npx rea init` flow, pre-`pnpm build` checkout) the
|
|
132
|
+
# CLI isn't built yet but consumers need to write files — config,
|
|
133
|
+
# source, docs, etc. Fix: substring scan the content for the
|
|
134
|
+
# credential markers in the catalog. When CLI is missing AND no
|
|
135
|
+
# marker matches, exit 0 (the pre-0.34.0 body would have done
|
|
136
|
+
# the same — no pattern hit). When CLI is missing AND a marker
|
|
137
|
+
# DOES match, preserve fail-closed (refuse rather than silently
|
|
138
|
+
# allow a credential-shaped write).
|
|
139
|
+
#
|
|
140
|
+
# Substrings cover every entry in SECRET_PATTERNS (catalog in
|
|
141
|
+
# `src/hooks/secret-scanner/index.ts`). Coarse — over-trigger is
|
|
142
|
+
# fine, under-trigger is the bypass we MUST avoid. Same posture
|
|
143
|
+
# as the round-7 dangerous-bash relevance pre-gate.
|
|
144
|
+
CONTENT_FOR_SCAN=""
|
|
145
|
+
if [ -n "${CONTENT:-}" ]; then
|
|
146
|
+
CONTENT_FOR_SCAN="$CONTENT"
|
|
147
|
+
else
|
|
148
|
+
# CONTENT may not have been populated (jq missing, parse failure).
|
|
149
|
+
# Fall back to the raw payload so the substring scan still catches
|
|
150
|
+
# credential markers embedded in JSON-string form.
|
|
151
|
+
CONTENT_FOR_SCAN="$INPUT"
|
|
152
|
+
fi
|
|
153
|
+
CRED_RELEVANT=0
|
|
154
|
+
case "$CONTENT_FOR_SCAN" in
|
|
155
|
+
*"AKIA"*) CRED_RELEVANT=1 ;;
|
|
156
|
+
*"AWS_SECRET_ACCESS_KEY"*|*"aws_secret_access_key"*) CRED_RELEVANT=1 ;;
|
|
157
|
+
*"-----BEGIN"*) CRED_RELEVANT=1 ;;
|
|
158
|
+
*"sk-ant-"*) CRED_RELEVANT=1 ;;
|
|
159
|
+
*"ghp_"*|*"ghs_"*|*"gho_"*|*"ghu_"*|*"ghr_"*) CRED_RELEVANT=1 ;;
|
|
160
|
+
*"github_pat_"*) CRED_RELEVANT=1 ;;
|
|
161
|
+
*"sk_live_"*|*"rk_live_"*|*"pk_live_"*) CRED_RELEVANT=1 ;;
|
|
162
|
+
*"sk_test_"*|*"rk_test_"*|*"pk_test_"*) CRED_RELEVANT=1 ;;
|
|
163
|
+
*"whsec_"*) CRED_RELEVANT=1 ;;
|
|
164
|
+
*"SECRET"*|*"PASSWORD"*|*"PRIVATE_KEY"*|*"API_SECRET"*) CRED_RELEVANT=1 ;;
|
|
165
|
+
*"SUPABASE_SERVICE_ROLE_KEY"*|*"SUPABASE_ANON_KEY"*) CRED_RELEVANT=1 ;;
|
|
166
|
+
*"ANTHROPIC_API_KEY"*|*"STRIPE_SECRET"*|*"DATABASE_URL"*) CRED_RELEVANT=1 ;;
|
|
167
|
+
*"postgresql://"*) CRED_RELEVANT=1 ;;
|
|
168
|
+
*"eyJ"*) CRED_RELEVANT=1 ;; # JWT prefix — catches Supabase keys
|
|
169
|
+
esac
|
|
170
|
+
if [ "$CRED_RELEVANT" -eq 0 ]; then
|
|
171
|
+
# No credential marker. The pre-0.34.0 bash body would have allowed
|
|
172
|
+
# this write — exit 0 to unblock `npx rea init` and pre-build
|
|
173
|
+
# checkouts.
|
|
56
174
|
exit 0
|
|
57
175
|
fi
|
|
58
|
-
#
|
|
59
|
-
|
|
176
|
+
# Credential marker matched. Preserve fail-closed posture.
|
|
177
|
+
printf 'rea: secret-scanner cannot run — the rea CLI is not built.\n' >&2
|
|
178
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
179
|
+
printf 'This shim fails closed because the pre-0.34.0 bash body enforced secret refusal without a CLI.\n' >&2
|
|
180
|
+
exit 2
|
|
60
181
|
fi
|
|
61
182
|
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
printf 'SECRET-SCAN ERROR: Failed to create temp file — blocking write (fail-secure)\n' >&2
|
|
183
|
+
# 5. Realpath sandbox check.
|
|
184
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
185
|
+
printf 'rea: secret-scanner cannot run — `node` is not on PATH.\n' >&2
|
|
186
|
+
printf 'Install Node 22+ (engines.node) to restore credential refusal.\n' >&2
|
|
67
187
|
exit 2
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
VIOLATIONS_FILE=""
|
|
71
|
-
|
|
72
|
-
cleanup() {
|
|
73
|
-
rm -f "$FILTERED_FILE"
|
|
74
|
-
[[ -n "$VIOLATIONS_FILE" ]] && rm -f "$VIOLATIONS_FILE"
|
|
75
|
-
}
|
|
76
|
-
trap cleanup EXIT
|
|
77
|
-
|
|
78
|
-
printf '%s' "$CONTENT" | awk '
|
|
79
|
-
{
|
|
80
|
-
line = $0
|
|
81
|
-
trimmed = line
|
|
82
|
-
sub(/^[[:space:]]+/, "", trimmed)
|
|
83
|
-
# Skip shell comment lines only
|
|
84
|
-
if (substr(trimmed, 1, 1) == "#") next
|
|
85
|
-
# Skip lines where process.env.VAR is the RHS of an assignment
|
|
86
|
-
# Pattern: = process.env.SOMETHING (not just any mention of process.env)
|
|
87
|
-
if (trimmed ~ /=[[:space:]]*process\.env\.[A-Z_]+[^a-zA-Z]?$/) next
|
|
88
|
-
if (trimmed ~ /=[[:space:]]*process\.env\.[A-Z_]+[[:space:]]*[;,)]/) next
|
|
89
|
-
if (trimmed ~ /os\.environ\[/) next
|
|
90
|
-
print line
|
|
91
|
-
}
|
|
92
|
-
' > "$FILTERED_FILE" 2>/dev/null
|
|
93
|
-
|
|
94
|
-
if [[ ! -s "$FILTERED_FILE" ]]; then
|
|
95
|
-
exit 0
|
|
96
188
|
fi
|
|
97
189
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
190
|
+
sandbox_check=$(node -e '
|
|
191
|
+
const fs = require("fs");
|
|
192
|
+
const path = require("path");
|
|
193
|
+
const cli = process.argv[1];
|
|
194
|
+
const projDir = process.argv[2];
|
|
195
|
+
let real, realProj;
|
|
196
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
197
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
200
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
const sep = path.sep;
|
|
203
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
204
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
205
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
208
|
+
let found = false;
|
|
209
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
210
|
+
const pj = path.join(cur, "package.json");
|
|
211
|
+
if (fs.existsSync(pj)) {
|
|
212
|
+
try {
|
|
213
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
214
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
215
|
+
} catch (e) { /* keep walking */ }
|
|
216
|
+
}
|
|
217
|
+
cur = path.dirname(cur);
|
|
218
|
+
}
|
|
219
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
220
|
+
process.stdout.write("ok");
|
|
221
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
222
|
+
|
|
223
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
224
|
+
printf 'rea: secret-scanner FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
118
225
|
exit 2
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
scan_pattern() {
|
|
122
|
-
local SEVERITY="$1"
|
|
123
|
-
local LABEL="$2"
|
|
124
|
-
local PATTERN="$3"
|
|
125
|
-
local MATCHES GREP_EXIT MATCH SNIPPET
|
|
126
|
-
MATCHES=$(grep -oE -e "$PATTERN" "$FILTERED_FILE" 2>/dev/null)
|
|
127
|
-
GREP_EXIT=$?
|
|
128
|
-
[[ $GREP_EXIT -ne 0 ]] && return 0
|
|
129
|
-
[[ -z "$MATCHES" ]] && return 0
|
|
130
|
-
MATCHES=$(printf '%s\n' "$MATCHES" | head -5)
|
|
131
|
-
while IFS= read -r MATCH; do
|
|
132
|
-
[[ -z "$MATCH" ]] && continue
|
|
133
|
-
if is_placeholder "$MATCH"; then continue; fi
|
|
134
|
-
if [[ ${#MATCH} -gt 60 ]]; then
|
|
135
|
-
SNIPPET="${MATCH:0:60}..."
|
|
136
|
-
else
|
|
137
|
-
SNIPPET="$MATCH"
|
|
138
|
-
fi
|
|
139
|
-
printf '%s|%s|%s\n' "$SEVERITY" "$LABEL" "$SNIPPET" >> "$VIOLATIONS_FILE"
|
|
140
|
-
done <<< "$MATCHES"
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
# ── HIGH severity patterns ─────────────────────────────────────────────────────
|
|
144
|
-
|
|
145
|
-
scan_pattern "HIGH" "AWS Access Key ID" \
|
|
146
|
-
'AKIA[0-9A-Z]{16}'
|
|
147
|
-
|
|
148
|
-
scan_pattern "HIGH" "AWS Secret Access Key" \
|
|
149
|
-
'[Aa][Ww][Ss]_SECRET_ACCESS_KEY[[:space:]]*=[[:space:]]*[A-Za-z0-9/+]{40}'
|
|
150
|
-
|
|
151
|
-
scan_pattern "HIGH" "Private key block" \
|
|
152
|
-
'-----BEGIN (RSA|EC|OPENSSH|PGP) PRIVATE KEY-----'
|
|
153
|
-
|
|
154
|
-
scan_pattern "HIGH" "Anthropic API key" \
|
|
155
|
-
'sk-ant-api03-[A-Za-z0-9_-]{93}'
|
|
156
|
-
|
|
157
|
-
scan_pattern "HIGH" "Anthropic OAuth token" \
|
|
158
|
-
'sk-ant-oat01-[A-Za-z0-9_-]{86}'
|
|
159
|
-
|
|
160
|
-
scan_pattern "HIGH" "GitHub classic Personal Access Token" \
|
|
161
|
-
'gh[puors]_[A-Za-z0-9]{36}'
|
|
162
|
-
|
|
163
|
-
scan_pattern "HIGH" "GitHub fine-grained Personal Access Token" \
|
|
164
|
-
'github_pat_[A-Za-z0-9_]{82}'
|
|
165
|
-
|
|
166
|
-
scan_pattern "HIGH" "Stripe live secret/restricted key" \
|
|
167
|
-
'(sk|rk)_live_[A-Za-z0-9]{24,}'
|
|
168
|
-
|
|
169
|
-
scan_pattern "HIGH" "Stripe webhook signing secret" \
|
|
170
|
-
'whsec_[A-Za-z0-9+/]{40,}'
|
|
171
|
-
|
|
172
|
-
scan_pattern "HIGH" "Generic secret assignment (double-quoted)" \
|
|
173
|
-
'(SECRET|PASSWORD|PRIVATE_KEY|API_SECRET)[[:space:]]*=[[:space:]]*"[^"]{20,}"'
|
|
174
|
-
|
|
175
|
-
scan_pattern "HIGH" "Generic secret assignment (single-quoted)" \
|
|
176
|
-
"(SECRET|PASSWORD|PRIVATE_KEY|API_SECRET)[[:space:]]*=[[:space:]]*'[^']{20,}'"
|
|
177
|
-
|
|
178
|
-
scan_pattern "HIGH" "Supabase service role key (JWT)" \
|
|
179
|
-
'SUPABASE_SERVICE_ROLE_KEY[[:space:]]*=[[:space:]]*["\'"'"']eyJ[A-Za-z0-9._-]{50,}'
|
|
180
|
-
|
|
181
|
-
# ── MEDIUM severity patterns ───────────────────────────────────────────────────
|
|
182
|
-
|
|
183
|
-
scan_pattern "MEDIUM" ".env credential assignment" \
|
|
184
|
-
'^(ANTHROPIC_API_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL|STRIPE_SECRET)[[:space:]]*=[[:space:]]*[^[:space:]]+'
|
|
185
|
-
|
|
186
|
-
scan_pattern "MEDIUM" "Stripe test API key (real credential, test env)" \
|
|
187
|
-
'(sk|pk|rk)_test_[A-Za-z0-9]{24,}'
|
|
188
|
-
|
|
189
|
-
scan_pattern "MEDIUM" "Stripe live publishable key" \
|
|
190
|
-
'pk_live_[A-Za-z0-9]{24,}'
|
|
191
|
-
|
|
192
|
-
scan_pattern "MEDIUM" "Hardcoded DB connection string with password" \
|
|
193
|
-
'postgresql://[^:]+:[^@]{8,}@'
|
|
194
|
-
|
|
195
|
-
scan_pattern "MEDIUM" "Supabase anon key in non-client context" \
|
|
196
|
-
'SUPABASE_ANON_KEY[[:space:]]*=[[:space:]]*["\'"'"']eyJ[A-Za-z0-9._-]{50,}'
|
|
197
|
-
|
|
198
|
-
if [[ ! -s "$VIOLATIONS_FILE" ]]; then
|
|
199
|
-
exit 0
|
|
200
226
|
fi
|
|
201
227
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
COUNT=0
|
|
210
|
-
while IFS='|' read -r SEVERITY LABEL SNIPPET; do
|
|
211
|
-
[[ -z "$SEVERITY" ]] && continue
|
|
212
|
-
COUNT=$(( COUNT + 1 ))
|
|
213
|
-
if [[ $COUNT -gt 5 ]]; then break; fi
|
|
214
|
-
printf ' %s: %s — '"'"'%s'"'"'\n' "$SEVERITY" "$LABEL" "$SNIPPET"
|
|
215
|
-
done < "$VIOLATIONS_FILE"
|
|
216
|
-
printf 'Block reason: Writing credentials to disk risks exposure via git history.\n'
|
|
217
|
-
printf 'Fix: Load credentials from environment variables — never hardcode secrets.\n'
|
|
218
|
-
} >&2
|
|
228
|
+
# 6. Version-probe.
|
|
229
|
+
probe_out=$("${REA_ARGV[@]}" hook secret-scanner --help 2>&1)
|
|
230
|
+
probe_status=$?
|
|
231
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'secret-scanner'; then
|
|
232
|
+
printf 'rea: this shim requires the `rea hook secret-scanner` subcommand (introduced in 0.34.0).\n' >&2
|
|
233
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
234
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
219
235
|
exit 2
|
|
220
236
|
fi
|
|
221
237
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
[[ -z "$SEVERITY" ]] && continue
|
|
226
|
-
printf ' %s: %s — '"'"'%s'"'"'\n' "$SEVERITY" "$LABEL" "$SNIPPET"
|
|
227
|
-
done < "$VIOLATIONS_FILE"
|
|
228
|
-
printf 'Note: Heuristic match — may be a false positive. If real, load from environment.\n'
|
|
229
|
-
} >&2
|
|
230
|
-
exit 0
|
|
238
|
+
# 7. Forward stdin (already captured up-front).
|
|
239
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook secret-scanner
|
|
240
|
+
exit $?
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bookedsolid/rea",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Agentic governance layer for Claude Code — policy enforcement, hook-based safety gates, audit logging, and Codex-integrated adversarial review for AI-assisted projects",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Booked Solid Technology <oss@bookedsolid.tech> (https://bookedsolid.tech)",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: dangerous-bash-interceptor.sh
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook dangerous-bash-interceptor`.
|
|
4
|
+
#
|
|
5
|
+
# Pre-0.34.0 the gate's full body lived here as bash (414 LOC, every
|
|
6
|
+
# refusal class H1-H17 + M1 plus their bypass-corpus regressions). The
|
|
7
|
+
# migration to the parser-backed Node binary moves all of that into
|
|
8
|
+
# `src/hooks/dangerous-bash-interceptor/index.ts`. This shim is the
|
|
9
|
+
# Claude Code dispatcher's view of the hook — it forwards stdin to
|
|
10
|
+
# the CLI and exits with whatever the CLI returns.
|
|
11
|
+
#
|
|
12
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on
|
|
13
|
+
# pass-through / MEDIUM-only advisory, exit 2 on HALT / HIGH rule
|
|
14
|
+
# match / malformed payload (fail-closed).
|
|
15
|
+
#
|
|
16
|
+
# # CLI-resolution trust boundary
|
|
17
|
+
#
|
|
18
|
+
# Mirrors the 0.32.0 final shim shape (round-8 of the codex iteration
|
|
19
|
+
# on the three Phase 1 pilots). The resolved CLI MUST live INSIDE
|
|
20
|
+
# realpath(CLAUDE_PROJECT_DIR) AND have an ancestor `package.json`
|
|
21
|
+
# whose `name` is `@bookedsolid/rea`. Defends against symlink-out and
|
|
22
|
+
# tarball-replacement attacks on the resolved CLI.
|
|
23
|
+
#
|
|
24
|
+
# # Fail-closed posture
|
|
25
|
+
#
|
|
26
|
+
# dangerous-bash-interceptor is the agent-runaway gate — the pre-0.34.0
|
|
27
|
+
# bash body refused destructive commands without any compiled CLI. The
|
|
28
|
+
# early-exit branches (CLI missing, node missing, sandbox failed,
|
|
29
|
+
# version skew) fail closed AFTER the relevance pre-gate passes.
|
|
30
|
+
# Irrelevant Bash calls exit 0 regardless of CLI state.
|
|
31
|
+
#
|
|
32
|
+
# # Relevance pre-gate
|
|
33
|
+
#
|
|
34
|
+
# 0.34.0 round-7 P1 fix: the pre-0.34.0 bash body refused destructive
|
|
35
|
+
# commands without any compiled CLI. The round-0 shim preserved that
|
|
36
|
+
# fail-closed-on-CLI-missing posture for ALL Bash, but that's stricter
|
|
37
|
+
# than the pre-0.34.0 body which only refused commands matching the
|
|
38
|
+
# destructive catalog. On a fresh / unbuilt install (`npx rea init`,
|
|
39
|
+
# pre-`pnpm build` checkout) the shim blocked benign Bash like `ls`,
|
|
40
|
+
# `mkdir`, `pnpm install` — defeating the install path itself.
|
|
41
|
+
#
|
|
42
|
+
# Fix: substring pre-gate over the EXTRACTED command (not raw payload —
|
|
43
|
+
# the local-review-gate round-2 lesson). When CLI is missing AND no
|
|
44
|
+
# destructive-keyword appears in the extracted command, exit 0 (the
|
|
45
|
+
# pre-0.34.0 bash body would have done the same — there's no rule to
|
|
46
|
+
# match). When CLI is missing AND a destructive-keyword DOES appear,
|
|
47
|
+
# preserve the original fail-closed posture (we'd rather refuse than
|
|
48
|
+
# silently allow a destructive command).
|
|
49
|
+
#
|
|
50
|
+
# The keyword list is coarse — it over-triggers (e.g. `git status` hits
|
|
51
|
+
# `git` substring) but that's fine: the CLI does the real evaluation
|
|
52
|
+
# and lets benign forms through. Over-trigger costs one node-spawn;
|
|
53
|
+
# under-trigger is the bypass we MUST avoid. Same posture as the
|
|
54
|
+
# 0.32.0 secret-scanner `gh issue create` substring fix.
|
|
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. Capture stdin once. The CLI consumes it via stdin pipe below.
|
|
67
|
+
INPUT=$(cat)
|
|
68
|
+
|
|
69
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
70
|
+
REA_ARGV=()
|
|
71
|
+
RESOLVED_CLI_PATH=""
|
|
72
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
73
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
74
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
75
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
76
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
77
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# 3b. Relevance pre-gate (round-7 P1). Only used when the CLI is
|
|
81
|
+
# missing — when present, every Bash call goes through the CLI.
|
|
82
|
+
# Extract the command string from the payload, then substring-scan
|
|
83
|
+
# it for destructive-catalog keywords. Mirrors the H1-H17 + M1
|
|
84
|
+
# rule heads.
|
|
85
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
86
|
+
CLI_MISSING_CMD=""
|
|
87
|
+
if command -v jq >/dev/null 2>&1; then
|
|
88
|
+
# Match the CLI's payload schema: tool_input.command. tostring so
|
|
89
|
+
# a non-string value (object/number) doesn't blow up jq.
|
|
90
|
+
CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
|
|
91
|
+
(.tool_input.command // "") | tostring
|
|
92
|
+
' 2>/dev/null || true)
|
|
93
|
+
else
|
|
94
|
+
# jq missing — fall back to scanning the raw payload. Over-trigger
|
|
95
|
+
# by design (the CLI is the source of truth; this is fail-closed
|
|
96
|
+
# only when keywords match). Substring scan still catches the
|
|
97
|
+
# destructive forms in JSON-string-encoded payloads.
|
|
98
|
+
CLI_MISSING_CMD="$INPUT"
|
|
99
|
+
fi
|
|
100
|
+
# If we couldn't extract a command, treat as relevant (fail closed).
|
|
101
|
+
CLI_MISSING_RELEVANT=0
|
|
102
|
+
if [ -z "$CLI_MISSING_CMD" ]; then
|
|
103
|
+
# Empty command (or non-Bash payload). The pre-0.34.0 bash body
|
|
104
|
+
# would have exited 0 here — no command, no rule match.
|
|
105
|
+
exit 0
|
|
106
|
+
fi
|
|
107
|
+
# Substring scan. Keywords cover every rule head H1-H17 + M1. Coarse
|
|
108
|
+
# by design — we're a safety net, not the source of truth. The CLI
|
|
109
|
+
# does the precise per-rule evaluation when reachable.
|
|
110
|
+
case "$CLI_MISSING_CMD" in
|
|
111
|
+
*"git "*) CLI_MISSING_RELEVANT=1 ;;
|
|
112
|
+
*"git "*) CLI_MISSING_RELEVANT=1 ;; # tab after git
|
|
113
|
+
*"rm "*|*"rm "*) CLI_MISSING_RELEVANT=1 ;;
|
|
114
|
+
*"psql"*|*"pgcli"*) CLI_MISSING_RELEVANT=1 ;;
|
|
115
|
+
*"DROP "*|*"DROP "*) CLI_MISSING_RELEVANT=1 ;;
|
|
116
|
+
*"kill "*|*"kill "*|*"killall"*) CLI_MISSING_RELEVANT=1 ;;
|
|
117
|
+
*"HUSKY="*) CLI_MISSING_RELEVANT=1 ;;
|
|
118
|
+
*"curl"*|*"wget"*) CLI_MISSING_RELEVANT=1 ;;
|
|
119
|
+
*"REA_BYPASS"*) CLI_MISSING_RELEVANT=1 ;;
|
|
120
|
+
*"alias "*|*"function "*) CLI_MISSING_RELEVANT=1 ;;
|
|
121
|
+
*"core.hooksPath"*|*"core.hookspath"*) CLI_MISSING_RELEVANT=1 ;;
|
|
122
|
+
*"npm "*|*"pnpm "*|*"yarn "*) CLI_MISSING_RELEVANT=1 ;;
|
|
123
|
+
*"--no-verify"*|*"--force"*) CLI_MISSING_RELEVANT=1 ;;
|
|
124
|
+
esac
|
|
125
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
126
|
+
# No destructive-keyword in the extracted command. The pre-0.34.0
|
|
127
|
+
# bash body would have allowed this — exit 0 to preserve install-
|
|
128
|
+
# path / unbuilt-checkout workflows.
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
# Keyword matched. Preserve fail-closed posture — the pre-0.34.0
|
|
132
|
+
# bash body would have evaluated this command and potentially refused.
|
|
133
|
+
printf 'rea: dangerous-bash-interceptor cannot run — the rea CLI is not built.\n' >&2
|
|
134
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
135
|
+
printf 'This shim fails closed because the pre-0.34.0 bash body enforced destructive-command refusal without a CLI.\n' >&2
|
|
136
|
+
exit 2
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# 4. Realpath sandbox check.
|
|
140
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
141
|
+
printf 'rea: dangerous-bash-interceptor cannot run — `node` is not on PATH.\n' >&2
|
|
142
|
+
printf 'Install Node 22+ (engines.node) to restore destructive-command refusal.\n' >&2
|
|
143
|
+
exit 2
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
sandbox_check=$(node -e '
|
|
147
|
+
const fs = require("fs");
|
|
148
|
+
const path = require("path");
|
|
149
|
+
const cli = process.argv[1];
|
|
150
|
+
const projDir = process.argv[2];
|
|
151
|
+
let real, realProj;
|
|
152
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
153
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
156
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const sep = path.sep;
|
|
159
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
160
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
161
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
164
|
+
let found = false;
|
|
165
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
166
|
+
const pj = path.join(cur, "package.json");
|
|
167
|
+
if (fs.existsSync(pj)) {
|
|
168
|
+
try {
|
|
169
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
170
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
171
|
+
} catch (e) { /* keep walking */ }
|
|
172
|
+
}
|
|
173
|
+
cur = path.dirname(cur);
|
|
174
|
+
}
|
|
175
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
176
|
+
process.stdout.write("ok");
|
|
177
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
178
|
+
|
|
179
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
180
|
+
printf 'rea: dangerous-bash-interceptor FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
181
|
+
exit 2
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# 5. Version-probe.
|
|
185
|
+
probe_out=$("${REA_ARGV[@]}" hook dangerous-bash-interceptor --help 2>&1)
|
|
186
|
+
probe_status=$?
|
|
187
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'dangerous-bash-interceptor'; then
|
|
188
|
+
printf 'rea: this shim requires the `rea hook dangerous-bash-interceptor` subcommand (introduced in 0.34.0).\n' >&2
|
|
189
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
190
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
191
|
+
exit 2
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
# 6. Forward stdin (already captured up-front).
|
|
195
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook dangerous-bash-interceptor
|
|
196
|
+
exit $?
|