@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,669 @@
1
+ /**
2
+ * Node-binary port of `hooks/dangerous-bash-interceptor.sh`.
3
+ *
4
+ * 0.34.0 Phase 2 port #1 (tier-2 medium-complexity hooks with enforcer
5
+ * logic). This is the agent-runaway gate — it refuses destructive Bash
6
+ * commands before Claude Code dispatches them. Every refusal class in
7
+ * the 414-LOC bash body must be preserved byte-for-byte; the bypass
8
+ * corpus pinned across 0.13–0.27 demands it.
9
+ *
10
+ * Behavioral contract — preserves the bash hook byte-for-byte:
11
+ *
12
+ * 1. HALT check → exit 2 with the shared banner.
13
+ * 2. Read stdin, extract `tool_input.command`. Non-Bash payloads or
14
+ * empty command → exit 0.
15
+ * 3. Compute smart exclusion flags:
16
+ * - `CMD_IS_REBASE_SAFE` → segments that begin with
17
+ * `git rebase --abort|--continue` skip the H2 rebase advisory.
18
+ * - `CMD_IS_CLEAN_DRY` → segments that begin with
19
+ * `git clean -n|--dry-run` skip the H5 destructive-clean check.
20
+ * 4. Run every HIGH check (H1–H17, M1) against the command. Each
21
+ * check returns 0..N matches; matches are accumulated into the
22
+ * violations table. The accumulator preserves the original bash
23
+ * hook's first-match-wins-per-check semantics — H1 fires once
24
+ * per command even if multiple push segments are unsafe.
25
+ * 5. If any HIGH match → emit "BASH INTERCEPTED" banner + exit 2.
26
+ * Else if MEDIUM-only → emit "BASH ADVISORY" banner + exit 0.
27
+ * Else exit 0 silently.
28
+ *
29
+ * The pattern catalog is in `RULES` below. Each rule is a self-
30
+ * contained closure with a stable identifier (`H1`, `H2`, …) so a
31
+ * future rule addition lands as a one-line array push, not a rewrite.
32
+ * Identifiers match the bash hook's `add_high "H<N>: …"` shape so
33
+ * audit/log consumers grepping for `H12` continue to work.
34
+ *
35
+ * Key parity choices:
36
+ *
37
+ * - Segment-anchored detection via `anySegmentStartsWith` (and
38
+ * `forEachSegment` for per-segment work). The bash 0.15.0 fix
39
+ * (segment-aware instead of full-command grep) is reproduced here.
40
+ * - Env-var-prefix shapes (H10 `HUSKY=0 git`, H15 `REA_BYPASS=…`,
41
+ * H16 alias/function defs) use `anySegmentRawMatches` since the
42
+ * prefix IS the signal — `stripSegmentPrefix` would eat it.
43
+ * - H12 (`curl|sh` pipe-RCE) scans the whole command via
44
+ * `quoteMaskedCmd` because pipe-RCE is a multi-segment property
45
+ * (`|` is the separator that joins curl to sh). The bash hook's
46
+ * `_rea_unwrap_nested_shells` is mirrored via `unwrapNestedShells`
47
+ * so inner payloads of `bash -c "curl … | sh"` are also scanned.
48
+ * - H17 (context-protection) reads
49
+ * `policy.context_protection.delegate_to_subagent` via the canonical
50
+ * YAML loader (matches the bash hook's 0.16.0 fix J.2).
51
+ */
52
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
53
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
54
+ import { anySegmentStartsWith, anySegmentContains, anySegmentRawMatches, forEachSegment, quoteMaskedCmd, unwrapNestedShells, } from '../_lib/segments.js';
55
+ import * as fs from 'node:fs';
56
+ import * as path from 'node:path';
57
+ import { parse as parseYaml } from 'yaml';
58
+ const MAX_DISPLAY_CMD_LEN = 200;
59
+ function truncate(cmd) {
60
+ if (cmd.length <= MAX_DISPLAY_CMD_LEN)
61
+ return cmd;
62
+ return cmd.slice(0, MAX_DISPLAY_CMD_LEN) + '...';
63
+ }
64
+ function escapeERE(pattern) {
65
+ return pattern.replace(/[\\.*^$()+?|{}[\]]/g, (m) => `\\${m}`);
66
+ }
67
+ // ── Rule catalog ────────────────────────────────────────────────────
68
+ const RULES = [
69
+ // ── H1: git push --force / force-push refspec ────────────────────
70
+ {
71
+ id: 'H1',
72
+ severity: 'HIGH',
73
+ run: (ctx) => {
74
+ const out = [];
75
+ let fired = false;
76
+ forEachSegment(ctx.cmd, (_raw, head) => {
77
+ if (fired)
78
+ return;
79
+ // Anchor on `^git push` (the prefix-stripped form). The bash
80
+ // 0.15.0 P1 fix anchored on this so a quoted-mention inside
81
+ // `echo "git push --force is bad"` does not trigger.
82
+ if (!/^git\s+push(\s|$)/i.test(head))
83
+ return;
84
+ // `--force-with-lease` is the safe form — skip.
85
+ if (/--force-with-lease/i.test(head))
86
+ return;
87
+ // Match any of:
88
+ // --force | --force=<value>
89
+ // -[A-Za-z]*f[A-Za-z]* (flag-cluster containing `f`)
90
+ // ` +<refspec>` (refspec-prefix force-push shorthand)
91
+ if (/--force(\s|=|$)/i.test(head) ||
92
+ /(^|\s)-[A-Za-z]*f[A-Za-z]*(\s|$)/.test(head) ||
93
+ /\s\+[A-Za-z0-9_./-]/.test(head)) {
94
+ fired = true;
95
+ out.push({
96
+ severity: 'HIGH',
97
+ id: 'H1',
98
+ label: 'git push --force — force push detected',
99
+ detail: "Force-pushing rewrites public history and breaks collaborators' local copies.",
100
+ alternatives: [
101
+ "Alt: Use 'git push --force-with-lease' — blocks if upstream has new commits you haven't pulled.",
102
+ ],
103
+ });
104
+ }
105
+ });
106
+ return out;
107
+ },
108
+ },
109
+ // ── H2: git rebase advisory (MEDIUM) ─────────────────────────────
110
+ {
111
+ id: 'H2',
112
+ severity: 'MEDIUM',
113
+ run: (ctx) => {
114
+ if (ctx.cmdIsRebaseSafe)
115
+ return [];
116
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+rebase(\\s|$)'))
117
+ return [];
118
+ return [
119
+ {
120
+ severity: 'MEDIUM',
121
+ id: 'H2',
122
+ label: 'git rebase — rewrites commit history (advisory)',
123
+ detail: 'Rebase changes commit SHAs. Safe on local feature branches; dangerous on shared/published branches.',
124
+ alternatives: [
125
+ "Alt: 'git merge origin/main' preserves history (creates merge commit).",
126
+ " 'git rebase --abort' to cancel if in progress.",
127
+ ],
128
+ },
129
+ ];
130
+ },
131
+ },
132
+ // ── H3: git checkout -- . ────────────────────────────────────────
133
+ {
134
+ id: 'H3',
135
+ severity: 'HIGH',
136
+ run: (ctx) => {
137
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+checkout\\s+--\\s+\\.')) {
138
+ return [];
139
+ }
140
+ return [
141
+ {
142
+ severity: 'HIGH',
143
+ id: 'H3',
144
+ label: 'git checkout -- . — discards all uncommitted changes',
145
+ detail: 'Overwrites working tree changes with HEAD. Uncommitted work is lost permanently.',
146
+ alternatives: [
147
+ "Alt: 'git stash' to temporarily shelve changes, 'git restore <file>' for individual files.",
148
+ ],
149
+ },
150
+ ];
151
+ },
152
+ },
153
+ // ── H4: git restore . ────────────────────────────────────────────
154
+ {
155
+ id: 'H4',
156
+ severity: 'HIGH',
157
+ run: (ctx) => {
158
+ // Two forms: `git restore <flags> .` and `git restore .` (bare).
159
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+restore\\s+.*\\s\\.(\\s|$)') &&
160
+ !anySegmentStartsWith(ctx.cmd, 'git\\s+restore\\s+\\.\\s*$')) {
161
+ return [];
162
+ }
163
+ return [
164
+ {
165
+ severity: 'HIGH',
166
+ id: 'H4',
167
+ label: 'git restore . — discards all uncommitted changes',
168
+ detail: 'Restores every tracked file to HEAD, permanently discarding all working tree modifications.',
169
+ alternatives: [
170
+ "Alt: 'git stash' to save changes temporarily, or restore individual files: 'git restore <file>'.",
171
+ ],
172
+ },
173
+ ];
174
+ },
175
+ },
176
+ // ── H5: git clean -f ─────────────────────────────────────────────
177
+ {
178
+ id: 'H5',
179
+ severity: 'HIGH',
180
+ run: (ctx) => {
181
+ if (ctx.cmdIsCleanDry)
182
+ return [];
183
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+clean\\s+-[a-zA-Z]*f')) {
184
+ return [];
185
+ }
186
+ return [
187
+ {
188
+ severity: 'HIGH',
189
+ id: 'H5',
190
+ label: 'git clean -f — removes untracked files',
191
+ detail: 'Permanently deletes untracked files from the working tree. Cannot be undone via git.',
192
+ alternatives: [
193
+ "Alt: 'git clean -n' (dry-run) to preview what would be deleted before committing.",
194
+ ],
195
+ },
196
+ ];
197
+ },
198
+ },
199
+ // ── H6: DROP TABLE / DROP DATABASE via psql ─────────────────────
200
+ {
201
+ id: 'H6',
202
+ severity: 'HIGH',
203
+ run: (ctx) => {
204
+ // Bash form: `(psql|pgcli)[^|&;]*DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)`
205
+ // The `[^|&;]*` keeps the match within a single shell segment.
206
+ // We use `anySegmentContains` so the segment splitter already
207
+ // bounds the match — and a literal `[^|&;]*` regex inside a
208
+ // segment is still safe.
209
+ if (!anySegmentContains(ctx.cmd, '(psql|pgcli)[^|&;]*DROP\\s+(TABLE|DATABASE|SCHEMA)')) {
210
+ return [];
211
+ }
212
+ return [
213
+ {
214
+ severity: 'HIGH',
215
+ id: 'H6',
216
+ label: 'DROP TABLE/DATABASE via psql — destructive DDL',
217
+ detail: 'Running destructive DDL directly in psql bypasses migration pipeline safety checks.',
218
+ alternatives: [
219
+ "Alt: Use your project's migration tool. Never run DROP via ad-hoc psql.",
220
+ ],
221
+ },
222
+ ];
223
+ },
224
+ },
225
+ // ── H7: kill -9 with pgrep subshell ─────────────────────────────
226
+ {
227
+ id: 'H7',
228
+ severity: 'HIGH',
229
+ run: (ctx) => {
230
+ if (!anySegmentStartsWith(ctx.cmd, 'kill\\s+-9\\s+(\\$\\(|`)'))
231
+ return [];
232
+ return [
233
+ {
234
+ severity: 'HIGH',
235
+ id: 'H7',
236
+ label: 'kill -9 with pgrep subshell — aggressive process termination',
237
+ detail: 'Sends SIGKILL to processes matched by name, which may kill unintended processes.',
238
+ alternatives: ["Alt: 'kill -15 <pid>' (SIGTERM) for graceful shutdown."],
239
+ },
240
+ ];
241
+ },
242
+ },
243
+ // ── H8: killall -9 ──────────────────────────────────────────────
244
+ {
245
+ id: 'H8',
246
+ severity: 'HIGH',
247
+ run: (ctx) => {
248
+ if (!anySegmentStartsWith(ctx.cmd, 'killall\\s+-9\\s+\\S'))
249
+ return [];
250
+ return [
251
+ {
252
+ severity: 'HIGH',
253
+ id: 'H8',
254
+ label: 'killall -9 — SIGKILL all matching processes',
255
+ detail: 'Immediately terminates all processes with the given name without cleanup.',
256
+ alternatives: [
257
+ "Alt: 'killall -15 <name>' (SIGTERM) allows graceful shutdown.",
258
+ ],
259
+ },
260
+ ];
261
+ },
262
+ },
263
+ // ── H9: git commit --no-verify ──────────────────────────────────
264
+ {
265
+ id: 'H9',
266
+ severity: 'HIGH',
267
+ run: (ctx) => {
268
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+commit.*--no-verify'))
269
+ return [];
270
+ return [
271
+ {
272
+ severity: 'HIGH',
273
+ id: 'H9',
274
+ label: 'git commit --no-verify — skipping pre-commit hooks',
275
+ detail: 'Bypasses all pre-commit safety gates including secret scanning and linting.',
276
+ alternatives: [
277
+ 'Alt: Fix the underlying hook failure rather than bypassing it.',
278
+ ],
279
+ },
280
+ ];
281
+ },
282
+ },
283
+ // ── H10: HUSKY=0 bypass ─────────────────────────────────────────
284
+ {
285
+ id: 'H10',
286
+ severity: 'HIGH',
287
+ run: (ctx) => {
288
+ if (!anySegmentRawMatches(ctx.cmd, '^HUSKY=0\\s+git\\s+(commit|push|tag)')) {
289
+ return [];
290
+ }
291
+ return [
292
+ {
293
+ severity: 'HIGH',
294
+ id: 'H10',
295
+ label: 'HUSKY=0 — bypasses all husky git hooks',
296
+ detail: 'Setting HUSKY=0 disables pre-commit, commit-msg, and pre-push safety gates without --no-verify.',
297
+ alternatives: [
298
+ 'Alt: Fix the underlying hook failure rather than suppressing all hooks.',
299
+ ],
300
+ },
301
+ ];
302
+ },
303
+ },
304
+ // ── H11: rm -rf with broad targets ──────────────────────────────
305
+ {
306
+ id: 'H11',
307
+ severity: 'HIGH',
308
+ run: (ctx) => {
309
+ // Target alternation — must end with whitespace or EOS so `.git/foo`
310
+ // (legitimate .git/ cleanup) doesn't trigger. Mirrors the bash
311
+ // 0.15.0 fix.
312
+ const TARGETS = '(\\/|~\\/|\\.\\/\\*|\\*|\\.|src|dist|build|node_modules)(\\s|$)';
313
+ const variants = [
314
+ // -rf, -fr
315
+ `rm\\s+-[a-zA-Z]*r[a-zA-Z]*f\\s+${TARGETS}`,
316
+ `rm\\s+-[a-zA-Z]*f[a-zA-Z]*r\\s+${TARGETS}`,
317
+ // split flags
318
+ `rm\\s+-[a-zA-Z]*r\\s+-[a-zA-Z]*f\\s+${TARGETS}`,
319
+ `rm\\s+-[a-zA-Z]*f\\s+-[a-zA-Z]*r\\s+${TARGETS}`,
320
+ // long flags
321
+ `rm\\s+--recursive\\s+--force\\s+${TARGETS}`,
322
+ `rm\\s+--force\\s+--recursive\\s+${TARGETS}`,
323
+ ];
324
+ const hit = variants.some((p) => anySegmentStartsWith(ctx.cmd, p));
325
+ if (!hit)
326
+ return [];
327
+ return [
328
+ {
329
+ severity: 'HIGH',
330
+ id: 'H11',
331
+ label: 'rm -rf with broad target — mass file deletion',
332
+ detail: 'Permanently deletes files and directories. Cannot be undone.',
333
+ alternatives: [
334
+ "Alt: Move to a temp location first, or use 'rm -ri' for interactive deletion.",
335
+ ],
336
+ },
337
+ ];
338
+ },
339
+ },
340
+ // ── H12: curl/wget piped to shell ───────────────────────────────
341
+ {
342
+ id: 'H12',
343
+ severity: 'HIGH',
344
+ run: (ctx) => {
345
+ // Pipe-RCE is fundamentally multi-segment. Scan the WHOLE command
346
+ // (not split) via quoteMaskedCmd to skip quoted-mention false
347
+ // positives, then iterate nested-shell payloads so
348
+ // `zsh -c "curl … | sh"` also fires.
349
+ const lines = unwrapNestedShells(ctx.cmd);
350
+ const re = /(curl|wget)[^|]*\|\s*(sudo\s+)?(bash|sh|zsh|fish)/i;
351
+ for (const line of lines) {
352
+ if (line.length === 0)
353
+ continue;
354
+ const masked = quoteMaskedCmd(line);
355
+ if (re.test(masked)) {
356
+ return [
357
+ {
358
+ severity: 'HIGH',
359
+ id: 'H12',
360
+ label: 'curl/wget piped to shell — remote code execution',
361
+ detail: 'Executing remote scripts without inspection is a major supply chain risk.',
362
+ alternatives: [
363
+ 'Alt: Download first, inspect the script, then execute: curl -o script.sh URL && cat script.sh && bash script.sh',
364
+ ],
365
+ },
366
+ ];
367
+ }
368
+ }
369
+ return [];
370
+ },
371
+ },
372
+ // ── H13: git push --no-verify ───────────────────────────────────
373
+ {
374
+ id: 'H13',
375
+ severity: 'HIGH',
376
+ run: (ctx) => {
377
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+push.*--no-verify'))
378
+ return [];
379
+ return [
380
+ {
381
+ severity: 'HIGH',
382
+ id: 'H13',
383
+ label: 'git push --no-verify — skipping pre-push hooks',
384
+ detail: 'Bypasses all pre-push safety gates including CI checks.',
385
+ alternatives: [
386
+ 'Alt: Fix the underlying hook failure rather than bypassing it.',
387
+ ],
388
+ },
389
+ ];
390
+ },
391
+ },
392
+ // ── H14: git -c core.hooksPath ─────────────────────────────────
393
+ {
394
+ id: 'H14',
395
+ severity: 'HIGH',
396
+ run: (ctx) => {
397
+ if (!anySegmentStartsWith(ctx.cmd, 'git\\s+-c\\s+core\\.hookspath')) {
398
+ return [];
399
+ }
400
+ return [
401
+ {
402
+ severity: 'HIGH',
403
+ id: 'H14',
404
+ label: 'git -c core.hooksPath — overriding hooks directory',
405
+ detail: 'Redirecting the hooks path can disable all safety hooks.',
406
+ alternatives: [
407
+ 'Alt: Fix the underlying hook issue. Do not bypass the hooks directory.',
408
+ ],
409
+ },
410
+ ];
411
+ },
412
+ },
413
+ // ── H15: REA_BYPASS env var ─────────────────────────────────────
414
+ {
415
+ id: 'H15',
416
+ severity: 'HIGH',
417
+ run: (ctx) => {
418
+ if (!anySegmentRawMatches(ctx.cmd, '^REA_BYPASS\\s*='))
419
+ return [];
420
+ return [
421
+ {
422
+ severity: 'HIGH',
423
+ id: 'H15',
424
+ label: 'REA_BYPASS env var — unauthorized bypass attempt',
425
+ detail: 'Setting REA_BYPASS is not a supported escape mechanism and indicates a bypass attempt.',
426
+ alternatives: ['Alt: If you need to override a gate, request human escalation.'],
427
+ },
428
+ ];
429
+ },
430
+ },
431
+ // ── H16: alias/function with bypass strings ─────────────────────
432
+ {
433
+ id: 'H16',
434
+ severity: 'HIGH',
435
+ run: (ctx) => {
436
+ if (!anySegmentRawMatches(ctx.cmd, '^(alias|function)\\s+[a-zA-Z_]+.*(--(no-verify|force)|HUSKY=0|core\\.hookspath)')) {
437
+ return [];
438
+ }
439
+ return [
440
+ {
441
+ severity: 'HIGH',
442
+ id: 'H16',
443
+ label: 'Alias/function definition with bypass — circumventing safety gates',
444
+ detail: 'Defining aliases or functions that embed bypass flags defeats safety hooks.',
445
+ alternatives: ['Alt: Do not wrap bypass patterns in aliases or functions.'],
446
+ },
447
+ ];
448
+ },
449
+ },
450
+ // ── H17: context_protection delegate-to-subagent ────────────────
451
+ {
452
+ id: 'H17',
453
+ severity: 'HIGH',
454
+ run: (ctx) => {
455
+ for (const pattern of ctx.delegatePatterns) {
456
+ if (pattern.length === 0)
457
+ continue;
458
+ const escaped = escapeERE(pattern);
459
+ if (anySegmentStartsWith(ctx.cmd, `${escaped}(\\s|$)`)) {
460
+ return [
461
+ {
462
+ severity: 'HIGH',
463
+ id: 'H17',
464
+ label: 'Context protection — command must run in a subagent',
465
+ detail: 'This command produces excessive output that will exhaust the coordinator context window. Delegate it to a subagent instead of running it directly.',
466
+ alternatives: [
467
+ `Alt: Use the Agent tool to delegate: Agent(subagent_type: 'qa-engineer-automation', prompt: 'Run ${pattern} and report pass/fail summary only.')`,
468
+ 'Alt: The context_protection policy in .rea/policy.yaml lists commands that must be delegated.',
469
+ ],
470
+ },
471
+ ];
472
+ }
473
+ }
474
+ return [];
475
+ },
476
+ },
477
+ // ── M1: npm install --force ─────────────────────────────────────
478
+ {
479
+ id: 'M1',
480
+ severity: 'MEDIUM',
481
+ run: (ctx) => {
482
+ if (!anySegmentContains(ctx.cmd, 'npm\\s+(install|i)\\s+.*--force')) {
483
+ return [];
484
+ }
485
+ return [
486
+ {
487
+ severity: 'MEDIUM',
488
+ id: 'M1',
489
+ label: 'npm install --force — bypasses dependency resolution',
490
+ detail: '--force skips conflict checks and can install incompatible package versions.',
491
+ alternatives: [
492
+ 'Alt: Resolve the dependency conflict explicitly. Use --legacy-peer-deps if needed.',
493
+ ],
494
+ },
495
+ ];
496
+ },
497
+ },
498
+ ];
499
+ function buildBlockBanner(violations, cmdDisplay) {
500
+ const lines = ['BASH INTERCEPTED: Dangerous command blocked\n'];
501
+ for (const v of violations) {
502
+ lines.push(` ${v.severity}: ${v.label}\n`);
503
+ lines.push(` Reason: ${v.detail}\n`);
504
+ for (const alt of v.alternatives) {
505
+ lines.push(` ${alt}\n`);
506
+ }
507
+ lines.push('\n');
508
+ }
509
+ lines.push(` BLOCKED COMMAND: ${cmdDisplay}\n`);
510
+ return lines.join('');
511
+ }
512
+ function buildAdvisoryBanner(violations, cmdDisplay) {
513
+ const lines = ['BASH ADVISORY: Potentially risky command (not blocked)\n'];
514
+ for (const v of violations) {
515
+ lines.push(` ${v.severity}: ${v.label}\n`);
516
+ lines.push(` Note: ${v.detail}\n`);
517
+ for (const alt of v.alternatives) {
518
+ lines.push(` ${alt}\n`);
519
+ }
520
+ lines.push('\n');
521
+ }
522
+ lines.push(` COMMAND: ${cmdDisplay}\n`);
523
+ return lines.join('');
524
+ }
525
+ /**
526
+ * Load the `context_protection.delegate_to_subagent` patterns from
527
+ * policy.yaml. Failure (missing file, unparseable YAML, missing key)
528
+ * returns an empty list — same posture as the bash hook's
529
+ * `policy_list "delegate_to_subagent"` which gracefully yields no
530
+ * entries on missing/malformed policy.
531
+ *
532
+ * 2026-05-15 codex round-2 P2 fix: do NOT use `loadPolicy()`. The
533
+ * strict zod schema rejects unknown keys (`zod.strict()`), which means
534
+ * a partial / migrating policy.yaml with ANY legacy field anywhere in
535
+ * the tree causes `loadPolicy()` to throw → the catch swallows it →
536
+ * delegate list collapses to `[]` → H17 patterns silently disabled.
537
+ *
538
+ * Same class as the 0.33.0 round-1 P3 fix for architecture-review-gate.
539
+ * Mirror that pattern: read the YAML directly via the canonical
540
+ * permissive parser (`yaml.parse()`) and pull ONLY the
541
+ * `context_protection.delegate_to_subagent` field. Unknown keys
542
+ * ELSEWHERE in the policy are tolerated — only this subset matters
543
+ * for H17.
544
+ *
545
+ * The bash hook's `policy_list "delegate_to_subagent"` reads the same
546
+ * field via awk without any schema validation, so this aligns the
547
+ * Node port with the bash hook's permissive posture.
548
+ */
549
+ function loadDelegatePatterns(reaRoot) {
550
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
551
+ let raw;
552
+ try {
553
+ raw = fs.readFileSync(policyPath, 'utf8');
554
+ }
555
+ catch {
556
+ return [];
557
+ }
558
+ let parsed;
559
+ try {
560
+ parsed = parseYaml(raw);
561
+ }
562
+ catch {
563
+ return [];
564
+ }
565
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
566
+ return [];
567
+ }
568
+ const cp = parsed['context_protection'];
569
+ if (cp === null || cp === undefined || typeof cp !== 'object' || Array.isArray(cp)) {
570
+ return [];
571
+ }
572
+ const delegates = cp['delegate_to_subagent'];
573
+ if (!Array.isArray(delegates))
574
+ return [];
575
+ const out = [];
576
+ for (const entry of delegates) {
577
+ if (typeof entry === 'string' && entry.length > 0) {
578
+ out.push(entry);
579
+ }
580
+ }
581
+ return out;
582
+ }
583
+ /**
584
+ * Pure executor. Returns `{ exitCode, stderr, violations }`; the CLI
585
+ * wrapper translates them into `process.stderr.write` + `process.exit`.
586
+ */
587
+ export async function runDangerousBashInterceptor(options = {}) {
588
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
589
+ let stderr = '';
590
+ const writeStderr = (s) => {
591
+ stderr += s;
592
+ if (options.stderrWrite)
593
+ options.stderrWrite(s);
594
+ };
595
+ // 1. HALT check.
596
+ const halt = checkHalt(reaRoot);
597
+ if (halt.halted) {
598
+ writeStderr(formatHaltBanner(halt.reason));
599
+ return { exitCode: 2, stderr, violations: [] };
600
+ }
601
+ // 2. Read + parse stdin.
602
+ const stdinRaw = options.stdinOverride !== undefined
603
+ ? options.stdinOverride
604
+ : await readStdinWithTimeout(5_000);
605
+ let toolName = '';
606
+ let cmd = '';
607
+ try {
608
+ const payload = parseHookPayload(stdinRaw);
609
+ toolName = payload.toolName;
610
+ cmd = payload.command;
611
+ }
612
+ catch (err) {
613
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
614
+ writeStderr(`dangerous-bash-interceptor: ${err.message} — refusing on uncertainty.\n`);
615
+ return { exitCode: 2, stderr, violations: [] };
616
+ }
617
+ throw err;
618
+ }
619
+ // 3. Non-Bash tool calls bypass — Claude Code's hook matcher
620
+ // already filters to Bash but defense-in-depth.
621
+ if (toolName !== '' && toolName !== 'Bash') {
622
+ return { exitCode: 0, stderr, violations: [] };
623
+ }
624
+ // 4. Empty command → allow.
625
+ if (cmd.length === 0) {
626
+ return { exitCode: 0, stderr, violations: [] };
627
+ }
628
+ // 5. Smart exclusion flags.
629
+ const cmdIsRebaseSafe = anySegmentStartsWith(cmd, 'git\\s+(rebase)\\s.*(--abort|--continue)');
630
+ const cmdIsCleanDry = anySegmentStartsWith(cmd, 'git\\s+clean.*([ \\t]-n|--dry-run)');
631
+ // 6. Delegate patterns.
632
+ const delegatePatterns = loadDelegatePatterns(reaRoot);
633
+ // 7. Run every rule.
634
+ const ctx = {
635
+ cmd,
636
+ cmdIsRebaseSafe,
637
+ cmdIsCleanDry,
638
+ delegatePatterns,
639
+ };
640
+ const violations = [];
641
+ for (const rule of RULES) {
642
+ violations.push(...rule.run(ctx));
643
+ }
644
+ if (violations.length === 0) {
645
+ return { exitCode: 0, stderr, violations: [] };
646
+ }
647
+ const display = truncate(cmd);
648
+ const highs = violations.filter((v) => v.severity === 'HIGH');
649
+ if (highs.length > 0) {
650
+ writeStderr(buildBlockBanner(violations, display));
651
+ return { exitCode: 2, stderr, violations };
652
+ }
653
+ writeStderr(buildAdvisoryBanner(violations, display));
654
+ return { exitCode: 0, stderr, violations };
655
+ }
656
+ /**
657
+ * CLI entry point — `rea hook dangerous-bash-interceptor`.
658
+ */
659
+ export async function runHookDangerousBashInterceptor(options = {}) {
660
+ const result = await runDangerousBashInterceptor({
661
+ ...options,
662
+ stderrWrite: (s) => process.stderr.write(s),
663
+ });
664
+ process.exit(result.exitCode);
665
+ }
666
+ // Internal exports for byte-fidelity / rule-catalog tests.
667
+ export const __INTERNAL_FOR_TESTS = {
668
+ RULES,
669
+ };