@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
|
@@ -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>;
|
|
@@ -0,0 +1,287 @@
|
|
|
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 path from 'node:path';
|
|
39
|
+
import fs from 'node:fs';
|
|
40
|
+
import { parse as parseYaml } from 'yaml';
|
|
41
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
42
|
+
import { parseWriteHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
43
|
+
import { normalizePath, hasTraversalSegment, hasInteriorDotSegment, resolveCanonRoot, resolveParentRealpath, } from '../_lib/path-normalize.js';
|
|
44
|
+
import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
|
|
45
|
+
const AGENT_WRITABLE = ['.rea/tasks.jsonl', '.rea/audit/'];
|
|
46
|
+
/** Match `pathLc` against a single blocked entry. Returns true on hit. */
|
|
47
|
+
function matchBlockedEntry(pathLc, blockedEntry) {
|
|
48
|
+
const entryLc = blockedEntry.toLowerCase();
|
|
49
|
+
// Directory prefix.
|
|
50
|
+
if (entryLc.endsWith('/')) {
|
|
51
|
+
if (pathLc.startsWith(entryLc))
|
|
52
|
+
return true;
|
|
53
|
+
if (pathLc === entryLc.slice(0, -1))
|
|
54
|
+
return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
// Glob (contains *).
|
|
58
|
+
if (entryLc.includes('*')) {
|
|
59
|
+
// Convert glob to regex: . → \., * → .*; anchor to whole string.
|
|
60
|
+
const escaped = entryLc.replace(/[.+^${}()|[\]\\]/g, (m) => `\\${m}`);
|
|
61
|
+
const re = '^' + escaped.replace(/\*/g, '.*') + '$';
|
|
62
|
+
try {
|
|
63
|
+
return new RegExp(re).test(pathLc);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Exact.
|
|
70
|
+
return pathLc === entryLc;
|
|
71
|
+
}
|
|
72
|
+
function loadBlockedPathsPermissive(reaRoot) {
|
|
73
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
74
|
+
if (!fs.existsSync(policyPath))
|
|
75
|
+
return [];
|
|
76
|
+
let raw;
|
|
77
|
+
try {
|
|
78
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = parseYaml(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const obj = parsed;
|
|
94
|
+
const bp = obj['blocked_paths'];
|
|
95
|
+
if (!Array.isArray(bp))
|
|
96
|
+
return [];
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const entry of bp) {
|
|
99
|
+
if (typeof entry === 'string' && entry.length > 0)
|
|
100
|
+
out.push(entry);
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
export async function runBlockedPathsEnforcer(options = {}) {
|
|
105
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
106
|
+
let stderr = '';
|
|
107
|
+
const writeStderr = (s) => {
|
|
108
|
+
stderr += s;
|
|
109
|
+
if (options.stderrWrite)
|
|
110
|
+
options.stderrWrite(s);
|
|
111
|
+
};
|
|
112
|
+
// 1. HALT check.
|
|
113
|
+
const halt = checkHalt(reaRoot);
|
|
114
|
+
if (halt.halted) {
|
|
115
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
116
|
+
return { exitCode: 2, stderr, matched: null };
|
|
117
|
+
}
|
|
118
|
+
// 2. Read + parse stdin.
|
|
119
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
120
|
+
? options.stdinOverride
|
|
121
|
+
: await readStdinWithTimeout(5_000);
|
|
122
|
+
let filePath = '';
|
|
123
|
+
try {
|
|
124
|
+
const payload = parseWriteHookPayload(stdinRaw);
|
|
125
|
+
filePath = payload.filePath;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
129
|
+
writeStderr(`blocked-paths-enforcer: ${err.message} — refusing on uncertainty.\n`);
|
|
130
|
+
return { exitCode: 2, stderr, matched: null };
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
if (filePath.length === 0) {
|
|
135
|
+
return { exitCode: 0, stderr, matched: null };
|
|
136
|
+
}
|
|
137
|
+
// 3. Load policy permissively.
|
|
138
|
+
const blockedPaths = loadBlockedPathsPermissive(reaRoot);
|
|
139
|
+
if (blockedPaths.length === 0) {
|
|
140
|
+
return { exitCode: 0, stderr, matched: null };
|
|
141
|
+
}
|
|
142
|
+
// 4. Normalize.
|
|
143
|
+
const normalized = normalizePath(filePath, reaRoot);
|
|
144
|
+
const lowerNorm = normalized.toLowerCase();
|
|
145
|
+
// 5. §5a path-traversal rejection. Both raw + normalized.
|
|
146
|
+
const rawTraversal = hasTraversalSegment(filePath.replace(/\\/g, '/'));
|
|
147
|
+
const normTraversal = hasTraversalSegment(normalized);
|
|
148
|
+
// URL-encoded traversal check on raw input.
|
|
149
|
+
const urlEncodedTraversal = /%2[Ee]%2[Ee]|%2[Ee]\.|\.%2[Ee]/.test(filePath);
|
|
150
|
+
if (rawTraversal || normTraversal || urlEncodedTraversal) {
|
|
151
|
+
writeStderr('BLOCKED PATH: path traversal rejected\n');
|
|
152
|
+
writeStderr('\n');
|
|
153
|
+
writeStderr(` File: ${filePath}\n`);
|
|
154
|
+
writeStderr(" Rule: path contains a '..' segment; rewrite to a canonical\n");
|
|
155
|
+
writeStderr(' project-relative path without traversal.\n');
|
|
156
|
+
return { exitCode: 2, stderr, matched: null };
|
|
157
|
+
}
|
|
158
|
+
// 6. §5a-bis interior `/./` segment rejection.
|
|
159
|
+
if (hasInteriorDotSegment(normalized)) {
|
|
160
|
+
writeStderr('BLOCKED PATH: interior dot-segment rejected\n');
|
|
161
|
+
writeStderr('\n');
|
|
162
|
+
writeStderr(` File: ${filePath}\n`);
|
|
163
|
+
writeStderr(" Rule: path contains an interior '/./' segment; rewrite to a\n");
|
|
164
|
+
writeStderr(' canonical project-relative path without dot segments.\n');
|
|
165
|
+
return { exitCode: 2, stderr, matched: null };
|
|
166
|
+
}
|
|
167
|
+
// 7. Agent-writable allow-list.
|
|
168
|
+
for (const writable of AGENT_WRITABLE) {
|
|
169
|
+
if (normalized === writable)
|
|
170
|
+
return { exitCode: 0, stderr, matched: null };
|
|
171
|
+
if (writable.endsWith('/') && normalized.startsWith(writable)) {
|
|
172
|
+
return { exitCode: 0, stderr, matched: null };
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// 8. Match against blocked_paths.
|
|
176
|
+
let matched = null;
|
|
177
|
+
for (const blocked of blockedPaths) {
|
|
178
|
+
if (matchBlockedEntry(lowerNorm, blocked)) {
|
|
179
|
+
matched = blocked;
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (matched !== null) {
|
|
184
|
+
const isGlob = matched.includes('*');
|
|
185
|
+
writeStderr('BLOCKED PATH: Write denied by policy\n');
|
|
186
|
+
writeStderr('\n');
|
|
187
|
+
writeStderr(` File: ${filePath}\n`);
|
|
188
|
+
writeStderr(` Blocked by: ${matched}${isGlob ? ' (glob pattern)' : ''}\n`);
|
|
189
|
+
writeStderr(' Source: .rea/policy.yaml → blocked_paths\n');
|
|
190
|
+
if (matched.endsWith('/')) {
|
|
191
|
+
writeStderr('\n');
|
|
192
|
+
writeStderr(' This path is protected by policy. To modify it, a human must\n');
|
|
193
|
+
writeStderr(' either update blocked_paths in policy.yaml or edit the file directly.\n');
|
|
194
|
+
}
|
|
195
|
+
await maybeAudit(reaRoot, 'denied', matched, filePath);
|
|
196
|
+
return { exitCode: 2, stderr, matched };
|
|
197
|
+
}
|
|
198
|
+
// 9. §H.2 intermediate-symlink resolution.
|
|
199
|
+
const symMatched = checkSymlinkResolution(filePath, blockedPaths, reaRoot);
|
|
200
|
+
if (symMatched !== null) {
|
|
201
|
+
writeStderr('BLOCKED PATH: intermediate-symlink resolution blocked\n');
|
|
202
|
+
writeStderr('\n');
|
|
203
|
+
writeStderr(` Logical: ${filePath}\n`);
|
|
204
|
+
writeStderr(` Resolved: ${symMatched.resolvedTarget}\n`);
|
|
205
|
+
writeStderr(` Blocked by: ${symMatched.entry}\n`);
|
|
206
|
+
writeStderr(' Source: .rea/policy.yaml → blocked_paths\n');
|
|
207
|
+
writeStderr('\n');
|
|
208
|
+
writeStderr(' Rule: an intermediate directory of the path is a symlink\n');
|
|
209
|
+
writeStderr(' whose target falls inside a blocked policy entry.\n');
|
|
210
|
+
await maybeAudit(reaRoot, 'denied', symMatched.entry, filePath);
|
|
211
|
+
return { exitCode: 2, stderr, matched: symMatched.entry };
|
|
212
|
+
}
|
|
213
|
+
await maybeAudit(reaRoot, 'allowed', null, filePath);
|
|
214
|
+
return { exitCode: 0, stderr, matched: null };
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Symlink-resolution check — mirrors `hooks/blocked-paths-enforcer.sh`
|
|
218
|
+
* §H.2. Returns the matched entry + resolved target form, or null.
|
|
219
|
+
*/
|
|
220
|
+
function checkSymlinkResolution(filePath, blockedPaths, reaRoot) {
|
|
221
|
+
// Only attempt resolution if the target exists or its parent dir
|
|
222
|
+
// exists — matches the bash `if [[ -e "$FILE_PATH" || -d ... ]]`.
|
|
223
|
+
let targetExists = false;
|
|
224
|
+
try {
|
|
225
|
+
targetExists = fs.existsSync(filePath);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
/* fall through */
|
|
229
|
+
}
|
|
230
|
+
const parentDir = path.dirname(filePath);
|
|
231
|
+
let parentExists = false;
|
|
232
|
+
try {
|
|
233
|
+
parentExists = fs.statSync(parentDir).isDirectory();
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
/* falls through */
|
|
237
|
+
}
|
|
238
|
+
if (!targetExists && !parentExists)
|
|
239
|
+
return null;
|
|
240
|
+
if (!parentExists)
|
|
241
|
+
return null;
|
|
242
|
+
const resolvedParent = resolveParentRealpath(filePath);
|
|
243
|
+
if (resolvedParent.length === 0)
|
|
244
|
+
return null;
|
|
245
|
+
const canonRoot = resolveCanonRoot(reaRoot);
|
|
246
|
+
// Resolved parent must be inside REA_ROOT for the check to be
|
|
247
|
+
// meaningful — external paths are out of scope (the logical-path
|
|
248
|
+
// matchers handle them).
|
|
249
|
+
if (resolvedParent !== canonRoot && !resolvedParent.startsWith(canonRoot + '/')) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const relativeResolved = resolvedParent === canonRoot ? '' : resolvedParent.slice(canonRoot.length + 1);
|
|
253
|
+
const resolvedTarget = relativeResolved.length > 0
|
|
254
|
+
? `${relativeResolved}/${path.basename(filePath)}`
|
|
255
|
+
: path.basename(filePath);
|
|
256
|
+
const resolvedTargetLc = resolvedTarget.toLowerCase();
|
|
257
|
+
for (const blocked of blockedPaths) {
|
|
258
|
+
if (matchBlockedEntry(resolvedTargetLc, blocked)) {
|
|
259
|
+
return { entry: blocked, resolvedTarget };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
async function maybeAudit(reaRoot, status, matched, filePath) {
|
|
265
|
+
try {
|
|
266
|
+
await appendAuditRecord(reaRoot, {
|
|
267
|
+
tool_name: 'rea.hook.blocked-paths-enforcer',
|
|
268
|
+
server_name: 'rea',
|
|
269
|
+
tier: Tier.Write,
|
|
270
|
+
status: status === 'allowed' ? InvocationStatus.Allowed : InvocationStatus.Denied,
|
|
271
|
+
metadata: {
|
|
272
|
+
...(matched !== null ? { matched } : {}),
|
|
273
|
+
file_path_preview: filePath.slice(0, 256),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* best-effort */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
export async function runHookBlockedPathsEnforcer(options = {}) {
|
|
282
|
+
const result = await runBlockedPathsEnforcer({
|
|
283
|
+
...options,
|
|
284
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
285
|
+
});
|
|
286
|
+
process.exit(result.exitCode);
|
|
287
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary port of `hooks/protected-paths-bash-gate.sh`.
|
|
3
|
+
*
|
|
4
|
+
* 0.35.0 Phase 3 port (paired tier-1 scanner-shim). Like blocked-paths-
|
|
5
|
+
* bash-gate but uses `runProtectedScan` against the
|
|
6
|
+
* `policy.protected_writes` / `policy.protected_paths_relax` resolved
|
|
7
|
+
* set. The bash gate was already a thin shim over the parser-backed
|
|
8
|
+
* scanner; this port drops the shim → CLI → scanner subprocess hop.
|
|
9
|
+
*
|
|
10
|
+
* Behavioral contract — preserves the bash hook byte-for-byte:
|
|
11
|
+
*
|
|
12
|
+
* 1. HALT check → exit 2 with shared banner.
|
|
13
|
+
* 2. Read stdin via `parseHookPayload`. Empty/missing command → exit 0.
|
|
14
|
+
* 3. Non-Bash tool calls bypass.
|
|
15
|
+
* 4. REA_HOOK_PATCH_SESSION-class bypass: when the env var is set with
|
|
16
|
+
* a non-empty reason, the scanner's protected-set is RELAXED for
|
|
17
|
+
* .claude/hooks/ — the patch-session pattern. Implemented by
|
|
18
|
+
* appending `.claude/hooks/` to the relax list when the env var is
|
|
19
|
+
* live (this mirrors the bash gate's §6b semantics for the Bash
|
|
20
|
+
* tier).
|
|
21
|
+
* 5. Load policy permissively (same lesson as 0.34.0 round-2 P2).
|
|
22
|
+
* 6. Run `runProtectedScan` with the resolved policy context.
|
|
23
|
+
* 7. Verdict `block` → exit 2; `allow` → exit 0.
|
|
24
|
+
*
|
|
25
|
+
* Audit-log parity: emits a `rea.hook.protected-paths-bash-gate` entry.
|
|
26
|
+
*/
|
|
27
|
+
import type { Buffer } from 'node:buffer';
|
|
28
|
+
import { type Verdict } from '../bash-scanner/index.js';
|
|
29
|
+
export interface ProtectedPathsBashGateOptions {
|
|
30
|
+
reaRoot?: string;
|
|
31
|
+
stdinOverride?: string | Buffer;
|
|
32
|
+
stderrWrite?: (s: string) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Test seam — overrides `process.env.REA_HOOK_PATCH_SESSION`. The
|
|
35
|
+
* CLI wrapper omits, letting the real env var govern the bypass.
|
|
36
|
+
*/
|
|
37
|
+
patchSessionOverride?: string;
|
|
38
|
+
}
|
|
39
|
+
export interface ProtectedPathsBashGateResult {
|
|
40
|
+
exitCode: number;
|
|
41
|
+
stderr: string;
|
|
42
|
+
/** Final verdict (test seam). Null when the gate short-circuited
|
|
43
|
+
* before scanning (HALT, non-Bash, empty cmd). */
|
|
44
|
+
verdict: Verdict | null;
|
|
45
|
+
}
|
|
46
|
+
export declare function runProtectedPathsBashGate(options?: ProtectedPathsBashGateOptions): Promise<ProtectedPathsBashGateResult>;
|
|
47
|
+
export declare function runHookProtectedPathsBashGate(options?: ProtectedPathsBashGateOptions): Promise<void>;
|