@bookedsolid/rea 0.34.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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>;
@@ -0,0 +1,168 @@
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 path from 'node:path';
28
+ import fs from 'node:fs';
29
+ import { parse as parseYaml } from 'yaml';
30
+ import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
31
+ import { parseHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
32
+ import { runProtectedScan } from '../bash-scanner/index.js';
33
+ import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
34
+ function loadPolicyPermissive(reaRoot) {
35
+ const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
36
+ const empty = { protectedRelax: [] };
37
+ if (!fs.existsSync(policyPath))
38
+ return empty;
39
+ let raw;
40
+ try {
41
+ raw = fs.readFileSync(policyPath, 'utf8');
42
+ }
43
+ catch {
44
+ return empty;
45
+ }
46
+ let parsed;
47
+ try {
48
+ parsed = parseYaml(raw);
49
+ }
50
+ catch {
51
+ return empty;
52
+ }
53
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
54
+ return empty;
55
+ }
56
+ const obj = parsed;
57
+ const out = { protectedRelax: [] };
58
+ if (Array.isArray(obj['protected_writes'])) {
59
+ out.protectedWrites = [];
60
+ for (const e of obj['protected_writes']) {
61
+ if (typeof e === 'string' && e.length > 0)
62
+ out.protectedWrites.push(e);
63
+ }
64
+ }
65
+ if (Array.isArray(obj['protected_paths_relax'])) {
66
+ for (const e of obj['protected_paths_relax']) {
67
+ if (typeof e === 'string' && e.length > 0)
68
+ out.protectedRelax.push(e);
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+ export async function runProtectedPathsBashGate(options = {}) {
74
+ const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
75
+ let stderr = '';
76
+ const writeStderr = (s) => {
77
+ stderr += s;
78
+ if (options.stderrWrite)
79
+ options.stderrWrite(s);
80
+ };
81
+ // 1. HALT check.
82
+ const halt = checkHalt(reaRoot);
83
+ if (halt.halted) {
84
+ writeStderr(formatHaltBanner(halt.reason));
85
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: 'rea HALT active' } };
86
+ }
87
+ // 2. Read + parse stdin.
88
+ const stdinRaw = options.stdinOverride !== undefined
89
+ ? options.stdinOverride
90
+ : await readStdinWithTimeout(5_000);
91
+ let toolName = '';
92
+ let cmd = '';
93
+ try {
94
+ const payload = parseHookPayload(stdinRaw);
95
+ toolName = payload.toolName;
96
+ cmd = payload.command;
97
+ }
98
+ catch (err) {
99
+ if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
100
+ writeStderr(`protected-paths-bash-gate: ${err.message} — refusing on uncertainty.\n`);
101
+ return { exitCode: 2, stderr, verdict: { verdict: 'block', reason: err.message } };
102
+ }
103
+ throw err;
104
+ }
105
+ // 3. Non-Bash tool calls bypass.
106
+ if (toolName !== '' && toolName !== 'Bash') {
107
+ return { exitCode: 0, stderr, verdict: null };
108
+ }
109
+ // 4. Empty command → allow.
110
+ if (cmd.length === 0) {
111
+ return { exitCode: 0, stderr, verdict: null };
112
+ }
113
+ // 5. Load policy permissively.
114
+ const policy = loadPolicyPermissive(reaRoot);
115
+ const relax = [...policy.protectedRelax];
116
+ // 6. REA_HOOK_PATCH_SESSION — relax .claude/hooks/ when env var is
117
+ // set with a non-empty reason. Mirrors settings-protection.sh §6b
118
+ // posture (the Bash-tier counterpart wasn't enforcing this against
119
+ // .claude/hooks/ until 0.35.0 — that gap is closed here).
120
+ const patchSession = options.patchSessionOverride ?? process.env['REA_HOOK_PATCH_SESSION'] ?? '';
121
+ if (patchSession.length > 0) {
122
+ relax.push('.claude/hooks/');
123
+ }
124
+ // 7. Scan.
125
+ const verdict = runProtectedScan({
126
+ reaRoot,
127
+ policy: {
128
+ ...(policy.protectedWrites !== undefined
129
+ ? { protected_writes: policy.protectedWrites }
130
+ : {}),
131
+ protected_paths_relax: relax,
132
+ },
133
+ stderr: (line) => writeStderr(line),
134
+ }, cmd);
135
+ // 8. Audit.
136
+ try {
137
+ await appendAuditRecord(reaRoot, {
138
+ tool_name: 'rea.hook.protected-paths-bash-gate',
139
+ server_name: 'rea',
140
+ tier: Tier.Read,
141
+ status: verdict.verdict === 'allow' ? InvocationStatus.Allowed : InvocationStatus.Denied,
142
+ metadata: {
143
+ verdict: verdict.verdict,
144
+ ...(verdict.detected_form !== undefined ? { detected_form: verdict.detected_form } : {}),
145
+ ...(verdict.hit_pattern !== undefined ? { hit_pattern: verdict.hit_pattern } : {}),
146
+ ...(patchSession.length > 0 ? { patch_session: true } : {}),
147
+ command_preview: cmd.slice(0, 256),
148
+ },
149
+ });
150
+ }
151
+ catch {
152
+ /* best-effort */
153
+ }
154
+ if (verdict.verdict === 'block') {
155
+ if (typeof verdict.reason === 'string' && verdict.reason.length > 0) {
156
+ writeStderr(verdict.reason + '\n');
157
+ }
158
+ return { exitCode: 2, stderr, verdict };
159
+ }
160
+ return { exitCode: 0, stderr, verdict };
161
+ }
162
+ export async function runHookProtectedPathsBashGate(options = {}) {
163
+ const result = await runProtectedPathsBashGate({
164
+ ...options,
165
+ stderrWrite: (s) => process.stderr.write(s),
166
+ });
167
+ process.exit(result.exitCode);
168
+ }
@@ -127,7 +127,22 @@ const SECRET_PATTERNS = [
127
127
  {
128
128
  severity: 'HIGH',
129
129
  label: 'Supabase service role key (JWT)',
130
- regex: /SUPABASE_SERVICE_ROLE_KEY\s*=\s*["']?eyJ[A-Za-z0-9._-]{50,}/g,
130
+ // 0.36.0 audit-trail (charter item 5 / 0.34.0 codex round-7 P2 #2):
131
+ // restore byte-parity with the pre-0.34.0 bash body. The bash hook
132
+ // required a quote introducer (`["']`, no `?`); only quoted
133
+ // assignments matched HIGH. Pre-fix this TS regex made the quote
134
+ // OPTIONAL (`["']?`), which upgraded an unquoted `.env` line like
135
+ // `SUPABASE_SERVICE_ROLE_KEY=eyJ...` from MEDIUM advisory (matched
136
+ // by the lower-down `.env credential assignment` pattern) to HIGH
137
+ // blocking. That over-blocks legitimate `.env` files committed to
138
+ // public repos and breaks parity with consumers still on the bash
139
+ // body. Fix: drop the `?` so the quote is required, matching bash
140
+ // exactly. Unquoted assignments continue to fire MEDIUM via the
141
+ // `.env credential assignment` pattern below (line ~190); the only
142
+ // change is that they no longer ALSO fire HIGH here. The `[\"']`
143
+ // character class accepts both single and double quotes
144
+ // (mirroring the bash `["\'"'"']` literal).
145
+ regex: /SUPABASE_SERVICE_ROLE_KEY\s*=\s*["']eyJ[A-Za-z0-9._-]{50,}/g,
131
146
  },
132
147
  // ── MEDIUM severity (advisory) ───────────────────────────────────
133
148
  {
@@ -153,10 +168,57 @@ const SECRET_PATTERNS = [
153
168
  label: 'Hardcoded DB connection string with password',
154
169
  regex: /postgresql:\/\/[^:]+:[^@]{8,}@/g,
155
170
  },
171
+ {
172
+ severity: 'MEDIUM',
173
+ label: 'Supabase service role key (JWT, unquoted non-.env shape)',
174
+ // 0.36.0 codex round-2 P1 → round-3 P3 → round-4 P1 evolution.
175
+ //
176
+ // Background:
177
+ // - Round 1: 0.34.0-introduced HIGH regex had `["']?` (quote
178
+ // optional) which over-blocked unquoted .env lines vs the
179
+ // pre-0.34.0 bash baseline. Charter item 5 dropped the `?`.
180
+ // - Round 2 P1: dropping the `?` left a gap for unquoted forms
181
+ // outside `^FOO=` shape (`export FOO=…` etc.) that ALSO
182
+ // existed in the bash baseline. Added an unquoted-anywhere
183
+ // MEDIUM rule to close it.
184
+ // - Round 3 P3: unquoted-anywhere double-fired with the broader
185
+ // `.env credential assignment` MEDIUM on plain `^FOO=` lines.
186
+ // Narrowed to only fire on 5 specific shell-keyword prefixes
187
+ // (`export`, `readonly`, `declare`, `local`, `typeset`).
188
+ // - Round 4 P1: too narrow — left other unquoted shapes
189
+ // (Dockerfile `ENV FOO=…`, k8s manifests, ad-hoc shell
190
+ // `FOO=…; bar`) entirely unscanned.
191
+ //
192
+ // Round-4 resolution: fire on any unquoted assignment that is
193
+ // NOT at the start of a line (`^`). The `.env credential
194
+ // assignment` MEDIUM pattern owns `^FOO=…`; this rule owns
195
+ // everything else (`ENV FOO=…`, `export FOO=…`, `; FOO=…`,
196
+ // template-string `${FOO=…}`, etc.). Implemented via a
197
+ // multi-line regex with a look-behind for "anything except a
198
+ // line start". JS regex doesn't support a direct "not at line
199
+ // start" assertion, so we require at least one non-newline char
200
+ // before `SUPABASE_SERVICE_ROLE_KEY` on the same line.
201
+ //
202
+ // The `(?!["'])` look-ahead refuses when the value starts with
203
+ // a quote so the same secret isn't double-reported by the HIGH
204
+ // pattern above. Result: each secret produces exactly one
205
+ // MEDIUM finding regardless of shape, and the HIGH rule keeps
206
+ // exclusive ownership of quoted forms.
207
+ regex: /(?<=[^\n\r])\bSUPABASE_SERVICE_ROLE_KEY\s*=\s*(?!["'])eyJ[A-Za-z0-9._-]{50,}/gm,
208
+ },
156
209
  {
157
210
  severity: 'MEDIUM',
158
211
  label: 'Supabase anon key in non-client context',
159
- regex: /SUPABASE_ANON_KEY\s*=\s*["']?eyJ[A-Za-z0-9._-]{50,}/g,
212
+ // 0.36.0 audit-trail (charter item 5 / 0.34.0 codex round-7 P2 #2,
213
+ // sibling fix): same parity restoration as the SUPABASE_SERVICE_ROLE_KEY
214
+ // pattern above — bash hook required a quote introducer, TS pattern
215
+ // had it optional via `?`. Removed for byte-parity. Unquoted .env
216
+ // forms continue to be advisory via the broader `.env credential
217
+ // assignment` MEDIUM pattern (where SUPABASE_ANON_KEY is NOT one of
218
+ // the named keys — anon-key prose in unquoted .env is acceptable
219
+ // since anon keys are public; only QUOTED-in-source matches stay
220
+ // an advisory MEDIUM here).
221
+ regex: /SUPABASE_ANON_KEY\s*=\s*["']eyJ[A-Za-z0-9._-]{50,}/g,
160
222
  },
161
223
  ];
162
224
  /**
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Node-binary port of `hooks/settings-protection.sh`.
3
+ *
4
+ * 0.35.0 Phase 4 port. The LARGEST hook in the repo at 582 LOC of
5
+ * bash — this is the gate that protects `.claude/settings.json`,
6
+ * `.claude/hooks/*`, `.husky/*`, `.rea/policy.yaml`, and `.rea/HALT`
7
+ * from agent writes. Without it the entire governance layer can be
8
+ * disabled by an agent's own Write tool.
9
+ *
10
+ * Behavioral contract — preserves the bash hook section by section:
11
+ *
12
+ * 1. HALT check → exit 2 with shared banner.
13
+ * 2. Read stdin, extract `tool_input.file_path` (or `notebook_path`
14
+ * via the shared Write payload parser). Missing → exit 0.
15
+ *
16
+ * §5a Path-traversal reject (`..` segment in raw OR normalized form).
17
+ * §5a-bis Interior `/./` segment reject (NORMALIZED form only).
18
+ *
19
+ * §5b Extension-surface allow-list. `.husky/{commit-msg,pre-push,
20
+ * pre-commit,prepare-commit-msg}.d/*` is the documented consumer
21
+ * extension surface — fragments here are NOT protected, with
22
+ * two defense-in-depth checks:
23
+ * (a) Final-component symlink refusal (`fs.lstatSync().isSymbolicLink()`).
24
+ * (b) Intermediate-directory symlink resolution — the parent's
25
+ * realpath must STILL end in `/.husky/<surface>.d/` or
26
+ * `/.husky/<surface>.d` (directory-boundary anchored per
27
+ * 0.20.1 helix-021 #3).
28
+ *
29
+ * §6 Default-protected list resolution. Sourced from
30
+ * `_lib/protected-paths.ts`'s `resolveProtectedPatterns` which
31
+ * honors `protected_writes` (full override) and
32
+ * `protected_paths_relax` (subtractor). Match runs case-insensitive.
33
+ *
34
+ * §6c Intermediate-symlink resolution against the hard-protected list
35
+ * (helix-016 H.1 fix). Parallel to §5b's surface-only check, this
36
+ * runs against ANY protected pattern.
37
+ *
38
+ * §6b REA_HOOK_PATCH_SESSION unlock for `.claude/hooks/` (the only
39
+ * patch-session pattern). When the env var is set with a non-
40
+ * empty reason, audit-log the edit (via the shared TS audit
41
+ * primitive — directly, no shell-out gymnastics) and allow.
42
+ * Audit-append failure is fail-closed — block the edit and
43
+ * surface the failure. This preserves hash-chain integrity.
44
+ *
45
+ * §6c-bis Patch-session patterns blocked when env var is NOT set.
46
+ *
47
+ * Stderr formatting is preserved verbatim from the bash hook so
48
+ * existing log-parsing consumers (if any) keep working.
49
+ */
50
+ import type { Buffer } from 'node:buffer';
51
+ export interface SettingsProtectionOptions {
52
+ reaRoot?: string;
53
+ stdinOverride?: string | Buffer;
54
+ stderrWrite?: (s: string) => void;
55
+ /** Test seam — overrides `process.env.REA_HOOK_PATCH_SESSION`. */
56
+ patchSessionOverride?: string;
57
+ /** Test seam — overrides `process.env.CLAUDE_SESSION_ID`. */
58
+ sessionIdOverride?: string;
59
+ }
60
+ export interface SettingsProtectionResult {
61
+ exitCode: number;
62
+ stderr: string;
63
+ /**
64
+ * When the gate blocks: the matched pattern (one of PROTECTED_PATTERNS,
65
+ * PATCH_SESSION_PATTERNS, or a §5a/§5a-bis sentinel string).
66
+ */
67
+ matched: string | null;
68
+ /** When the gate blocks via §5b extension-surface symlink refusal. */
69
+ surfaceSymlinkRefused: boolean;
70
+ /** When the gate allows under REA_HOOK_PATCH_SESSION. */
71
+ patchSessionAllowed: boolean;
72
+ }
73
+ export declare function runSettingsProtection(options?: SettingsProtectionOptions): Promise<SettingsProtectionResult>;
74
+ export declare function runHookSettingsProtection(options?: SettingsProtectionOptions): Promise<void>;