@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 +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/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/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 +1 -1
- 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
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}
|
|
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;
|
|
Binary file
|
|
@@ -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>;
|