@bookedsolid/rea 0.34.0 → 0.36.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.
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Shared protected-paths catalog for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/protected-paths.sh`. The
5
+ * canonical hard-protected list shared between the Write/Edit tier
6
+ * (`settings-protection`) and the Bash tier (`protected-paths-bash-
7
+ * gate` → already in the bash-scanner module via `runProtectedScan`).
8
+ *
9
+ * # Why a TS port at all?
10
+ *
11
+ * The bash helper is sourced into both `settings-protection.sh` and
12
+ * the Bash-tier scanner caller. Now that settings-protection.sh is
13
+ * being moved to Node-binary in 0.35.0, the protected-list resolution
14
+ * needs to land in TypeScript too — otherwise the new `runSettingsProtection`
15
+ * would have to shell out to bash to read the list, which defeats the
16
+ * point of the Node-binary migration.
17
+ *
18
+ * # Kill-switch invariants (NON-RELAXABLE)
19
+ *
20
+ * These are ALWAYS protected, even when listed in `protected_paths_relax`:
21
+ *
22
+ * .rea/HALT — the kill switch itself
23
+ * .rea/policy.yaml — the policy that defines all enforcement
24
+ * .claude/settings.json — the hook registration that activates rea
25
+ * .rea/last-review.cache.json — verdict-cache security boundary
26
+ * .rea/last-review.json — operator forensic snapshot
27
+ *
28
+ * # Policy interaction
29
+ *
30
+ * - `protected_writes` (optional list): when set, FULLY REPLACES the
31
+ * hardcoded default. Kill-switch invariants are added back
32
+ * defensively. The override pattern set is tracked separately so
33
+ * `isProtected()` can prioritize override matches over the
34
+ * extension-surface allow-list (helix-020 G2 fix).
35
+ * - `protected_paths_relax` (list): SUBTRACTS from whatever the
36
+ * effective set is. Kill-switch invariants in this list are silently
37
+ * dropped + an advisory is emitted to stderr (caller's responsibility
38
+ * to surface).
39
+ */
40
+ export const KILL_SWITCH_INVARIANTS = [
41
+ '.claude/settings.json',
42
+ '.rea/policy.yaml',
43
+ '.rea/HALT',
44
+ '.rea/last-review.cache.json',
45
+ '.rea/last-review.json',
46
+ ];
47
+ /**
48
+ * Hardcoded historical default — the 7 patterns the bash helper ships
49
+ * (`REA_PROTECTED_PATTERNS_FULL`). Suffix `/` indicates prefix match;
50
+ * no suffix means case-insensitive exact match.
51
+ */
52
+ export const PROTECTED_PATTERNS_FULL = [
53
+ '.claude/settings.json',
54
+ '.claude/settings.local.json',
55
+ '.husky/',
56
+ '.rea/policy.yaml',
57
+ '.rea/HALT',
58
+ '.rea/last-review.cache.json',
59
+ '.rea/last-review.json',
60
+ ];
61
+ /**
62
+ * Patch-session patterns — protected from agents by default but
63
+ * unlockable by setting `REA_HOOK_PATCH_SESSION=<reason>`. Mirrors
64
+ * `PATCH_SESSION_PATTERNS` in settings-protection.sh §6b.
65
+ */
66
+ export const PATCH_SESSION_PATTERNS = ['.claude/hooks/'];
67
+ /**
68
+ * Documented husky extension surface — `.husky/{commit-msg,pre-push,
69
+ * pre-commit,prepare-commit-msg}.d/*`. Consumers write extension
70
+ * fragments here freely; the §6 prefix block on `.husky/` would
71
+ * otherwise catch them.
72
+ *
73
+ * The bare directory itself (e.g. `.husky/pre-push.d/`) is NOT
74
+ * considered extension-surface — only fragments INSIDE the surface.
75
+ */
76
+ export function isExtensionSurface(p) {
77
+ const lower = p.toLowerCase();
78
+ const surfaces = [
79
+ '.husky/commit-msg.d/',
80
+ '.husky/pre-push.d/',
81
+ '.husky/pre-commit.d/',
82
+ '.husky/prepare-commit-msg.d/',
83
+ ];
84
+ // Refuse the bare directory itself.
85
+ for (const s of surfaces) {
86
+ if (lower === s)
87
+ return false;
88
+ if (lower === s.slice(0, -1))
89
+ return false; // without trailing slash
90
+ }
91
+ for (const s of surfaces) {
92
+ if (lower.startsWith(s) && lower.length > s.length) {
93
+ return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+ /**
99
+ * Resolve the effective hard-protected pattern set against policy.
100
+ * Pure function — no I/O, no stderr emission. Stderr advisories come
101
+ * back as strings so the caller can route them appropriately.
102
+ */
103
+ export function resolveProtectedPatterns(input = {}) {
104
+ const writes = input.protectedWrites;
105
+ const relax = input.protectedPathsRelax ?? [];
106
+ // 1. Compose the BASE list.
107
+ const baseList = [];
108
+ if (writes !== undefined) {
109
+ // protected_writes set — replaces the default.
110
+ for (const w of writes) {
111
+ if (typeof w === 'string' && w.length > 0)
112
+ baseList.push(w);
113
+ }
114
+ // Add kill-switch invariants if not already present (case-insensitive).
115
+ for (const inv of KILL_SWITCH_INVARIANTS) {
116
+ const invLc = inv.toLowerCase();
117
+ if (!baseList.some((b) => b.toLowerCase() === invLc)) {
118
+ baseList.push(inv);
119
+ }
120
+ }
121
+ }
122
+ else {
123
+ for (const pat of PROTECTED_PATTERNS_FULL)
124
+ baseList.push(pat);
125
+ }
126
+ // 2. Validate relax entries — kill-switch invariants are non-relaxable.
127
+ const advisories = [];
128
+ const relaxedSet = [];
129
+ for (const r of relax) {
130
+ if (typeof r !== 'string' || r.length === 0)
131
+ continue;
132
+ if (KILL_SWITCH_INVARIANTS.some((inv) => inv.toLowerCase() === r.toLowerCase())) {
133
+ advisories.push(`rea: protected_paths_relax: ${r} is a kill-switch invariant and cannot be relaxed; ignoring.\n`);
134
+ }
135
+ else {
136
+ relaxedSet.push(r);
137
+ }
138
+ }
139
+ // 3. Build the effective list — base entries NOT in relaxed set
140
+ // (case-insensitive comparison).
141
+ const patterns = [];
142
+ for (const pat of baseList) {
143
+ const patLc = pat.toLowerCase();
144
+ const relaxed = relaxedSet.some((r) => r.toLowerCase() === patLc);
145
+ if (!relaxed)
146
+ patterns.push(pat);
147
+ }
148
+ // 4. Build the OVERRIDE subset (only entries from `protected_writes`,
149
+ // NOT kill-switch invariants added back defensively). Mirrors
150
+ // REA_PROTECTED_OVERRIDE_PATTERNS in the bash helper.
151
+ const overridePatterns = [];
152
+ if (writes !== undefined) {
153
+ for (const w of writes) {
154
+ if (typeof w !== 'string' || w.length === 0)
155
+ continue;
156
+ const wLc = w.toLowerCase();
157
+ const relaxed = relaxedSet.some((r) => r.toLowerCase() === wLc);
158
+ if (!relaxed)
159
+ overridePatterns.push(w);
160
+ }
161
+ }
162
+ return { patterns, overridePatterns, advisories };
163
+ }
164
+ /**
165
+ * Match a project-relative path against a pattern list. Mirrors the
166
+ * shell exact-equal AND the trailing-slash prefix-glob shapes,
167
+ * case-INSENSITIVE.
168
+ *
169
+ * Returns the matched pattern (preserving its original case) or `null`.
170
+ */
171
+ export function matchAny(pathLc, patterns) {
172
+ for (const pattern of patterns) {
173
+ const patternLc = pattern.toLowerCase();
174
+ if (pathLc === patternLc)
175
+ return pattern;
176
+ if (patternLc.endsWith('/') && pathLc.startsWith(patternLc))
177
+ return pattern;
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * Full equivalent of `rea_path_is_protected` from the bash helper.
183
+ * Three-step decision:
184
+ *
185
+ * 1. Explicit `protected_writes` overrides win FIRST (helix-020 G2).
186
+ * 2. Extension-surface allow-list short-circuits "not protected"
187
+ * for `.husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d`
188
+ * fragments.
189
+ * 3. Default hard-protected list (kill-switch invariants + the
190
+ * historical patterns from PROTECTED_PATTERNS_FULL).
191
+ */
192
+ export function isProtected(pathRel, resolution) {
193
+ const lower = pathRel.toLowerCase();
194
+ // 1. Explicit overrides win.
195
+ const overrideHit = matchAny(lower, resolution.overridePatterns);
196
+ if (overrideHit !== null) {
197
+ return { protected: true, matchedPattern: overrideHit };
198
+ }
199
+ // 2. Extension-surface short-circuit.
200
+ if (isExtensionSurface(pathRel)) {
201
+ return { protected: false, matchedPattern: null };
202
+ }
203
+ // 3. Default protected list.
204
+ const defaultHit = matchAny(lower, resolution.patterns);
205
+ if (defaultHit !== null) {
206
+ return { protected: true, matchedPattern: defaultHit };
207
+ }
208
+ return { protected: false, matchedPattern: null };
209
+ }
210
+ /**
211
+ * Strip C0/C1 control characters from a string before echoing it back to
212
+ * the operator. Mirrors `sanitize_for_stderr` in settings-protection.sh.
213
+ *
214
+ * Byte ranges stripped (after UTF-16→code-point):
215
+ * – — C0 controls (BEL, BS, HT, LF, CR, ESC, …)
216
+ *  — DEL
217
+ * €–Ÿ — C1 controls (CSI, OSC, …)
218
+ *
219
+ * String-level filter — does NOT operate on raw bytes. Sufficient for
220
+ * the bash helper's use case: file-name display in error messages.
221
+ */
222
+ export function sanitizeForStderr(s) {
223
+ let out = '';
224
+ for (const ch of s) {
225
+ const cp = ch.codePointAt(0);
226
+ if ((cp >= 0x00 && cp <= 0x1f) || cp === 0x7f || (cp >= 0x80 && cp <= 0x9f)) {
227
+ continue;
228
+ }
229
+ out += ch;
230
+ }
231
+ return out;
232
+ }
@@ -467,6 +467,50 @@ function splitSegmentsRecursive(cmd, depth) {
467
467
  }
468
468
  return out;
469
469
  }
470
+ /**
471
+ * Test whether a flag token is a `-c`-class introducer.
472
+ *
473
+ * Bash accepts `-c` combined with any other short single-char flags in
474
+ * a single short-flag bundle: `-c`, `-lc`, `-lic`, `-cli`, `-lci`,
475
+ * `-cil`, `-ilc`, etc. The bash cmd-segments.sh `WRAP` regex lists
476
+ * a non-exhaustive defensive subset (`c|lc|lic|ic|cl|cli|li|il`) but
477
+ * bash itself accepts ANY short-flag bundle that contains a `c`.
478
+ *
479
+ * The TS detector mirrors bash semantics: a SHORT-flag bundle
480
+ * (`-letters`, single leading `-`) whose letter set contains `c` is
481
+ * an introducer. The separated `--c` long-flag form is also
482
+ * recognized for parity with the bash WRAP regex's `--c` alternation.
483
+ *
484
+ * Long flags (`--rcfile`, `--noprofile`, `--login`, `--init-file`)
485
+ * are NOT introducers regardless of whether they contain the letter
486
+ * `c` — bash's long-options namespace is disjoint from the `-c`
487
+ * payload-execute semantics.
488
+ *
489
+ * 0.36.0 audit-trail:
490
+ * - Charter item 4 / 0.34.0 codex round-7 P2 #1: pre-fix the test
491
+ * was `/c/i.test(flag.replace(/^--?/, ''))` which over-matched on
492
+ * every flag with a `c` in its name (`--rcfile`, `--noprofile`).
493
+ * - 0.36.0 codex round-1 P1: the first fix attempt used an explicit
494
+ * allowlist `Set` mirroring the bash WRAP regex's explicit
495
+ * alternation, which was a NARROWING vs the pre-fix behavior —
496
+ * valid combined-flag forms like `-lci`, `-cil`, `-ilc` were not
497
+ * in the allowlist and stopped unwrapping, reopening a bypass
498
+ * surface against env-file-protection / dependency-audit-gate /
499
+ * dangerous-bash matchers. This function restores parity with
500
+ * bash itself: any short-flag bundle containing `c` qualifies.
501
+ */
502
+ function isCDashIntroducer(flag) {
503
+ // Separated long-flag form (rare but bash accepts it).
504
+ if (flag === '--c')
505
+ return true;
506
+ // Short-flag bundle: single leading `-`, then one-or-more letters.
507
+ // The bundle is a `-c` introducer iff it contains the letter `c`
508
+ // (any position, any other-letters mix).
509
+ const m = /^-([A-Za-z]+)$/.exec(flag);
510
+ if (m === null)
511
+ return false;
512
+ return /c/i.test(m[1] ?? '');
513
+ }
470
514
  /**
471
515
  * Recognize a nested-shell wrapper segment and return the unquoted
472
516
  * payload string. Returns `null` when the segment is not a wrapper.
@@ -554,15 +598,29 @@ function extractNestedShellPayload(head) {
554
598
  return null;
555
599
  const flag = flagMatch[0] ?? '';
556
600
  cursor += flag.length;
557
- // Recognized flag-token shapes:
558
- // `-c` `-l` `-i` `-e` `-lc` `-lic` `-ic` `-cl` `-cli` `-li` `-il`
559
- // `--c` `--noprofile` (etc.) — we don't enforce the full list,
560
- // just that it's `-<letters>` or `--<letters>`.
601
+ // Recognized flag-token shapes (parity with cmd-segments.sh WRAP):
602
+ // - pre-flags (no `-c` yet): `-l`, `-i`, `-e`, `-li`, `-il`,
603
+ // `--noprofile`, `--rcfile`, `--login` (etc.)
604
+ // - `-c`-class introducer: exactly `-c`, `-lc`, `-lic`, `-cl`,
605
+ // `-cli`, `-li`, `-il`, `-ic` (the bash WRAP regex's
606
+ // `-(c|lc|lic|ic|cl|cli|li|il)` set), OR separated `--c`.
607
+ //
608
+ // 0.36.0 audit-trail (charter item 4 / 0.34.0 codex round-7 P2 #1):
609
+ // pre-fix the test `/c/i.test(flag.replace(/^--?/, ''))` treated
610
+ // ANY flag containing the letter `c` as a `-c` introducer. This
611
+ // false-positived on benign flags like `--rcfile`, `--noprofile`
612
+ // (with `c` in the name), causing the walker to "commit" to a -c
613
+ // unwrap, advance past the flag, and then either fail to find a
614
+ // quoted payload or unwrap something that was never a shell-payload
615
+ // body. Net effect: over-trigger of nested-shell unwrap, with
616
+ // downstream advisory matchers seeing payloads that weren't ever
617
+ // shell-payloads. Fix restricts the introducer set to the exact
618
+ // WRAP-regex shapes; any other flag shape continues the flag walk
619
+ // (still valid — pre-flags before `-c` are accepted) but does NOT
620
+ // mark `sawCFlag = true`.
561
621
  if (!/^--?[A-Za-z]+$/.test(flag))
562
622
  return null;
563
- // Does this flag contain `c` (the -c introducer letter)?
564
- // `--c` also counts (rare but bash accepts).
565
- if (/c/i.test(flag.replace(/^--?/, ''))) {
623
+ if (isCDashIntroducer(flag)) {
566
624
  sawCFlag = true;
567
625
  // Continue the loop — the payload is the NEXT non-flag token.
568
626
  // (Bash's argv parser stops walking flags as soon as it sees -c,
@@ -570,6 +628,8 @@ function extractNestedShellPayload(head) {
570
628
  // safety; the bash WRAP regex similarly tolerates trailing
571
629
  // flag-like tokens before the quoted body.)
572
630
  }
631
+ // Else: a pre-flag (e.g. `-l`, `--rcfile`, `--noprofile`) — keep
632
+ // walking; if a later token IS in `CDASH_INTRODUCERS` we'll fire.
573
633
  }
574
634
  if (!sawCFlag)
575
635
  return null;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-bash-gate.sh`.
3
+ *
4
+ * 0.35.0 Phase 3 port (paired tier-1 scanner-shim). This was a thin
5
+ * bash shim over `rea hook scan-bash --mode blocked` — the heavy
6
+ * lifting (the parser-backed AST walker that closes 9 bypass classes
7
+ * from helix-023 + discord-ops Round 13) lives in `src/hooks/bash-
8
+ * scanner/`.
9
+ *
10
+ * The Node-binary port preserves the same byte-for-byte verdict shape
11
+ * and exit-code contract but eliminates the bash-shim → node-CLI →
12
+ * scanner-module subprocess hop. The caller is now `rea hook blocked-
13
+ * paths-bash-gate`, which calls `runBlockedScan` directly.
14
+ *
15
+ * Behavioral contract — preserves the bash hook byte-for-byte:
16
+ *
17
+ * 1. HALT check → exit 2 with shared banner.
18
+ * 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0
19
+ * (the bash gate's `[[ -z "$payload" ]] && exit 0` guard).
20
+ * 3. Non-Bash tool calls bypass — Claude Code's hook matcher already
21
+ * filters to Bash but defense-in-depth.
22
+ * 4. Load policy permissively (a partial/migrating policy.yaml with
23
+ * unknown keys must NOT collapse the `blocked_paths` list — same
24
+ * lesson from 0.33.0 round-1 P3 + 0.34.0 round-2 P2).
25
+ * 5. Empty `blocked_paths` → allow (no-op). Mirrors
26
+ * `runBlockedScan({ blockedPaths: [] }, cmd)` short-circuit.
27
+ * 6. Run `runBlockedScan` against the command.
28
+ * 7. Verdict `block` → exit 2 with the scanner's reason. Verdict
29
+ * `allow` → exit 0.
30
+ *
31
+ * Audit-log parity: emits a `rea.hook.blocked-paths-bash-gate` entry
32
+ * (best-effort, never blocks the verdict on audit failure).
33
+ */
34
+ import type { Buffer } from 'node:buffer';
35
+ import { type Verdict } from '../bash-scanner/index.js';
36
+ export interface BlockedPathsBashGateOptions {
37
+ reaRoot?: string;
38
+ stdinOverride?: string | Buffer;
39
+ stderrWrite?: (s: string) => void;
40
+ }
41
+ export interface BlockedPathsBashGateResult {
42
+ exitCode: number;
43
+ stderr: string;
44
+ /** Final verdict from the scanner (test seam). */
45
+ verdict: Verdict | null;
46
+ }
47
+ /**
48
+ * Pure executor. Returns `{ exitCode, stderr, verdict }`; the CLI
49
+ * wrapper translates them into `process.stderr.write` + `process.exit`.
50
+ */
51
+ export declare function runBlockedPathsBashGate(options?: BlockedPathsBashGateOptions): Promise<BlockedPathsBashGateResult>;
52
+ /**
53
+ * CLI entry point — `rea hook blocked-paths-bash-gate`.
54
+ */
55
+ export declare function runHookBlockedPathsBashGate(options?: BlockedPathsBashGateOptions): Promise<void>;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-bash-gate.sh`.
3
+ *
4
+ * 0.35.0 Phase 3 port (paired tier-1 scanner-shim). This was a thin
5
+ * bash shim over `rea hook scan-bash --mode blocked` — the heavy
6
+ * lifting (the parser-backed AST walker that closes 9 bypass classes
7
+ * from helix-023 + discord-ops Round 13) lives in `src/hooks/bash-
8
+ * scanner/`.
9
+ *
10
+ * The Node-binary port preserves the same byte-for-byte verdict shape
11
+ * and exit-code contract but eliminates the bash-shim → node-CLI →
12
+ * scanner-module subprocess hop. The caller is now `rea hook blocked-
13
+ * paths-bash-gate`, which calls `runBlockedScan` directly.
14
+ *
15
+ * Behavioral contract — preserves the bash hook byte-for-byte:
16
+ *
17
+ * 1. HALT check → exit 2 with shared banner.
18
+ * 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0
19
+ * (the bash gate's `[[ -z "$payload" ]] && exit 0` guard).
20
+ * 3. Non-Bash tool calls bypass — Claude Code's hook matcher already
21
+ * filters to Bash but defense-in-depth.
22
+ * 4. Load policy permissively (a partial/migrating policy.yaml with
23
+ * unknown keys must NOT collapse the `blocked_paths` list — same
24
+ * lesson from 0.33.0 round-1 P3 + 0.34.0 round-2 P2).
25
+ * 5. Empty `blocked_paths` → allow (no-op). Mirrors
26
+ * `runBlockedScan({ blockedPaths: [] }, cmd)` short-circuit.
27
+ * 6. Run `runBlockedScan` against the command.
28
+ * 7. Verdict `block` → exit 2 with the scanner's reason. Verdict
29
+ * `allow` → exit 0.
30
+ *
31
+ * Audit-log parity: emits a `rea.hook.blocked-paths-bash-gate` entry
32
+ * (best-effort, never blocks the verdict on audit failure).
33
+ */
34
+ import path from 'node:path';
35
+ import fs from 'node:fs';
36
+ import { parse as parseYaml } from 'yaml';
37
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
38
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
39
+ import { runBlockedScan } from '../bash-scanner/index.js';
40
+ import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
41
+ /**
42
+ * Load `blocked_paths` from `<reaRoot>/.rea/policy.yaml` permissively.
43
+ *
44
+ * Why not `loadPolicy`? The strict zod loader refuses partial / unknown
45
+ * keys (it's strict-mode by design). A consumer running a migrating
46
+ * policy.yaml or holding legacy keys would have their `blocked_paths`
47
+ * effectively wiped — silently. The bash gate's pre-0.35.0 yaml grep
48
+ * scanned for the key directly with no schema validation; we mirror
49
+ * that permissive posture by reading `blocked_paths` from the parsed
50
+ * YAML directly without validation.
51
+ *
52
+ * Returns `[]` on any failure (missing file, bad YAML, missing key,
53
+ * unexpected type). Empty list is the "no enforcement" no-op state.
54
+ */
55
+ function loadBlockedPathsPermissive(reaRoot) {
56
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
57
+ if (!fs.existsSync(policyPath))
58
+ return [];
59
+ let raw;
60
+ try {
61
+ raw = fs.readFileSync(policyPath, 'utf8');
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ let parsed;
67
+ try {
68
+ parsed = parseYaml(raw);
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
74
+ return [];
75
+ }
76
+ const obj = parsed;
77
+ const bp = obj['blocked_paths'];
78
+ if (!Array.isArray(bp))
79
+ return [];
80
+ const out = [];
81
+ for (const entry of bp) {
82
+ if (typeof entry === 'string' && entry.length > 0) {
83
+ out.push(entry);
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * Pure executor. Returns `{ exitCode, stderr, verdict }`; the CLI
90
+ * wrapper translates them into `process.stderr.write` + `process.exit`.
91
+ */
92
+ export async function runBlockedPathsBashGate(options = {}) {
93
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
94
+ let stderr = '';
95
+ const writeStderr = (s) => {
96
+ stderr += s;
97
+ if (options.stderrWrite)
98
+ options.stderrWrite(s);
99
+ };
100
+ // 1. HALT check.
101
+ const halt = checkHalt(reaRoot);
102
+ if (halt.halted) {
103
+ writeStderr(formatHaltBanner(halt.reason));
104
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: 'rea HALT active' } };
105
+ }
106
+ // 2. Read + parse stdin.
107
+ const stdinRaw = options.stdinOverride !== undefined
108
+ ? options.stdinOverride
109
+ : await readStdinWithTimeout(5_000);
110
+ let toolName = '';
111
+ let cmd = '';
112
+ try {
113
+ const payload = parseHookPayload(stdinRaw);
114
+ toolName = payload.toolName;
115
+ cmd = payload.command;
116
+ }
117
+ catch (err) {
118
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
119
+ writeStderr(`blocked-paths-bash-gate: ${err.message} — refusing on uncertainty.\n`);
120
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: err.message } };
121
+ }
122
+ throw err;
123
+ }
124
+ // 3. Non-Bash tool calls bypass.
125
+ if (toolName !== '' && toolName !== 'Bash') {
126
+ return { exitCode: 0, stderr, verdict: null };
127
+ }
128
+ // 4. Empty command → allow.
129
+ if (cmd.length === 0) {
130
+ return { exitCode: 0, stderr, verdict: null };
131
+ }
132
+ // 5. Load policy permissively.
133
+ const blockedPaths = loadBlockedPathsPermissive(reaRoot);
134
+ // 6. Empty list → allow.
135
+ if (blockedPaths.length === 0) {
136
+ return { exitCode: 0, stderr, verdict: { verdict: 'allow' } };
137
+ }
138
+ // 7. Scan.
139
+ const verdict = runBlockedScan({ reaRoot, blockedPaths }, cmd);
140
+ // 8. Audit — best-effort, never changes verdict.
141
+ try {
142
+ await appendAuditRecord(reaRoot, {
143
+ tool_name: 'rea.hook.blocked-paths-bash-gate',
144
+ server_name: 'rea',
145
+ tier: Tier.Read,
146
+ status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
147
+ metadata: {
148
+ verdict: verdict.verdict,
149
+ ...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
150
+ ...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
151
+ command_preview: cmd.slice(0, 256),
152
+ },
153
+ });
154
+ }
155
+ catch {
156
+ /* best-effort */
157
+ }
158
+ if (verdict.verdict === 'block') {
159
+ if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
160
+ writeStderr(verdict.reason + '\n');
161
+ }
162
+ return { exitCode: 2, stderr, verdict };
163
+ }
164
+ return { exitCode: 0, stderr, verdict };
165
+ }
166
+ /**
167
+ * CLI entry point — `rea hook blocked-paths-bash-gate`.
168
+ */
169
+ export async function runHookBlockedPathsBashGate(options = {}) {
170
+ const result = await runBlockedPathsBashGate({
171
+ ...options,
172
+ stderrWrite: (s) => process.stderr.write(s),
173
+ });
174
+ process.exit(result.exitCode);
175
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Node-binary port of `hooks/blocked-paths-enforcer.sh`.
3
+ *
4
+ * 0.35.0 Phase 4 port (paired Write/Edit tier). Enforces
5
+ * `policy.blocked_paths` against Write/Edit/MultiEdit/NotebookEdit
6
+ * tool calls. Sibling of `blocked-paths-bash-gate` (Bash-tier) — same
7
+ * policy data, different surface.
8
+ *
9
+ * Behavioral contract — preserves the bash hook byte-for-byte:
10
+ *
11
+ * 1. HALT check → exit 2 with shared banner.
12
+ * 2. Read stdin, extract `tool_input.file_path` (or `notebook_path`).
13
+ * Missing/empty → exit 0.
14
+ * 3. Load policy permissively (a partial / migrating policy.yaml
15
+ * must NOT collapse the blocked_paths list).
16
+ * 4. Empty `blocked_paths` → exit 0.
17
+ * 5. §5a path-traversal rejection. Refuses any path with a `..`
18
+ * segment in EITHER the raw form OR the normalized form. Also
19
+ * catches URL-encoded traversal (`%2E%2E/`, `..%2F`, etc.)
20
+ * against the raw input.
21
+ * 6. §5a-bis interior `/./` segment rejection (0.29.0 helix-/./-class).
22
+ * NORMALIZED form only — `normalize_path` already strips leading
23
+ * `./` segments, so anything remaining is interior by construction.
24
+ * 7. Agent-writable allow-list short-circuit (`.rea/tasks.jsonl`,
25
+ * `.rea/audit/`) — even if blocked_paths includes `.rea/` as a
26
+ * prefix block, these are PM-data writeables.
27
+ * 8. Match the normalized path against each blocked entry:
28
+ * - directory prefix (entry ends with `/`)
29
+ * - glob (entry contains `*`)
30
+ * - exact (lower-case, case-INSENSITIVE)
31
+ * Match → exit 2 with reason.
32
+ * 9. §H.2 intermediate-symlink resolution. If the parent dir exists,
33
+ * resolve its realpath. If the resolved target falls inside a
34
+ * blocked entry, refuse.
35
+ *
36
+ * Audit-log parity: emits a `rea.hook.blocked-paths-enforcer` entry.
37
+ */
38
+ import type { Buffer } from 'node:buffer';
39
+ export interface BlockedPathsEnforcerOptions {
40
+ reaRoot?: string;
41
+ stdinOverride?: string | Buffer;
42
+ stderrWrite?: (s: string) => void;
43
+ }
44
+ export interface BlockedPathsEnforcerResult {
45
+ exitCode: number;
46
+ stderr: string;
47
+ /** Test seam — when the gate blocks, the matched blocked-paths entry. */
48
+ matched: string | null;
49
+ }
50
+ export declare function runBlockedPathsEnforcer(options?: BlockedPathsEnforcerOptions): Promise<BlockedPathsEnforcerResult>;
51
+ export declare function runHookBlockedPathsEnforcer(options?: BlockedPathsEnforcerOptions): Promise<void>;