@bookedsolid/rea 0.31.0 → 0.33.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 (43) hide show
  1. package/.husky/prepare-commit-msg +80 -6
  2. package/MIGRATING.md +24 -15
  3. package/dist/cli/hook.js +60 -22
  4. package/dist/hooks/_lib/halt-check.d.ts +78 -0
  5. package/dist/hooks/_lib/halt-check.js +106 -0
  6. package/dist/hooks/_lib/payload.d.ts +124 -0
  7. package/dist/hooks/_lib/payload.js +245 -0
  8. package/dist/hooks/_lib/segments.d.ts +125 -0
  9. package/dist/hooks/_lib/segments.js +766 -0
  10. package/dist/hooks/architecture-review-gate/index.d.ts +58 -0
  11. package/dist/hooks/architecture-review-gate/index.js +250 -0
  12. package/dist/hooks/attribution-advisory/index.d.ts +72 -0
  13. package/dist/hooks/attribution-advisory/index.js +233 -0
  14. package/dist/hooks/bash-scanner/protected-scan.js +14 -2
  15. package/dist/hooks/changeset-security-gate/index.d.ts +71 -0
  16. package/dist/hooks/changeset-security-gate/index.js +330 -0
  17. package/dist/hooks/dependency-audit-gate/index.d.ts +91 -0
  18. package/dist/hooks/dependency-audit-gate/index.js +294 -0
  19. package/dist/hooks/env-file-protection/index.d.ts +55 -0
  20. package/dist/hooks/env-file-protection/index.js +159 -0
  21. package/dist/hooks/pr-issue-link-gate/index.d.ts +91 -0
  22. package/dist/hooks/pr-issue-link-gate/index.js +127 -0
  23. package/dist/hooks/security-disclosure-gate/index.d.ts +91 -0
  24. package/dist/hooks/security-disclosure-gate/index.js +502 -0
  25. package/hooks/_lib/protected-paths.sh +10 -3
  26. package/hooks/architecture-review-gate.sh +92 -77
  27. package/hooks/attribution-advisory.sh +139 -131
  28. package/hooks/changeset-security-gate.sh +114 -149
  29. package/hooks/dependency-audit-gate.sh +115 -156
  30. package/hooks/env-file-protection.sh +130 -97
  31. package/hooks/pr-issue-link-gate.sh +114 -45
  32. package/hooks/security-disclosure-gate.sh +148 -316
  33. package/hooks/settings-protection.sh +13 -9
  34. package/package.json +1 -1
  35. package/templates/architecture-review-gate.dogfood-staged.sh +116 -0
  36. package/templates/attribution-advisory.dogfood-staged.sh +170 -0
  37. package/templates/changeset-security-gate.dogfood-staged.sh +137 -0
  38. package/templates/dependency-audit-gate.dogfood-staged.sh +138 -0
  39. package/templates/env-file-protection.dogfood-staged.sh +157 -0
  40. package/templates/pr-issue-link-gate.dogfood-staged.sh +134 -0
  41. package/templates/prepare-commit-msg.husky.sh +80 -6
  42. package/templates/security-disclosure-gate.dogfood-staged.sh +171 -0
  43. package/templates/settings-protection.dogfood.patch +58 -0
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Node-binary port of `hooks/security-disclosure-gate.sh`.
3
+ *
4
+ * 0.32.0 Phase 1 Pilot #2 — env-var-policy + body-file-resolver +
5
+ * mode-aware redirect router for `gh issue create` commands that
6
+ * mention vulnerability-class keywords.
7
+ *
8
+ * Why pilot #2 (and not #3): pilot #2 is the LARGEST of the three
9
+ * (339 LOC bash) and exercises every primitive landed in Phase 0:
10
+ * - `checkHalt` (Phase 0)
11
+ * - `parseHookPayload` (Phase 0)
12
+ * - `splitSegments` / `anySegmentStartsWith` (Phase 0, used by
13
+ * pilot #3 first but in scope here for `gh issue create`)
14
+ * - File-IO resolver for `--body-file` / `-F` paths with `..`
15
+ * traversal refusal, ABSOLUTE-vs-relative resolution, 64 KiB cap.
16
+ * - Read of `REA_DISCLOSURE_MODE` env var with three-state semantics
17
+ * (`advisory` / `issues` / `disabled`).
18
+ *
19
+ * Behavioral contract — preserves bash hook byte-for-byte:
20
+ *
21
+ * 1. HALT check → exit 2 with shared banner.
22
+ * 2. Read `REA_DISCLOSURE_MODE` env var. `disabled` → exit 0
23
+ * immediately (no scan at all).
24
+ * 3. Read stdin. If `tool_name` isn't `Bash`, exit 0.
25
+ * 4. Identify `gh issue create` segments via `anySegmentStartsWith`.
26
+ * Substring fallback when the segment splitter is unreachable is
27
+ * moot in Node — `splitSegments` is always in scope. (The bash
28
+ * hook had a fallback only because `cmd-segments.sh` might be
29
+ * absent in foreign installs.)
30
+ * 5. Resolve `--body-file PATH` and `-F PATH` arguments. The
31
+ * resolver MUST match the bash quote-aware awk tokenizer for the
32
+ * shape `--body-file "path with spaces.md"` — we run our own
33
+ * quote-aware walker that yields each `--body-file` / `-F`
34
+ * value. Stdin form (`-`) is skipped. Paths whose CANONICAL form
35
+ * (after resolving `..` segments) escape REA_ROOT are REFUSED
36
+ * with exit 2 + advisory banner (matches the 0.17.0 helix-019 #1
37
+ * fix). Readable files contribute the first 64 KiB to the scan
38
+ * buffer; unreadable files print a warning and continue.
39
+ * 6. Build `FULL_TEXT` = body-file contents + command text (both
40
+ * lowercased) and scan for SECURITY_PATTERNS (an ordered list of
41
+ * ERE patterns mirroring the bash array). First match wins;
42
+ * `MATCHED_PATTERN` becomes the body-banner placeholder.
43
+ * 7. Route on mode:
44
+ * - `issues` → block banner pointing to `gh issue create
45
+ * --label 'security,internal' …` private form
46
+ * - `advisory` → block banner pointing to `gh api
47
+ * repos/.../security-advisories` private form
48
+ * Both return exit 2.
49
+ *
50
+ * Out-of-scope vs. the bash hook (intentional simplifications):
51
+ *
52
+ * - The bash hook emits `json_output "block" "..."` via
53
+ * `_lib/common.sh`. The JSON format is a Claude Code-specific
54
+ * wrapper that lets the hook present a structured block reason
55
+ * to the agent. In the Node tier, the canonical surface is `{
56
+ * hookSpecificOutput: { hookEventName: 'PreToolUse', ... } }`
57
+ * emitted on STDOUT with exit code 0; the legacy bash hook emits
58
+ * it on stdout. We preserve that exact shape via `emitJsonBlock`.
59
+ * - The bash hook's `require_jq` check is moot — Node parses JSON
60
+ * natively.
61
+ */
62
+ import { Buffer } from 'node:buffer';
63
+ import fs from 'node:fs';
64
+ import path from 'node:path';
65
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
66
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
67
+ import { anySegmentStartsWith } from '../_lib/segments.js';
68
+ /**
69
+ * Ordered list of ERE patterns that indicate a security finding when
70
+ * present in a public issue. Order mirrors the bash array
71
+ * `SECURITY_PATTERNS=(...)` so the `MATCHED_PATTERN` placeholder
72
+ * picks the same first-match string the bash hook would have.
73
+ */
74
+ const SECURITY_PATTERNS = [
75
+ // Vulnerability classes
76
+ 'bypass',
77
+ 'exploit',
78
+ 'injection',
79
+ 'traversal',
80
+ 'exfiltrat',
81
+ 'escalat',
82
+ 'privilege',
83
+ 'rce',
84
+ 'remote.code.exec',
85
+ 'arbitrary.code',
86
+ 'code.execution',
87
+ 'zero.day',
88
+ '0day',
89
+ 'CVE-',
90
+ 'CVSS',
91
+ 'GHSA-',
92
+ // Reagent-specific sensitive terms
93
+ 'hook.bypass',
94
+ 'HALT.bypass',
95
+ 'redaction.bypass',
96
+ 'policy.bypass',
97
+ 'middleware.bypass',
98
+ 'skip.*gate',
99
+ 'evad',
100
+ // Credential/secret exposure
101
+ 'secret.*leak',
102
+ 'credential.*leak',
103
+ 'token.*leak',
104
+ 'key.*expos',
105
+ 'expos.*secret',
106
+ // Prompt injection
107
+ 'prompt.inject',
108
+ 'jailbreak',
109
+ 'jail.break',
110
+ ];
111
+ const BODY_FILE_BYTE_CAP = 64 * 1024;
112
+ /**
113
+ * Quote-aware tokenizer that yields each `--body-file <PATH>` and
114
+ * `-F <PATH>` argument from the raw command string. Mirrors the awk
115
+ * walker in security-disclosure-gate.sh#_extract_body_file_paths,
116
+ * including the 0.18.0 helix-020 G3.B `\<space>` plain-mode escape
117
+ * fix.
118
+ */
119
+ function extractBodyFilePaths(cmd) {
120
+ // First, tokenize the command string with quote/escape awareness
121
+ // and yield tokens. Then walk tokens looking for `--body-file` /
122
+ // `-F` (consume next), or `--body-file=PATH` / `-F=PATH` (use the
123
+ // inline value).
124
+ const tokens = [];
125
+ let i = 0;
126
+ const n = cmd.length;
127
+ let tok = '';
128
+ let mode = 'plain';
129
+ const flush = () => {
130
+ if (tok.length > 0) {
131
+ tokens.push(tok);
132
+ tok = '';
133
+ }
134
+ };
135
+ while (i < n) {
136
+ const ch = cmd[i];
137
+ if (mode === 'plain') {
138
+ if (ch === '\\' && i + 1 < n) {
139
+ // Plain-mode `\X` → literal X. helix-020 G3.B fix.
140
+ tok += cmd[i + 1];
141
+ i += 2;
142
+ continue;
143
+ }
144
+ if (ch === ' ' || ch === '\t' || ch === '\n') {
145
+ flush();
146
+ i += 1;
147
+ continue;
148
+ }
149
+ if (ch === '"') {
150
+ mode = 'dquote';
151
+ tok += ch;
152
+ i += 1;
153
+ continue;
154
+ }
155
+ if (ch === "'") {
156
+ mode = 'squote';
157
+ tok += ch;
158
+ i += 1;
159
+ continue;
160
+ }
161
+ tok += ch;
162
+ i += 1;
163
+ continue;
164
+ }
165
+ if (mode === 'dquote') {
166
+ if (ch === '\\' && i + 1 < n) {
167
+ // Preserve `\"` / `\\` literally inside the token; bash's
168
+ // `awk` walker emits the escape sequence verbatim, and
169
+ // strip_outer_quotes handles the outer pair.
170
+ tok += ch + cmd[i + 1];
171
+ i += 2;
172
+ continue;
173
+ }
174
+ if (ch === '"') {
175
+ mode = 'plain';
176
+ tok += ch;
177
+ i += 1;
178
+ continue;
179
+ }
180
+ tok += ch;
181
+ i += 1;
182
+ continue;
183
+ }
184
+ // mode === 'squote'
185
+ if (ch === "'") {
186
+ mode = 'plain';
187
+ tok += ch;
188
+ i += 1;
189
+ continue;
190
+ }
191
+ tok += ch;
192
+ i += 1;
193
+ }
194
+ flush();
195
+ /**
196
+ * Strip a single outer pair of matching `"..."` or `'...'`.
197
+ * Mirrors awk strip_outer_quotes.
198
+ */
199
+ const stripOuterQuotes = (s) => {
200
+ if (s.length < 2)
201
+ return s;
202
+ const first = s[0];
203
+ const last = s[s.length - 1];
204
+ if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
205
+ return s.slice(1, -1);
206
+ }
207
+ return s;
208
+ };
209
+ const out = [];
210
+ let skipNext = false;
211
+ for (const t of tokens) {
212
+ if (skipNext) {
213
+ skipNext = false;
214
+ if (t === '-' || t === '')
215
+ continue;
216
+ const stripped = stripOuterQuotes(t);
217
+ out.push({ raw: stripped, isStdinForm: false });
218
+ continue;
219
+ }
220
+ if (t === '--body-file' || t === '-F') {
221
+ skipNext = true;
222
+ continue;
223
+ }
224
+ if (t.startsWith('--body-file=')) {
225
+ const v = stripOuterQuotes(t.slice('--body-file='.length));
226
+ if (v !== '' && v !== '-')
227
+ out.push({ raw: v, isStdinForm: false });
228
+ continue;
229
+ }
230
+ if (t.startsWith('-F=')) {
231
+ const v = stripOuterQuotes(t.slice('-F='.length));
232
+ if (v !== '' && v !== '-')
233
+ out.push({ raw: v, isStdinForm: false });
234
+ continue;
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+ /**
240
+ * Canonicalize a path by walking `..` segments. Mirrors the bash
241
+ * resolver — pure-string, NO `fs.realpath` (we explicitly do NOT want
242
+ * to follow symlinks here; the protected-paths gates do that
243
+ * separately).
244
+ */
245
+ function canonicalizePath(abs) {
246
+ const parts = abs.split('/');
247
+ const out = [];
248
+ for (const p of parts) {
249
+ if (p === '' || p === '.')
250
+ continue;
251
+ if (p === '..') {
252
+ if (out.length > 0)
253
+ out.pop();
254
+ continue;
255
+ }
256
+ out.push(p);
257
+ }
258
+ return '/' + out.join('/');
259
+ }
260
+ function resolveBodyFile(bodyPath, reaRoot, cwd) {
261
+ const isAbsolute = bodyPath.startsWith('/');
262
+ const abs = isAbsolute ? bodyPath : path.join(cwd, bodyPath);
263
+ // Detect traversal in the RAW path (matches the bash check `case
264
+ // "/$raw_path/" in */../*) had_traversal=1 ;; esac`).
265
+ const hadTraversal = `/${bodyPath}/`.includes('/../');
266
+ let resolved = abs;
267
+ if (hadTraversal) {
268
+ resolved = canonicalizePath(abs);
269
+ // Hard refusal if resolved escapes REA_ROOT.
270
+ const reaRootCanonical = canonicalizePath(reaRoot);
271
+ if (resolved !== reaRootCanonical &&
272
+ !resolved.startsWith(reaRootCanonical + '/')) {
273
+ return { kind: 'traversal', resolved, raw: bodyPath };
274
+ }
275
+ }
276
+ // Check readability.
277
+ try {
278
+ fs.accessSync(resolved, fs.constants.R_OK);
279
+ }
280
+ catch {
281
+ return { kind: 'unreadable', raw: bodyPath };
282
+ }
283
+ // Final check — make sure it's a regular file (not a directory).
284
+ try {
285
+ const st = fs.statSync(resolved);
286
+ if (!st.isFile())
287
+ return { kind: 'unreadable', raw: bodyPath };
288
+ }
289
+ catch {
290
+ return { kind: 'unreadable', raw: bodyPath };
291
+ }
292
+ return { kind: 'ok', resolved };
293
+ }
294
+ function readBodyFileChunk(p) {
295
+ // Read up to BODY_FILE_BYTE_CAP bytes. Lowercase to match the
296
+ // bash hook's `tr '[:upper:]' '[:lower:]'`.
297
+ try {
298
+ const fd = fs.openSync(p, 'r');
299
+ try {
300
+ const buf = Buffer.alloc(BODY_FILE_BYTE_CAP);
301
+ const bytesRead = fs.readSync(fd, buf, 0, BODY_FILE_BYTE_CAP, 0);
302
+ return buf.slice(0, bytesRead).toString('utf8').toLowerCase();
303
+ }
304
+ finally {
305
+ fs.closeSync(fd);
306
+ }
307
+ }
308
+ catch {
309
+ return '';
310
+ }
311
+ }
312
+ function normalizeDisclosureMode(raw) {
313
+ if (raw === 'issues')
314
+ return 'issues';
315
+ if (raw === 'disabled')
316
+ return 'disabled';
317
+ // Default and unrecognized → 'advisory'. Mirrors the bash hook's
318
+ // default and silent-default-on-bogus posture.
319
+ return 'advisory';
320
+ }
321
+ function emitTraversalRefusal(rawPath, resolved) {
322
+ return [
323
+ 'SECURITY DISCLOSURE GATE: --body-file path traversal escapes project root\n',
324
+ '\n',
325
+ ` Path: ${rawPath}\n`,
326
+ ` Resolved: ${resolved}\n`,
327
+ '\n',
328
+ ' Rule: --body-file paths whose canonical form uses `..` segments to\n',
329
+ ' escape REA_ROOT are refused. Move the file inside the project\n',
330
+ ' tree, or paste the body inline via --body.\n',
331
+ ].join('');
332
+ }
333
+ function emitBlockJsonAndStderr(reason) {
334
+ // Claude Code PreToolUse hook block format. Mirrors `json_output
335
+ // "block" "..."` in _lib/common.sh — which printed `message` to
336
+ // stderr before exiting 2. 0.32.0 codex round 2 P2: restore the
337
+ // stderr banner so hook runners that only surface stderr (the
338
+ // pre-0.32.0 bash hook contract, plus any non-Claude-Code wrapper
339
+ // that ignores the JSON-on-stdout protocol) still get the
340
+ // remediation text. Newline terminator matches `printf '%s\n'`.
341
+ const obj = {
342
+ hookSpecificOutput: {
343
+ hookEventName: 'PreToolUse',
344
+ permissionDecision: 'deny',
345
+ permissionDecisionReason: reason,
346
+ },
347
+ };
348
+ return { json: JSON.stringify(obj) + '\n', stderr: reason + '\n' };
349
+ }
350
+ function buildIssuesModeReason(matched) {
351
+ return `SECURITY DISCLOSURE GATE: This issue appears to describe a security finding (matched: '${matched}').
352
+
353
+ This project is configured for PRIVATE disclosure (REA_DISCLOSURE_MODE=issues).
354
+
355
+ CORRECT PATH for security findings in this private repo:
356
+ Use: gh issue create --label 'security,internal' --title '...' --body '...'
357
+
358
+ The 'security' and 'internal' labels keep this off public project boards and
359
+ mark it for maintainer-only triage. Do NOT use the public issue queue without
360
+ these labels for security findings.
361
+
362
+ If this is NOT a security finding, rephrase the title/body to avoid triggering
363
+ security patterns, then retry.`;
364
+ }
365
+ function buildAdvisoryModeReason(matched, mode) {
366
+ return `SECURITY DISCLOSURE GATE: This issue appears to describe a security vulnerability (matched: '${matched}'). Do NOT create a public GitHub issue for security vulnerabilities.
367
+
368
+ CORRECT DISCLOSURE PATH:
369
+ 1. Use GitHub Security Advisories (private):
370
+ gh api repos/{owner}/{repo}/security-advisories --method POST --input - <<'JSON'
371
+ { "summary": "...", "description": "...", "severity": "medium|high|critical",
372
+ "vulnerabilities": [{"package": {"name": "@pkg", "ecosystem": "npm"}}] }
373
+ JSON
374
+ 2. Or navigate to: Security tab → Advisories → 'Report a vulnerability'
375
+ 3. Or email security@bookedsolid.tech (see SECURITY.md)
376
+
377
+ The finding will be publicly disclosed AFTER a patch is released (coordinated disclosure).
378
+
379
+ WHY: Public issues expose vulnerabilities before users can patch. This is enforced by the
380
+ security-disclosure-gate hook (REA_DISCLOSURE_MODE=${mode}).
381
+
382
+ If this is NOT a security vulnerability, rephrase the issue to avoid triggering
383
+ security patterns, then retry.`;
384
+ }
385
+ /**
386
+ * Pure executor.
387
+ */
388
+ export async function runSecurityDisclosureGate(options = {}) {
389
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
390
+ const cwd = options.cwdOverride ?? process.cwd();
391
+ let stderr = '';
392
+ let stdout = '';
393
+ const writeStderr = (s) => {
394
+ stderr += s;
395
+ if (options.stderrWrite)
396
+ options.stderrWrite(s);
397
+ };
398
+ const writeStdout = (s) => {
399
+ stdout += s;
400
+ if (options.stdoutWrite)
401
+ options.stdoutWrite(s);
402
+ };
403
+ // 1. HALT check.
404
+ const halt = checkHalt(reaRoot);
405
+ if (halt.halted) {
406
+ writeStderr(formatHaltBanner(halt.reason));
407
+ return { exitCode: 2, stderr, stdout };
408
+ }
409
+ // 2. Disclosure mode.
410
+ const rawMode = options.disclosureModeOverride ?? process.env['REA_DISCLOSURE_MODE'];
411
+ const mode = normalizeDisclosureMode(rawMode);
412
+ if (mode === 'disabled') {
413
+ return { exitCode: 0, stderr, stdout };
414
+ }
415
+ // 3. Stdin.
416
+ const stdinRaw = options.stdinOverride !== undefined
417
+ ? options.stdinOverride
418
+ : await readStdinWithTimeout(5_000);
419
+ let toolName = '';
420
+ let cmd = '';
421
+ try {
422
+ const payload = parseHookPayload(stdinRaw);
423
+ toolName = payload.toolName;
424
+ cmd = payload.command;
425
+ }
426
+ catch (err) {
427
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
428
+ writeStderr(`security-disclosure-gate: ${err.message} — refusing on uncertainty.\n`);
429
+ return { exitCode: 2, stderr, stdout };
430
+ }
431
+ throw err;
432
+ }
433
+ if (toolName !== '' && toolName !== 'Bash') {
434
+ return { exitCode: 0, stderr, stdout };
435
+ }
436
+ if (cmd.length === 0) {
437
+ return { exitCode: 0, stderr, stdout };
438
+ }
439
+ // 4. Only intercept `gh issue create` (head-anchored).
440
+ if (!anySegmentStartsWith(cmd, 'gh\\s+issue\\s+create')) {
441
+ return { exitCode: 0, stderr, stdout };
442
+ }
443
+ // 5. Body-file resolution.
444
+ const bodyTokens = extractBodyFilePaths(cmd);
445
+ let bodyFileText = '';
446
+ for (const tok of bodyTokens) {
447
+ if (tok.isStdinForm)
448
+ continue;
449
+ const r = resolveBodyFile(tok.raw, reaRoot, cwd);
450
+ if (r.kind === 'traversal') {
451
+ writeStderr(emitTraversalRefusal(r.raw, r.resolved));
452
+ return { exitCode: 2, stderr, stdout };
453
+ }
454
+ if (r.kind === 'unreadable') {
455
+ writeStderr(`security-disclosure-gate: --body-file ${r.raw} unreadable; skipping body scan\n`);
456
+ continue;
457
+ }
458
+ const chunk = readBodyFileChunk(r.resolved);
459
+ if (chunk.length > 0)
460
+ bodyFileText += '\n' + chunk;
461
+ }
462
+ // 6. Pattern scan.
463
+ const fullText = bodyFileText + '\n' + cmd.toLowerCase();
464
+ let matched = '';
465
+ for (const p of SECURITY_PATTERNS) {
466
+ const re = new RegExp(p, 'i');
467
+ if (re.test(fullText)) {
468
+ matched = p;
469
+ break;
470
+ }
471
+ }
472
+ if (matched === '') {
473
+ return { exitCode: 0, stderr, stdout };
474
+ }
475
+ // 7. Mode-aware routing.
476
+ const reason = mode === 'issues'
477
+ ? buildIssuesModeReason(matched)
478
+ : buildAdvisoryModeReason(matched, mode);
479
+ const blockOutput = emitBlockJsonAndStderr(reason);
480
+ writeStdout(blockOutput.json);
481
+ // 0.32.0 codex round 2 P2: also emit the remediation banner to
482
+ // stderr so hook runners that only surface stderr (legacy bash
483
+ // hook contract, non-Claude-Code wrappers) still see the
484
+ // operator-facing reason text. Claude Code itself prefers the
485
+ // JSON on stdout but tolerates duplicate stderr.
486
+ if (blockOutput.stderr.length > 0)
487
+ writeStderr(blockOutput.stderr);
488
+ return { exitCode: 2, stderr, stdout };
489
+ }
490
+ /**
491
+ * CLI entry — `rea hook security-disclosure-gate`.
492
+ */
493
+ export async function runHookSecurityDisclosureGate(options = {}) {
494
+ const result = await runSecurityDisclosureGate({
495
+ ...options,
496
+ stderrWrite: (s) => process.stderr.write(s),
497
+ stdoutWrite: (s) => process.stdout.write(s),
498
+ });
499
+ process.exit(result.exitCode);
500
+ }
501
+ // Internal exports for tests.
502
+ export const __INTERNAL_SECURITY_PATTERNS_FOR_TESTS = SECURITY_PATTERNS;
@@ -242,7 +242,7 @@ _rea_load_protected_patterns() {
242
242
  }
243
243
 
244
244
  # Test whether a project-relative path is in the documented husky
245
- # extension surface (`.husky/commit-msg.d/*`, `.husky/pre-push.d/*`).
245
+ # extension surface (`.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/*`).
246
246
  # Returns 0 on match, 1 on no match. Case-insensitive.
247
247
  #
248
248
  # 0.16.4 helix-018 Option B: settings-protection.sh §5b has carved
@@ -253,17 +253,24 @@ _rea_load_protected_patterns() {
253
253
  # redirect was refused by the bash-gate even though the equivalent
254
254
  # Write-tool call would succeed. This helper bakes the carve-out
255
255
  # into the shared lib so every caller inherits it uniformly.
256
+ #
257
+ # 0.32.0 codex round 2 P1: `.husky/prepare-commit-msg.d/*` joins the
258
+ # carve-out to match settings-protection.sh §5b — the Write-tier
259
+ # allow-list shipped earlier in 0.32.0 was incomplete without the
260
+ # Bash-tier parity. Without this update, the migration path in
261
+ # MIGRATING.md (`cat > .husky/prepare-commit-msg.d/...`) is refused
262
+ # by `protected-paths-bash-gate.sh` even though Write/Edit succeeds.
256
263
  rea_path_is_extension_surface() {
257
264
  local p_lc
258
265
  p_lc=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')
259
266
  case "$p_lc" in
260
- .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*)
267
+ .husky/commit-msg.d/*|.husky/pre-push.d/*|.husky/pre-commit.d/*|.husky/prepare-commit-msg.d/*)
261
268
  # Refuse the bare directory itself — only fragments INSIDE
262
269
  # the surface count. `.husky/pre-push.d/` (trailing slash, no
263
270
  # fragment) and `.husky/pre-push.d` (the dir node) both fall
264
271
  # through to the protection check via the parent prefix.
265
272
  case "$p_lc" in
266
- .husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/) return 1 ;;
273
+ .husky/commit-msg.d/|.husky/pre-push.d/|.husky/pre-commit.d/|.husky/prepare-commit-msg.d/) return 1 ;;
267
274
  esac
268
275
  return 0
269
276
  ;;