@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.
Files changed (37) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/path-normalize.d.ts +81 -0
  3. package/dist/hooks/_lib/path-normalize.js +171 -0
  4. package/dist/hooks/_lib/payload.js +1 -1
  5. package/dist/hooks/_lib/protected-paths.d.ts +0 -0
  6. package/dist/hooks/_lib/protected-paths.js +232 -0
  7. package/dist/hooks/_lib/segments.d.ts +102 -0
  8. package/dist/hooks/_lib/segments.js +290 -0
  9. package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
  10. package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
  11. package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
  12. package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
  13. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  14. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  15. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  16. package/dist/hooks/local-review-gate/index.js +374 -0
  17. package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
  18. package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
  19. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  20. package/dist/hooks/secret-scanner/index.js +404 -0
  21. package/dist/hooks/settings-protection/index.d.ts +74 -0
  22. package/dist/hooks/settings-protection/index.js +485 -0
  23. package/hooks/blocked-paths-bash-gate.sh +118 -116
  24. package/hooks/blocked-paths-enforcer.sh +152 -256
  25. package/hooks/dangerous-bash-interceptor.sh +168 -386
  26. package/hooks/local-review-gate.sh +523 -410
  27. package/hooks/protected-paths-bash-gate.sh +123 -210
  28. package/hooks/secret-scanner.sh +210 -200
  29. package/hooks/settings-protection.sh +171 -549
  30. package/package.json +1 -1
  31. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
  32. package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
  33. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  34. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  35. package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
  36. package/templates/secret-scanner.dogfood-staged.sh +240 -0
  37. 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 $?