@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.
- package/dist/cli/doctor.js +45 -36
- package/dist/cli/hook.js +28 -0
- package/dist/hooks/_lib/path-normalize.d.ts +81 -0
- package/dist/hooks/_lib/path-normalize.js +171 -0
- package/dist/hooks/_lib/payload.js +1 -1
- package/dist/hooks/_lib/protected-paths.d.ts +0 -0
- package/dist/hooks/_lib/protected-paths.js +232 -0
- package/dist/hooks/_lib/segments.js +67 -7
- package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
- package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
- package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
- package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
- package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
- package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
- package/dist/hooks/secret-scanner/index.js +64 -2
- package/dist/hooks/settings-protection/index.d.ts +74 -0
- package/dist/hooks/settings-protection/index.js +485 -0
- package/hooks/blocked-paths-bash-gate.sh +118 -116
- package/hooks/blocked-paths-enforcer.sh +152 -256
- package/hooks/protected-paths-bash-gate.sh +123 -210
- package/hooks/settings-protection.sh +171 -549
- package/package.json +3 -2
- package/scripts/lint-awk-shim-quotes.mjs +386 -0
- package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
- package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
- package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
- package/templates/settings-protection.dogfood-staged.sh +204 -0
|
@@ -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
|
|
559
|
-
//
|
|
560
|
-
//
|
|
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
|
-
|
|
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>;
|