@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.
@@ -1,230 +1,240 @@
1
1
  #!/bin/bash
2
2
  # PreToolUse hook: secret-scanner.sh
3
- # Fires BEFORE every Write or Edit tool call.
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
- # Content extraction:
8
- # Write tool tool_input.content
9
- # Edit tool → tool_input.new_string
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
- # NOTE: This hook is a last-resort pre-write guard. The primary secret gate is
12
- # gitleaks running in the pre-commit hook. This hook stops obvious credentials
13
- # before they hit disk. It cannot catch all encoding tricks — rely on gitleaks
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
- # Exit codes:
17
- # 0 = no secrets detected — allow the tool to proceed
18
- # 2 = secrets detected — block the tool call
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
- INPUT=$(cat)
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
- # 0.16.0: payload extraction moved to `_lib/payload-read.sh`. The shared
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
- FILE_PATH=$(extract_file_path "$INPUT")
47
- CONTENT=$(extract_write_content "$INPUT")
55
+ # 2. Capture stdin once.
56
+ INPUT=$(cat)
48
57
 
49
- if [[ -z "$CONTENT" ]]; then
50
- exit 0
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
- # Smart file-path exclusions (suffix-based only no directory exclusions)
54
- if [[ -n "$FILE_PATH" ]]; then
55
- if [[ "$FILE_PATH" == *.env.example || "$FILE_PATH" == *.env.sample ]]; then
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
- # Test files are NOT excluded — real secrets in test files must be caught.
59
- # The is_placeholder() function handles false positives from test fixtures.
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
- # Build line-filtered content
63
- # Strip: shell comment lines (#) and lines where process.env.VAR is the RHS of an assignment
64
- # NOT stripped: lines that merely mention process.env somewhere (bypass vector if too broad)
65
- FILTERED_FILE=$(mktemp "${TMPDIR:-/tmp}/rea-secret-scan-XXXXXX") || {
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
- is_placeholder() {
99
- local MATCH
100
- MATCH=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
101
- [[ "$MATCH" =~ \<[a-z_]+\> ]] && return 0
102
- [[ "$MATCH" =~ your_key_here ]] && return 0
103
- [[ "$MATCH" =~ your_api_key ]] && return 0
104
- [[ "$MATCH" =~ your_secret ]] && return 0
105
- [[ "$MATCH" =~ placeholder ]] && return 0
106
- [[ "$MATCH" =~ changeme ]] && return 0
107
- [[ "$MATCH" =~ insert.*here ]] && return 0
108
- # Prefix checks: require full placeholder compound, not just a prefix
109
- [[ "$MATCH" =~ ^(test|fake|mock|demo|example)_(key|token|secret|credential|api)$ ]] && return 0
110
- [[ "$MATCH" =~ ^test_[a-z_]+_key$ ]] && return 0
111
- # Repeated-character dummies (aaaaaaa, 1111111, etc.)
112
- if printf '%s' "$MATCH" | grep -qE '^(.)\1{7,}$'; then return 0; fi
113
- return 1
114
- }
115
-
116
- VIOLATIONS_FILE=$(mktemp "${TMPDIR:-/tmp}/rea-secret-violations-XXXXXX") || {
117
- printf 'SECRET-SCAN ERROR: Failed to create violations file blocking write (fail-secure)\n' >&2
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
- FILE_BASENAME=$(basename "${FILE_PATH:-unknown}")
203
- HIGH_COUNT=$(grep -cF 'HIGH|' "$VIOLATIONS_FILE" 2>/dev/null || true)
204
- : "${HIGH_COUNT:=0}"
205
-
206
- if [[ "$HIGH_COUNT" -gt 0 ]]; then
207
- {
208
- printf 'SECRET DETECTED: Potential credential in %s\n' "$FILE_BASENAME"
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
- printf 'SECRET-SCAN WARN: Low-confidence credential pattern in %s (advisory not blocking)\n' "$FILE_BASENAME"
224
- while IFS='|' read -r SEVERITY LABEL SNIPPET; do
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.33.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 $?