@bookedsolid/rea 0.33.0 → 0.35.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 +49 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.d.ts +102 -0
- package/dist/hooks/_lib/segments.js +290 -0
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -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/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.d.ts +143 -0
- package/dist/hooks/secret-scanner/index.js +404 -0
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/dangerous-bash-interceptor.sh +168 -386
- package/hooks/local-review-gate.sh +523 -410
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/secret-scanner.sh +210 -200
- package/hooks/settings-protection.sh +171 -549
- package/package.json +1 -1
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
- package/templates/local-review-gate.dogfood-staged.sh +573 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/secret-scanner.dogfood-staged.sh +240 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: protected-paths-bash-gate.sh
|
|
3
|
+
# 0.35.0+ — Node-binary shim for `rea hook protected-paths-bash-gate`.
|
|
4
|
+
#
|
|
5
|
+
# Pre-0.35.0 this was a thin bash shim over `rea hook scan-bash --mode
|
|
6
|
+
# protected` (the parser-backed AST walker that replaces the 536-line
|
|
7
|
+
# pre-0.23.0 regex pipeline). The full bash body is preserved at
|
|
8
|
+
# `__tests__/hooks/parity/baselines/protected-paths-bash-gate.sh.pre-0.35.0`.
|
|
9
|
+
#
|
|
10
|
+
# This shim now resolves the CLI through the same 2-tier sandboxed
|
|
11
|
+
# resolver as the 0.32.0+ pilots and calls `rea hook protected-paths-
|
|
12
|
+
# bash-gate` directly — eliminating the shim → CLI → scanner-module
|
|
13
|
+
# subprocess hop entirely.
|
|
14
|
+
#
|
|
15
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on allow,
|
|
16
|
+
# exit 2 on HALT / verdict block / malformed payload / sandbox fail.
|
|
17
|
+
#
|
|
18
|
+
# # CLI-resolution trust boundary
|
|
19
|
+
#
|
|
20
|
+
# Mirrors the 0.32.0 final shim shape.
|
|
21
|
+
#
|
|
22
|
+
# # Fail-closed posture
|
|
23
|
+
#
|
|
24
|
+
# protected-paths-bash-gate is a Tier-1 security gate. The pre-0.35.0
|
|
25
|
+
# bash body refused on uncertainty. Early-exit branches fail closed
|
|
26
|
+
# AFTER the relevance pre-gate passes. Irrelevant Bash calls exit 0
|
|
27
|
+
# regardless of CLI state.
|
|
28
|
+
#
|
|
29
|
+
# # Relevance pre-gate
|
|
30
|
+
#
|
|
31
|
+
# Substring scan over the extracted command for any of the protected-
|
|
32
|
+
# path markers: .claude/, .husky/, .rea/policy.yaml, .rea/HALT, the
|
|
33
|
+
# verdict cache paths. When the CLI is missing AND none of these
|
|
34
|
+
# substrings appear, exit 0 (the pre-0.35.0 bash body would have
|
|
35
|
+
# allowed). When the CLI is missing AND a marker DOES match, preserve
|
|
36
|
+
# fail-closed.
|
|
37
|
+
|
|
38
|
+
set -uo pipefail
|
|
39
|
+
|
|
40
|
+
# 1. HALT check.
|
|
41
|
+
# shellcheck source=_lib/halt-check.sh
|
|
42
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
43
|
+
check_halt
|
|
44
|
+
REA_ROOT=$(rea_root)
|
|
45
|
+
|
|
46
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
47
|
+
|
|
48
|
+
# 2. Capture stdin once.
|
|
49
|
+
INPUT=$(cat)
|
|
50
|
+
|
|
51
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
52
|
+
REA_ARGV=()
|
|
53
|
+
RESOLVED_CLI_PATH=""
|
|
54
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
55
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
56
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
57
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
58
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
59
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# 3b. Relevance pre-gate. Only used when the CLI is missing.
|
|
63
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
64
|
+
CLI_MISSING_CMD=""
|
|
65
|
+
if command -v jq >/dev/null 2>&1; then
|
|
66
|
+
CLI_MISSING_CMD=$(printf '%s' "$INPUT" | jq -r '
|
|
67
|
+
(.tool_input.command // "") | tostring
|
|
68
|
+
' 2>/dev/null || true)
|
|
69
|
+
else
|
|
70
|
+
CLI_MISSING_CMD="$INPUT"
|
|
71
|
+
fi
|
|
72
|
+
if [ -z "$CLI_MISSING_CMD" ]; then
|
|
73
|
+
exit 0
|
|
74
|
+
fi
|
|
75
|
+
CLI_MISSING_RELEVANT=0
|
|
76
|
+
case "$CLI_MISSING_CMD" in
|
|
77
|
+
*".claude/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
78
|
+
*".husky/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
79
|
+
*".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
|
|
80
|
+
*".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
|
|
81
|
+
*".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
|
|
82
|
+
*".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
|
|
83
|
+
esac
|
|
84
|
+
# Codex round-1 P2 fix: scan policy.protected_writes entries too so a
|
|
85
|
+
# consumer-defined protected path isn't silently allowed when the CLI
|
|
86
|
+
# is missing. Read the policy via the same awk parser the consumer-
|
|
87
|
+
# facing relevance pre-gates use for blocked_paths.
|
|
88
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
89
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
90
|
+
if [ -f "$POLICY_FILE" ]; then
|
|
91
|
+
while IFS= read -r entry; do
|
|
92
|
+
[ -z "$entry" ] && continue
|
|
93
|
+
base="$entry"
|
|
94
|
+
case "$base" in
|
|
95
|
+
*/) base="${base%/}" ;;
|
|
96
|
+
esac
|
|
97
|
+
[ -z "$base" ] && continue
|
|
98
|
+
case "$CLI_MISSING_CMD" in
|
|
99
|
+
*"$base"*) CLI_MISSING_RELEVANT=1; break ;;
|
|
100
|
+
esac
|
|
101
|
+
done < <(awk '
|
|
102
|
+
/^protected_writes:/ { in_block=1; next }
|
|
103
|
+
in_block && /^[[:space:]]*-/ {
|
|
104
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
105
|
+
gsub(/^["'\'']/, "")
|
|
106
|
+
gsub(/["'\'']$/, "")
|
|
107
|
+
print
|
|
108
|
+
next
|
|
109
|
+
}
|
|
110
|
+
in_block && /^[^[:space:]-]/ { in_block=0 }
|
|
111
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
112
|
+
fi
|
|
113
|
+
fi
|
|
114
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
printf 'rea: protected-paths-bash-gate cannot run — the rea CLI is not built.\n' >&2
|
|
118
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
119
|
+
printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
|
|
120
|
+
exit 2
|
|
121
|
+
fi
|
|
122
|
+
|
|
123
|
+
# 4. Realpath sandbox check.
|
|
124
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
125
|
+
printf 'rea: protected-paths-bash-gate cannot run — `node` is not on PATH.\n' >&2
|
|
126
|
+
printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
|
|
127
|
+
exit 2
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
sandbox_check=$(node -e '
|
|
131
|
+
const fs = require("fs");
|
|
132
|
+
const path = require("path");
|
|
133
|
+
const cli = process.argv[1];
|
|
134
|
+
const projDir = process.argv[2];
|
|
135
|
+
let real, realProj;
|
|
136
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
137
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
140
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const sep = path.sep;
|
|
143
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
144
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
145
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
// Codex round-1 P1 fix: enforce dist/cli/index.js shape (see
|
|
148
|
+
// settings-protection.sh).
|
|
149
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
150
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
151
|
+
process.stdout.write("bad:cli-shape"); process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
154
|
+
let found = false;
|
|
155
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
156
|
+
const pj = path.join(cur, "package.json");
|
|
157
|
+
if (fs.existsSync(pj)) {
|
|
158
|
+
try {
|
|
159
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
160
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
161
|
+
} catch (e) { /* keep walking */ }
|
|
162
|
+
}
|
|
163
|
+
cur = path.dirname(cur);
|
|
164
|
+
}
|
|
165
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
166
|
+
process.stdout.write("ok");
|
|
167
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
168
|
+
|
|
169
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
170
|
+
printf 'rea: protected-paths-bash-gate FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
171
|
+
exit 2
|
|
172
|
+
fi
|
|
173
|
+
|
|
174
|
+
# 5. Version-probe.
|
|
175
|
+
probe_out=$("${REA_ARGV[@]}" hook protected-paths-bash-gate --help 2>&1)
|
|
176
|
+
probe_status=$?
|
|
177
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'protected-paths-bash-gate'; then
|
|
178
|
+
printf 'rea: this shim requires the `rea hook protected-paths-bash-gate` subcommand (introduced in 0.35.0).\n' >&2
|
|
179
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
180
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
181
|
+
exit 2
|
|
182
|
+
fi
|
|
183
|
+
|
|
184
|
+
# 6. Forward stdin (already captured up-front).
|
|
185
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook protected-paths-bash-gate
|
|
186
|
+
exit $?
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: secret-scanner.sh
|
|
3
|
+
# 0.34.0+ — Node-binary shim for `rea hook secret-scanner`.
|
|
4
|
+
#
|
|
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.
|
|
12
|
+
#
|
|
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.
|
|
16
|
+
#
|
|
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.
|
|
44
|
+
|
|
45
|
+
set -uo pipefail
|
|
46
|
+
|
|
47
|
+
# 1. HALT check.
|
|
48
|
+
# shellcheck source=_lib/halt-check.sh
|
|
49
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
50
|
+
check_halt
|
|
51
|
+
REA_ROOT=$(rea_root)
|
|
52
|
+
|
|
53
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
54
|
+
|
|
55
|
+
# 2. Capture stdin once.
|
|
56
|
+
INPUT=$(cat)
|
|
57
|
+
|
|
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"
|
|
125
|
+
fi
|
|
126
|
+
|
|
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.
|
|
174
|
+
exit 0
|
|
175
|
+
fi
|
|
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
|
|
181
|
+
fi
|
|
182
|
+
|
|
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
|
|
187
|
+
exit 2
|
|
188
|
+
fi
|
|
189
|
+
|
|
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
|
|
225
|
+
exit 2
|
|
226
|
+
fi
|
|
227
|
+
|
|
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
|
|
235
|
+
exit 2
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
# 7. Forward stdin (already captured up-front).
|
|
239
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook secret-scanner
|
|
240
|
+
exit $?
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# PreToolUse hook: settings-protection.sh
|
|
3
|
+
# 0.35.0+ — Node-binary shim for `rea hook settings-protection`.
|
|
4
|
+
#
|
|
5
|
+
# Pre-0.35.0 this was the LARGEST hook in the repo at 582 LOC of bash:
|
|
6
|
+
# §5a `..` traversal reject, §5a-bis interior `/./` reject, §5b
|
|
7
|
+
# extension-surface allow-list (with final-component + intermediate-
|
|
8
|
+
# directory symlink refusal), §6 hard-protected pattern resolution
|
|
9
|
+
# (PROTECTED_PATTERNS sourced from `_lib/protected-paths.sh` with
|
|
10
|
+
# `protected_writes` override + `protected_paths_relax` subtractor),
|
|
11
|
+
# §6c intermediate-symlink resolution against the hard-protected list,
|
|
12
|
+
# §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-
|
|
13
|
+
# chained audit append (fail-closed). The full bash body is preserved
|
|
14
|
+
# at `__tests__/hooks/parity/baselines/settings-protection.sh.pre-0.35.0`.
|
|
15
|
+
#
|
|
16
|
+
# The migration moves every section into
|
|
17
|
+
# `src/hooks/settings-protection/index.ts`. This shim is the Claude Code
|
|
18
|
+
# dispatcher's view of the hook — it forwards stdin to the CLI and
|
|
19
|
+
# exits with whatever the CLI returns.
|
|
20
|
+
#
|
|
21
|
+
# Behavioral contract is preserved byte-for-byte: exit 0 on allow,
|
|
22
|
+
# exit 2 on HALT / traversal-reject / interior-dot-reject / protected
|
|
23
|
+
# match / patch-session-mismatch / malformed payload.
|
|
24
|
+
#
|
|
25
|
+
# # CLI-resolution trust boundary
|
|
26
|
+
#
|
|
27
|
+
# Mirrors the 0.32.0 final shim shape.
|
|
28
|
+
#
|
|
29
|
+
# # Fail-closed posture
|
|
30
|
+
#
|
|
31
|
+
# settings-protection is THE gate protecting the entire governance layer
|
|
32
|
+
# from agent self-disable. Pre-0.35.0 the bash body enforced refusal
|
|
33
|
+
# without any compiled CLI; the Node-binary port preserves that — early-
|
|
34
|
+
# exit branches fail closed AFTER the relevance pre-gate passes.
|
|
35
|
+
#
|
|
36
|
+
# # Relevance pre-gate
|
|
37
|
+
#
|
|
38
|
+
# Substring scan over the extracted file_path / notebook_path for the
|
|
39
|
+
# protected-path markers (.claude/, .husky/, .rea/policy.yaml, .rea/HALT,
|
|
40
|
+
# the verdict cache paths, plus any policy.blocked_paths entry). When
|
|
41
|
+
# CLI is missing AND none of these substrings appear in the payload's
|
|
42
|
+
# file path, exit 0. The pre-0.35.0 bash body would have allowed.
|
|
43
|
+
#
|
|
44
|
+
# # Bootstrap safety
|
|
45
|
+
#
|
|
46
|
+
# This shim is ITSELF protected by `settings-protection.sh`. The new
|
|
47
|
+
# shim must not block legitimate writes — the `bash -n` syntax check
|
|
48
|
+
# in the test:bash-syntax script catches parse errors BEFORE the
|
|
49
|
+
# install lands them. The relevance pre-gate keeps benign writes (like
|
|
50
|
+
# editing `src/foo.ts`) exiting 0 even when the CLI is missing.
|
|
51
|
+
|
|
52
|
+
set -uo pipefail
|
|
53
|
+
|
|
54
|
+
# 1. HALT check.
|
|
55
|
+
# shellcheck source=_lib/halt-check.sh
|
|
56
|
+
source "$(dirname "$0")/_lib/halt-check.sh"
|
|
57
|
+
check_halt
|
|
58
|
+
REA_ROOT=$(rea_root)
|
|
59
|
+
|
|
60
|
+
proj="${CLAUDE_PROJECT_DIR:-$REA_ROOT}"
|
|
61
|
+
|
|
62
|
+
# 2. Capture stdin once.
|
|
63
|
+
INPUT=$(cat)
|
|
64
|
+
|
|
65
|
+
# 3. Resolve the rea CLI through the fixed 2-tier sandboxed order.
|
|
66
|
+
REA_ARGV=()
|
|
67
|
+
RESOLVED_CLI_PATH=""
|
|
68
|
+
if [ -f "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js" ]; then
|
|
69
|
+
REA_ARGV=(node "$proj/node_modules/@bookedsolid/rea/dist/cli/index.js")
|
|
70
|
+
RESOLVED_CLI_PATH="$proj/node_modules/@bookedsolid/rea/dist/cli/index.js"
|
|
71
|
+
elif [ -f "$proj/dist/cli/index.js" ]; then
|
|
72
|
+
REA_ARGV=(node "$proj/dist/cli/index.js")
|
|
73
|
+
RESOLVED_CLI_PATH="$proj/dist/cli/index.js"
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# 3b. Relevance pre-gate. Only used when the CLI is missing.
|
|
77
|
+
if [ "${#REA_ARGV[@]}" -eq 0 ]; then
|
|
78
|
+
CLI_MISSING_FILE_PATH=""
|
|
79
|
+
if command -v jq >/dev/null 2>&1; then
|
|
80
|
+
CLI_MISSING_FILE_PATH=$(printf '%s' "$INPUT" | jq -r '
|
|
81
|
+
(.tool_input.file_path // .tool_input.notebook_path // "") | tostring
|
|
82
|
+
' 2>/dev/null || true)
|
|
83
|
+
else
|
|
84
|
+
CLI_MISSING_FILE_PATH="$INPUT"
|
|
85
|
+
fi
|
|
86
|
+
if [ -z "$CLI_MISSING_FILE_PATH" ]; then
|
|
87
|
+
exit 0
|
|
88
|
+
fi
|
|
89
|
+
CLI_MISSING_RELEVANT=0
|
|
90
|
+
case "$CLI_MISSING_FILE_PATH" in
|
|
91
|
+
*".claude/settings"*) CLI_MISSING_RELEVANT=1 ;;
|
|
92
|
+
*".claude/hooks/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
93
|
+
*".husky/"*) CLI_MISSING_RELEVANT=1 ;;
|
|
94
|
+
*".rea/policy.yaml"*) CLI_MISSING_RELEVANT=1 ;;
|
|
95
|
+
*".rea/HALT"*) CLI_MISSING_RELEVANT=1 ;;
|
|
96
|
+
*".rea/last-review"*) CLI_MISSING_RELEVANT=1 ;;
|
|
97
|
+
*".claude\\"*|*".husky\\"*|*".rea\\"*) CLI_MISSING_RELEVANT=1 ;;
|
|
98
|
+
*"..%2F"*|*"%2E%2E"*) CLI_MISSING_RELEVANT=1 ;;
|
|
99
|
+
esac
|
|
100
|
+
# Codex round-1 P2 fix: scan policy.protected_writes entries too so a
|
|
101
|
+
# consumer-defined protected path isn't silently allowed when the CLI
|
|
102
|
+
# is missing.
|
|
103
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
104
|
+
POLICY_FILE="${REA_ROOT}/.rea/policy.yaml"
|
|
105
|
+
if [ -f "$POLICY_FILE" ]; then
|
|
106
|
+
while IFS= read -r entry; do
|
|
107
|
+
[ -z "$entry" ] && continue
|
|
108
|
+
base="$entry"
|
|
109
|
+
case "$base" in
|
|
110
|
+
*/) base="${base%/}" ;;
|
|
111
|
+
esac
|
|
112
|
+
[ -z "$base" ] && continue
|
|
113
|
+
case "$CLI_MISSING_FILE_PATH" in
|
|
114
|
+
*"$base"*) CLI_MISSING_RELEVANT=1; break ;;
|
|
115
|
+
esac
|
|
116
|
+
done < <(awk '
|
|
117
|
+
/^protected_writes:/ { in_block=1; next }
|
|
118
|
+
in_block && /^[[:space:]]*-/ {
|
|
119
|
+
sub(/^[[:space:]]*-[[:space:]]*/, "")
|
|
120
|
+
gsub(/^["'\'']/, "")
|
|
121
|
+
gsub(/["'\'']$/, "")
|
|
122
|
+
print
|
|
123
|
+
next
|
|
124
|
+
}
|
|
125
|
+
in_block && /^[^[:space:]-]/ { in_block=0 }
|
|
126
|
+
' "$POLICY_FILE" 2>/dev/null)
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
if [ "$CLI_MISSING_RELEVANT" -eq 0 ]; then
|
|
130
|
+
exit 0
|
|
131
|
+
fi
|
|
132
|
+
printf 'rea: settings-protection cannot run — the rea CLI is not built.\n' >&2
|
|
133
|
+
printf 'Run `pnpm install && pnpm build` (or `npm install` for a consumer install) to restore protection.\n' >&2
|
|
134
|
+
printf 'This shim fails closed because the pre-0.35.0 bash body enforced protected-path refusal without a CLI.\n' >&2
|
|
135
|
+
exit 2
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# 4. Realpath sandbox check.
|
|
139
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
140
|
+
printf 'rea: settings-protection cannot run — `node` is not on PATH.\n' >&2
|
|
141
|
+
printf 'Install Node 22+ (engines.node) to restore protected-path refusal.\n' >&2
|
|
142
|
+
exit 2
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
sandbox_check=$(node -e '
|
|
146
|
+
const fs = require("fs");
|
|
147
|
+
const path = require("path");
|
|
148
|
+
const cli = process.argv[1];
|
|
149
|
+
const projDir = process.argv[2];
|
|
150
|
+
let real, realProj;
|
|
151
|
+
try { real = fs.realpathSync(cli); } catch (e) {
|
|
152
|
+
process.stdout.write("bad:realpath"); process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
try { realProj = fs.realpathSync(projDir); } catch (e) {
|
|
155
|
+
process.stdout.write("bad:realpath-proj"); process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
const sep = path.sep;
|
|
158
|
+
const projWithSep = realProj.endsWith(sep) ? realProj : realProj + sep;
|
|
159
|
+
if (!(real === realProj || real.startsWith(projWithSep))) {
|
|
160
|
+
process.stdout.write("bad:cli-escapes-project"); process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
// Codex round-1 P1 fix: enforce dist/cli/index.js shape so a
|
|
163
|
+
// workspace attacker who repoints node_modules/@bookedsolid/rea or
|
|
164
|
+
// dist at an arbitrary in-project JS file cannot execute it as the
|
|
165
|
+
// trusted gate CLI. Pre-0.35.0 shims had this check; the 0.34.0
|
|
166
|
+
// round-8 template dropped it; restored here.
|
|
167
|
+
const expectedEnd = path.join("dist", "cli", "index.js");
|
|
168
|
+
if (!real.endsWith(path.sep + expectedEnd) && real !== "/" + expectedEnd) {
|
|
169
|
+
process.stdout.write("bad:cli-shape"); process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
let cur = path.dirname(path.dirname(path.dirname(real)));
|
|
172
|
+
let found = false;
|
|
173
|
+
for (let i = 0; i < 20 && cur && cur !== path.dirname(cur); i += 1) {
|
|
174
|
+
const pj = path.join(cur, "package.json");
|
|
175
|
+
if (fs.existsSync(pj)) {
|
|
176
|
+
try {
|
|
177
|
+
const data = JSON.parse(fs.readFileSync(pj, "utf8"));
|
|
178
|
+
if (data && data.name === "@bookedsolid/rea") { found = true; break; }
|
|
179
|
+
} catch (e) { /* keep walking */ }
|
|
180
|
+
}
|
|
181
|
+
cur = path.dirname(cur);
|
|
182
|
+
}
|
|
183
|
+
if (!found) { process.stdout.write("bad:no-rea-pkg-json"); process.exit(1); }
|
|
184
|
+
process.stdout.write("ok");
|
|
185
|
+
' -- "$RESOLVED_CLI_PATH" "$proj" 2>/dev/null)
|
|
186
|
+
|
|
187
|
+
if [ "$sandbox_check" != "ok" ]; then
|
|
188
|
+
printf 'rea: settings-protection FAILED sandbox check (%s) — refusing.\n' "$sandbox_check" >&2
|
|
189
|
+
exit 2
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
# 5. Version-probe.
|
|
193
|
+
probe_out=$("${REA_ARGV[@]}" hook settings-protection --help 2>&1)
|
|
194
|
+
probe_status=$?
|
|
195
|
+
if [ "$probe_status" -ne 0 ] || ! printf '%s' "$probe_out" | grep -q -e 'settings-protection'; then
|
|
196
|
+
printf 'rea: this shim requires the `rea hook settings-protection` subcommand (introduced in 0.35.0).\n' >&2
|
|
197
|
+
printf 'The resolved CLI at %s does not implement it.\n' "$RESOLVED_CLI_PATH" >&2
|
|
198
|
+
printf 'Run `pnpm install` (or `npm install`) to sync the CLI; refusing in the meantime to preserve enforcement.\n' >&2
|
|
199
|
+
exit 2
|
|
200
|
+
fi
|
|
201
|
+
|
|
202
|
+
# 6. Forward stdin (already captured up-front).
|
|
203
|
+
printf '%s' "$INPUT" | "${REA_ARGV[@]}" hook settings-protection
|
|
204
|
+
exit $?
|