@bookedsolid/rea 0.34.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/hook.js CHANGED
@@ -46,6 +46,10 @@ import { runHookArchitectureReviewGate } from '../hooks/architecture-review-gate
46
46
  import { runHookDangerousBashInterceptor } from '../hooks/dangerous-bash-interceptor/index.js';
47
47
  import { runHookLocalReviewGate } from '../hooks/local-review-gate/index.js';
48
48
  import { runHookSecretScanner } from '../hooks/secret-scanner/index.js';
49
+ import { runHookBlockedPathsBashGate } from '../hooks/blocked-paths-bash-gate/index.js';
50
+ import { runHookProtectedPathsBashGate } from '../hooks/protected-paths-bash-gate/index.js';
51
+ import { runHookBlockedPathsEnforcer } from '../hooks/blocked-paths-enforcer/index.js';
52
+ import { runHookSettingsProtection } from '../hooks/settings-protection/index.js';
49
53
  import { loadPolicy } from '../policy/loader.js';
50
54
  import { appendAuditRecord, InvocationStatus, Tier } from '../audit/append.js';
51
55
  import { CODEX_REVIEW_TOOL_NAME, CODEX_REVIEW_SERVER_NAME, } from '../audit/codex-event.js';
@@ -1022,6 +1026,30 @@ export function registerHookCommand(program) {
1022
1026
  .action(async () => {
1023
1027
  await runHookSecretScanner();
1024
1028
  });
1029
+ hook
1030
+ .command('blocked-paths-bash-gate')
1031
+ .description('Node-binary port of `hooks/blocked-paths-bash-gate.sh` (0.35.0). PreToolUse Bash gate refusing shell writes to `policy.blocked_paths` entries. Calls the AST-backed `runBlockedScan` directly (no shim→CLI→scanner subprocess hop). Permissive policy read — partial/migrating policy.yaml does NOT collapse the blocked_paths list. Empty list → no-op. Verdict `block` → exit 2; `allow` → exit 0.')
1032
+ .action(async () => {
1033
+ await runHookBlockedPathsBashGate();
1034
+ });
1035
+ hook
1036
+ .command('protected-paths-bash-gate')
1037
+ .description('Node-binary port of `hooks/protected-paths-bash-gate.sh` (0.35.0). PreToolUse Bash gate refusing shell-redirect/cp/mv/install/etc. to protected paths (.claude/settings.json, .claude/hooks/*, .husky/*, .rea/policy.yaml, .rea/HALT). Honors `policy.protected_writes` (full override) + `policy.protected_paths_relax` (subtractor). REA_HOOK_PATCH_SESSION relaxes .claude/hooks/ for the session.')
1038
+ .action(async () => {
1039
+ await runHookProtectedPathsBashGate();
1040
+ });
1041
+ hook
1042
+ .command('blocked-paths-enforcer')
1043
+ .description('Node-binary port of `hooks/blocked-paths-enforcer.sh` (0.35.0). PreToolUse Write/Edit/MultiEdit/NotebookEdit gate refusing writes to `policy.blocked_paths` entries. §5a path-traversal reject + §5a-bis interior `/./` reject + §H.2 intermediate-symlink resolution. Agent-writable allow-list (.rea/tasks.jsonl, .rea/audit/) short-circuits before policy match.')
1044
+ .action(async () => {
1045
+ await runHookBlockedPathsEnforcer();
1046
+ });
1047
+ hook
1048
+ .command('settings-protection')
1049
+ .description('Node-binary port of `hooks/settings-protection.sh` (0.35.0, the LARGEST hook in the repo at 582 LOC of bash). PreToolUse Write/Edit/MultiEdit/NotebookEdit gate protecting .claude/settings.json, .claude/hooks/*, .husky/*, .rea/policy.yaml, .rea/HALT, .rea/last-review.{json,cache.json}. Honors `protected_writes` (full override) + `protected_paths_relax` (subtractor, kill-switch invariants non-relaxable). §5b extension-surface allow-list for .husky/{commit-msg,pre-push,pre-commit,prepare-commit-msg}.d/* with final-component and intermediate-directory symlink refusal. §6c intermediate-symlink resolution. §6b REA_HOOK_PATCH_SESSION unlock for .claude/hooks/ with hash-chained audit append (fail-closed on append failure).')
1050
+ .action(async () => {
1051
+ await runHookSettingsProtection();
1052
+ });
1025
1053
  hook
1026
1054
  .command('policy-get')
1027
1055
  .description('Read a value from `.rea/policy.yaml` via the canonical YAML parser. Used by bash-tier hooks (`hooks/_lib/policy-read.sh::policy_nested_scalar`) so inline AND block YAML forms agree at a single source of truth. Default scalar mode: prints raw value or empty. With `--json`: emits JSON (scalar or object/array; missing path → `null`). Unparseable YAML → empty / null, exit 1.')
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Shared path-normalization primitives for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/path-normalize.sh`. The bash
5
+ * helper is the single source of truth shared between settings-
6
+ * protection.sh and blocked-paths-enforcer.sh (and the Bash-tier
7
+ * gates' relevance pre-checks). The 4 hooks landing in 0.35.0 all
8
+ * need the same normalization to stay byte-parity with their bash
9
+ * counterparts.
10
+ *
11
+ * Functions:
12
+ * - `normalizePath(p, reaRoot)` — project-relative form. Strip
13
+ * reaRoot prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C` → translate
14
+ * `\` → `/` → strip leading `./` segments.
15
+ * - `hasTraversalSegment(p)` — true if any `..` segment exists.
16
+ * - `hasInteriorDotSegment(p)` — true if any interior `/./` segment
17
+ * exists (0.29.0 helix-/./-class refusal).
18
+ * - `resolveParentRealpath(targetPath)` — pure-Node equivalent of
19
+ * the bash `resolve_parent_realpath`. Returns the realpath of the
20
+ * parent dir, walking up to the nearest existing ancestor if the
21
+ * parent doesn't exist yet, then appending the unresolved tail.
22
+ * - `resolveCanonRoot(reaRoot)` — `cd -P && pwd -P` equivalent of
23
+ * the project root, with macOS `/var` → `/private/var` collapse.
24
+ *
25
+ * All functions are pure (no logging, no exit) — the caller decides
26
+ * how to surface failures.
27
+ */
28
+ /**
29
+ * Project-relative form of a file path. Mirrors the bash helper
30
+ * byte-for-byte.
31
+ *
32
+ * Order of operations (lifted from `hooks/_lib/path-normalize.sh`):
33
+ * 1. Strip leading `<reaRoot>/` prefix.
34
+ * 2. URL-decode `%2F`, `%2E`, `%20`, `%5C` (case-insensitive). Other
35
+ * percent-encodings are left untouched — the bash helper only
36
+ * decodes this fixed set.
37
+ * 3. Translate backslash separators to forward slashes.
38
+ * 4. Strip leading `./` segments. Interior `./` is NOT stripped (that
39
+ * would corrupt `..` traversals — see §5a-bis).
40
+ */
41
+ export declare function normalizePath(input: string, reaRoot: string): string;
42
+ /**
43
+ * True if any `/../` segment is present (bracketing the input with `/`
44
+ * on each side so leading/trailing `..` segments still count). Mirrors
45
+ * the bash `case "/$path/" in *<slash>..<slash>*) traversal=1` shape
46
+ * (the literal asterisk-slash form is omitted to keep the JSDoc block
47
+ * from terminating early).
48
+ */
49
+ export declare function hasTraversalSegment(p: string): boolean;
50
+ /**
51
+ * True if any interior `/./` segment is present. The bash helper uses
52
+ * the equivalent `*<slash>.<slash>*` case-glob shape; leading `./` is
53
+ * stripped by normalizePath, so anything that survives is interior.
54
+ */
55
+ export declare function hasInteriorDotSegment(p: string): boolean;
56
+ /**
57
+ * Canonicalize a project root the same way bash `cd -P && pwd -P`
58
+ * would — follow every symlink in the path to the physical form. On
59
+ * macOS this collapses `/var/...` → `/private/var/...` because `/var`
60
+ * is itself a symlink. Used to make REA_ROOT prefix comparisons
61
+ * symmetric against realpath'd children.
62
+ *
63
+ * Returns the original `reaRoot` (unmodified) when realpath fails —
64
+ * the bash helper falls back the same way via `|| resolved=""`.
65
+ */
66
+ export declare function resolveCanonRoot(reaRoot: string): string;
67
+ /**
68
+ * Resolve the realpath of the parent directory of `targetPath`. Pure-
69
+ * Node mirror of `hooks/_lib/path-normalize.sh::resolve_parent_realpath`,
70
+ * including the 0.21.2 helix-022 #1 nearest-existing-ancestor walk.
71
+ *
72
+ * Returns:
73
+ * - The resolved realpath of the parent when it exists.
74
+ * - When the parent doesn't exist on disk: walk UP looking for the
75
+ * nearest existing ancestor, realpath that, then append the
76
+ * unresolved tail. This catches symlink walks where the terminal
77
+ * directory is created mid-segment (`mkdir -p linkroot/.husky/sub`).
78
+ * - Empty string when no existing ancestor inside REA_ROOT could be
79
+ * resolved.
80
+ */
81
+ export declare function resolveParentRealpath(targetPath: string): string;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Shared path-normalization primitives for the Node-binary hook tier.
3
+ *
4
+ * 0.35.0 — TypeScript port of `hooks/_lib/path-normalize.sh`. The bash
5
+ * helper is the single source of truth shared between settings-
6
+ * protection.sh and blocked-paths-enforcer.sh (and the Bash-tier
7
+ * gates' relevance pre-checks). The 4 hooks landing in 0.35.0 all
8
+ * need the same normalization to stay byte-parity with their bash
9
+ * counterparts.
10
+ *
11
+ * Functions:
12
+ * - `normalizePath(p, reaRoot)` — project-relative form. Strip
13
+ * reaRoot prefix → URL-decode `%2F`/`%2E`/`%20`/`%5C` → translate
14
+ * `\` → `/` → strip leading `./` segments.
15
+ * - `hasTraversalSegment(p)` — true if any `..` segment exists.
16
+ * - `hasInteriorDotSegment(p)` — true if any interior `/./` segment
17
+ * exists (0.29.0 helix-/./-class refusal).
18
+ * - `resolveParentRealpath(targetPath)` — pure-Node equivalent of
19
+ * the bash `resolve_parent_realpath`. Returns the realpath of the
20
+ * parent dir, walking up to the nearest existing ancestor if the
21
+ * parent doesn't exist yet, then appending the unresolved tail.
22
+ * - `resolveCanonRoot(reaRoot)` — `cd -P && pwd -P` equivalent of
23
+ * the project root, with macOS `/var` → `/private/var` collapse.
24
+ *
25
+ * All functions are pure (no logging, no exit) — the caller decides
26
+ * how to surface failures.
27
+ */
28
+ import fs from 'node:fs';
29
+ import path from 'node:path';
30
+ /**
31
+ * Project-relative form of a file path. Mirrors the bash helper
32
+ * byte-for-byte.
33
+ *
34
+ * Order of operations (lifted from `hooks/_lib/path-normalize.sh`):
35
+ * 1. Strip leading `<reaRoot>/` prefix.
36
+ * 2. URL-decode `%2F`, `%2E`, `%20`, `%5C` (case-insensitive). Other
37
+ * percent-encodings are left untouched — the bash helper only
38
+ * decodes this fixed set.
39
+ * 3. Translate backslash separators to forward slashes.
40
+ * 4. Strip leading `./` segments. Interior `./` is NOT stripped (that
41
+ * would corrupt `..` traversals — see §5a-bis).
42
+ */
43
+ export function normalizePath(input, reaRoot) {
44
+ let p = input;
45
+ // 1. Strip $REA_ROOT/ prefix.
46
+ const prefix = reaRoot.endsWith(path.sep) ? reaRoot : reaRoot + '/';
47
+ if (p === reaRoot || p.startsWith(prefix)) {
48
+ if (p === reaRoot) {
49
+ p = '';
50
+ }
51
+ else {
52
+ p = p.slice(prefix.length);
53
+ }
54
+ }
55
+ // 2. URL-decode the fixed set: %2F→/, %2E→., %20→' ', %5C→\.
56
+ p = p
57
+ .replace(/%2[Ff]/g, '/')
58
+ .replace(/%2[Ee]/g, '.')
59
+ .replace(/%20/g, ' ')
60
+ .replace(/%5[Cc]/g, '\\');
61
+ // 3. Translate backslash separators to forward slashes.
62
+ p = p.replace(/\\/g, '/');
63
+ // 4. Strip leading `./` segments only.
64
+ while (p.startsWith('./')) {
65
+ p = p.slice(2);
66
+ }
67
+ return p;
68
+ }
69
+ /**
70
+ * True if any `/../` segment is present (bracketing the input with `/`
71
+ * on each side so leading/trailing `..` segments still count). Mirrors
72
+ * the bash `case "/$path/" in *<slash>..<slash>*) traversal=1` shape
73
+ * (the literal asterisk-slash form is omitted to keep the JSDoc block
74
+ * from terminating early).
75
+ */
76
+ export function hasTraversalSegment(p) {
77
+ const bracketed = `/${p}/`;
78
+ return bracketed.includes('/../');
79
+ }
80
+ /**
81
+ * True if any interior `/./` segment is present. The bash helper uses
82
+ * the equivalent `*<slash>.<slash>*` case-glob shape; leading `./` is
83
+ * stripped by normalizePath, so anything that survives is interior.
84
+ */
85
+ export function hasInteriorDotSegment(p) {
86
+ const bracketed = `/${p}/`;
87
+ return bracketed.includes('/./');
88
+ }
89
+ /**
90
+ * Canonicalize a project root the same way bash `cd -P && pwd -P`
91
+ * would — follow every symlink in the path to the physical form. On
92
+ * macOS this collapses `/var/...` → `/private/var/...` because `/var`
93
+ * is itself a symlink. Used to make REA_ROOT prefix comparisons
94
+ * symmetric against realpath'd children.
95
+ *
96
+ * Returns the original `reaRoot` (unmodified) when realpath fails —
97
+ * the bash helper falls back the same way via `|| resolved=""`.
98
+ */
99
+ export function resolveCanonRoot(reaRoot) {
100
+ try {
101
+ return fs.realpathSync(reaRoot);
102
+ }
103
+ catch {
104
+ return reaRoot;
105
+ }
106
+ }
107
+ /**
108
+ * Resolve the realpath of the parent directory of `targetPath`. Pure-
109
+ * Node mirror of `hooks/_lib/path-normalize.sh::resolve_parent_realpath`,
110
+ * including the 0.21.2 helix-022 #1 nearest-existing-ancestor walk.
111
+ *
112
+ * Returns:
113
+ * - The resolved realpath of the parent when it exists.
114
+ * - When the parent doesn't exist on disk: walk UP looking for the
115
+ * nearest existing ancestor, realpath that, then append the
116
+ * unresolved tail. This catches symlink walks where the terminal
117
+ * directory is created mid-segment (`mkdir -p linkroot/.husky/sub`).
118
+ * - Empty string when no existing ancestor inside REA_ROOT could be
119
+ * resolved.
120
+ */
121
+ export function resolveParentRealpath(targetPath) {
122
+ const parentDir = path.dirname(targetPath);
123
+ // Fast path: parent exists. Resolve directly.
124
+ let parentStat;
125
+ try {
126
+ parentStat = fs.statSync(parentDir);
127
+ }
128
+ catch {
129
+ /* falls through to walk-up below */
130
+ }
131
+ if (parentStat?.isDirectory()) {
132
+ try {
133
+ return fs.realpathSync(parentDir);
134
+ }
135
+ catch {
136
+ return '';
137
+ }
138
+ }
139
+ // Walk up to the nearest existing ancestor; accumulate the tail.
140
+ let walk = parentDir;
141
+ let tail = '';
142
+ // Bound the walk to avoid pathological loops on relative paths.
143
+ for (let i = 0; i < 64; i++) {
144
+ if (!walk || walk === '/' || walk === '.')
145
+ break;
146
+ try {
147
+ const s = fs.statSync(walk);
148
+ if (s.isDirectory())
149
+ break;
150
+ }
151
+ catch {
152
+ /* keep walking */
153
+ }
154
+ const base = path.basename(walk);
155
+ tail = tail.length > 0 ? `${base}/${tail}` : base;
156
+ walk = path.dirname(walk);
157
+ }
158
+ if (!walk || walk === '/' || walk === '.') {
159
+ return '';
160
+ }
161
+ let resolvedWalk;
162
+ try {
163
+ resolvedWalk = fs.realpathSync(walk);
164
+ }
165
+ catch {
166
+ return '';
167
+ }
168
+ if (tail.length === 0)
169
+ return resolvedWalk;
170
+ return `${resolvedWalk}/${tail}`;
171
+ }
@@ -97,7 +97,7 @@ export function parseHookPayload(raw) {
97
97
  }
98
98
  const c = ti.command;
99
99
  if (c !== undefined && typeof c !== 'string') {
100
- throw new TypePayloadError(`hook payload tool_input.command is ${typeof c}, expected string`);
100
+ throw new TypePayloadError(`hook payload tool_input.command is non-string (got ${typeof c}); expected string`);
101
101
  }
102
102
  if (typeof c === 'string')
103
103
  command = c;
@@ -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
+ }
@@ -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>;