@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,485 @@
|
|
|
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 fs from 'node:fs';
|
|
51
|
+
import path from 'node:path';
|
|
52
|
+
import crypto from 'node:crypto';
|
|
53
|
+
import { execSync } from 'node:child_process';
|
|
54
|
+
import { parse as parseYaml } from 'yaml';
|
|
55
|
+
import { checkHalt, formatHaltBanner } from '../_lib/halt-check.js';
|
|
56
|
+
import { parseWriteHookPayload, MalformedPayloadError, TypePayloadError, readStdinWithTimeout, } from '../_lib/payload.js';
|
|
57
|
+
import { normalizePath, hasTraversalSegment, hasInteriorDotSegment, resolveCanonRoot, resolveParentRealpath, } from '../_lib/path-normalize.js';
|
|
58
|
+
import { resolveProtectedPatterns, matchAny, isExtensionSurface, PATCH_SESSION_PATTERNS, sanitizeForStderr, } from '../_lib/protected-paths.js';
|
|
59
|
+
import { appendAuditRecord, InvocationStatus, Tier } from '../../audit/append.js';
|
|
60
|
+
function loadPolicyPermissive(reaRoot) {
|
|
61
|
+
const policyPath = path.join(reaRoot, '.rea', 'policy.yaml');
|
|
62
|
+
const empty = { protectedRelax: [] };
|
|
63
|
+
if (!fs.existsSync(policyPath))
|
|
64
|
+
return empty;
|
|
65
|
+
let raw;
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(policyPath, 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return empty;
|
|
71
|
+
}
|
|
72
|
+
let parsed;
|
|
73
|
+
try {
|
|
74
|
+
parsed = parseYaml(raw);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return empty;
|
|
78
|
+
}
|
|
79
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
80
|
+
return empty;
|
|
81
|
+
}
|
|
82
|
+
const obj = parsed;
|
|
83
|
+
const out = { protectedRelax: [] };
|
|
84
|
+
if (Array.isArray(obj['protected_writes'])) {
|
|
85
|
+
out.protectedWrites = [];
|
|
86
|
+
for (const e of obj['protected_writes']) {
|
|
87
|
+
if (typeof e === 'string' && e.length > 0)
|
|
88
|
+
out.protectedWrites.push(e);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(obj['protected_paths_relax'])) {
|
|
92
|
+
for (const e of obj['protected_paths_relax']) {
|
|
93
|
+
if (typeof e === 'string' && e.length > 0)
|
|
94
|
+
out.protectedRelax.push(e);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** sha256 of a file's contents, or '' on any failure. */
|
|
100
|
+
function sha256File(filePath) {
|
|
101
|
+
try {
|
|
102
|
+
// Use the same shell helpers the bash hook tried in order so any
|
|
103
|
+
// pre-existing operator scripts keep parity. Falling back to node
|
|
104
|
+
// crypto when the file is present.
|
|
105
|
+
const data = fs.readFileSync(filePath);
|
|
106
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function gitConfig(reaRoot, key) {
|
|
113
|
+
try {
|
|
114
|
+
return execSync(`git -C "${reaRoot.replace(/"/g, '\\"')}" config ${key}`, {
|
|
115
|
+
encoding: 'utf8',
|
|
116
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
117
|
+
}).trim();
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return 'unknown';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export async function runSettingsProtection(options = {}) {
|
|
124
|
+
const reaRoot = options.reaRoot ?? process.env['CLAUDE_PROJECT_DIR'] ?? process.cwd();
|
|
125
|
+
let stderr = '';
|
|
126
|
+
const writeStderr = (s) => {
|
|
127
|
+
stderr += s;
|
|
128
|
+
if (options.stderrWrite)
|
|
129
|
+
options.stderrWrite(s);
|
|
130
|
+
};
|
|
131
|
+
// 1. HALT check.
|
|
132
|
+
const halt = checkHalt(reaRoot);
|
|
133
|
+
if (halt.halted) {
|
|
134
|
+
writeStderr(formatHaltBanner(halt.reason));
|
|
135
|
+
return {
|
|
136
|
+
exitCode: 2,
|
|
137
|
+
stderr,
|
|
138
|
+
matched: null,
|
|
139
|
+
surfaceSymlinkRefused: false,
|
|
140
|
+
patchSessionAllowed: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// 2. Read + parse stdin.
|
|
144
|
+
const stdinRaw = options.stdinOverride !== undefined
|
|
145
|
+
? options.stdinOverride
|
|
146
|
+
: await readStdinWithTimeout(5_000);
|
|
147
|
+
let filePath = '';
|
|
148
|
+
try {
|
|
149
|
+
const payload = parseWriteHookPayload(stdinRaw);
|
|
150
|
+
filePath = payload.filePath;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err instanceof MalformedPayloadError || err instanceof TypePayloadError) {
|
|
154
|
+
writeStderr(`settings-protection: ${err.message} — refusing on uncertainty.\n`);
|
|
155
|
+
return {
|
|
156
|
+
exitCode: 2,
|
|
157
|
+
stderr,
|
|
158
|
+
matched: null,
|
|
159
|
+
surfaceSymlinkRefused: false,
|
|
160
|
+
patchSessionAllowed: false,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
if (filePath.length === 0) {
|
|
166
|
+
return {
|
|
167
|
+
exitCode: 0,
|
|
168
|
+
stderr,
|
|
169
|
+
matched: null,
|
|
170
|
+
surfaceSymlinkRefused: false,
|
|
171
|
+
patchSessionAllowed: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// 3. Normalize.
|
|
175
|
+
const normalized = normalizePath(filePath, reaRoot);
|
|
176
|
+
const lowerNorm = normalized.toLowerCase();
|
|
177
|
+
const safeFilePath = sanitizeForStderr(filePath);
|
|
178
|
+
const safeNormalized = sanitizeForStderr(normalized);
|
|
179
|
+
// §5a. Path traversal reject.
|
|
180
|
+
const rawSlashed = filePath.replace(/\\/g, '/');
|
|
181
|
+
const rawTraversal = hasTraversalSegment(rawSlashed);
|
|
182
|
+
const normTraversal = hasTraversalSegment(normalized);
|
|
183
|
+
if (rawTraversal || normTraversal) {
|
|
184
|
+
writeStderr('SETTINGS PROTECTION: path traversal rejected\n');
|
|
185
|
+
writeStderr('\n');
|
|
186
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
187
|
+
writeStderr(" Rule: path contains a '..' segment; rewrite to a canonical\n");
|
|
188
|
+
writeStderr(' project-relative path without traversal.\n');
|
|
189
|
+
return {
|
|
190
|
+
exitCode: 2,
|
|
191
|
+
stderr,
|
|
192
|
+
matched: '__traversal__',
|
|
193
|
+
surfaceSymlinkRefused: false,
|
|
194
|
+
patchSessionAllowed: false,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// §5a-bis. Interior /./ segment reject.
|
|
198
|
+
if (hasInteriorDotSegment(normalized)) {
|
|
199
|
+
writeStderr('SETTINGS PROTECTION: interior dot-segment rejected\n');
|
|
200
|
+
writeStderr('\n');
|
|
201
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
202
|
+
writeStderr(" Rule: path contains an interior '/./' segment; rewrite to a\n");
|
|
203
|
+
writeStderr(' canonical project-relative path without dot segments.\n');
|
|
204
|
+
return {
|
|
205
|
+
exitCode: 2,
|
|
206
|
+
stderr,
|
|
207
|
+
matched: '__interior_dot__',
|
|
208
|
+
surfaceSymlinkRefused: false,
|
|
209
|
+
patchSessionAllowed: false,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
// §5b. Extension-surface allow-list (.husky/{commit-msg,pre-push,
|
|
213
|
+
// pre-commit,prepare-commit-msg}.d/*).
|
|
214
|
+
if (isExtensionSurface(normalized)) {
|
|
215
|
+
// (a) Final-component symlink refusal.
|
|
216
|
+
let isFinalSymlink = false;
|
|
217
|
+
try {
|
|
218
|
+
const st = fs.lstatSync(filePath);
|
|
219
|
+
isFinalSymlink = st.isSymbolicLink();
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
/* file doesn't exist — fine */
|
|
223
|
+
}
|
|
224
|
+
if (isFinalSymlink) {
|
|
225
|
+
writeStderr('SETTINGS PROTECTION: symlink in extension surface refused\n');
|
|
226
|
+
writeStderr('\n');
|
|
227
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
228
|
+
writeStderr(' Rule: .husky/{commit-msg,pre-push,prepare-commit-msg}.d/* must\n');
|
|
229
|
+
writeStderr(' be regular files (a symlink could resolve to a protected\n');
|
|
230
|
+
writeStderr(' package-managed body and bypass §6 protection).\n');
|
|
231
|
+
return {
|
|
232
|
+
exitCode: 2,
|
|
233
|
+
stderr,
|
|
234
|
+
matched: '__surface_symlink__',
|
|
235
|
+
surfaceSymlinkRefused: true,
|
|
236
|
+
patchSessionAllowed: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// (b) Intermediate-directory symlink resolution.
|
|
240
|
+
const parentDir = path.dirname(filePath);
|
|
241
|
+
let parentIsDir = false;
|
|
242
|
+
try {
|
|
243
|
+
parentIsDir = fs.statSync(parentDir).isDirectory();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
/* parent doesn't exist — bash hook does nothing */
|
|
247
|
+
}
|
|
248
|
+
if (parentIsDir) {
|
|
249
|
+
let resolvedParent = '';
|
|
250
|
+
try {
|
|
251
|
+
resolvedParent = fs.realpathSync(parentDir);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
/* fall-through with empty */
|
|
255
|
+
}
|
|
256
|
+
if (resolvedParent.length > 0) {
|
|
257
|
+
// Directory-boundary anchored — 0.20.1 helix-021 #3 fix.
|
|
258
|
+
// Match `*/.husky/{surface}.d` or `*/.husky/{surface}.d/*` exactly.
|
|
259
|
+
//
|
|
260
|
+
// Codex round-1 P2 fix: pre-commit IS in the documented
|
|
261
|
+
// extension surface via isExtensionSurface(), so writes inside
|
|
262
|
+
// .husky/pre-commit.d/ route through this branch. Without
|
|
263
|
+
// `pre-commit` in this surfaces array the legitimate fragment
|
|
264
|
+
// is denied as "extension path resolves outside surface".
|
|
265
|
+
// The bash hook's surfaces list omitted `pre-commit` because
|
|
266
|
+
// it was added later — preserve the bash behavior for the
|
|
267
|
+
// OTHER surfaces but close the regression for pre-commit here.
|
|
268
|
+
const surfaces = ['commit-msg', 'pre-push', 'pre-commit', 'prepare-commit-msg'];
|
|
269
|
+
let matchedSurface = false;
|
|
270
|
+
for (const s of surfaces) {
|
|
271
|
+
const dir = `/.husky/${s}.d`;
|
|
272
|
+
if (resolvedParent.endsWith(dir) ||
|
|
273
|
+
resolvedParent.includes(dir + '/')) {
|
|
274
|
+
matchedSurface = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!matchedSurface) {
|
|
279
|
+
writeStderr('SETTINGS PROTECTION: extension path resolves outside surface\n');
|
|
280
|
+
writeStderr('\n');
|
|
281
|
+
writeStderr(` Logical: ${safeFilePath}\n`);
|
|
282
|
+
writeStderr(` Resolved: ${resolvedParent}\n`);
|
|
283
|
+
writeStderr(' Rule: an intermediate directory of the extension path is a\n');
|
|
284
|
+
writeStderr(' symlink whose target leaves .husky/{commit-msg,pre-push,prepare-commit-msg}.d/.\n');
|
|
285
|
+
writeStderr(' Refused to prevent symlinked-parent bypass of the\n');
|
|
286
|
+
writeStderr(' package-managed body protection.\n');
|
|
287
|
+
return {
|
|
288
|
+
exitCode: 2,
|
|
289
|
+
stderr,
|
|
290
|
+
matched: '__surface_parent_symlink__',
|
|
291
|
+
surfaceSymlinkRefused: true,
|
|
292
|
+
patchSessionAllowed: false,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// Documented extension surface — allow.
|
|
298
|
+
return {
|
|
299
|
+
exitCode: 0,
|
|
300
|
+
stderr,
|
|
301
|
+
matched: null,
|
|
302
|
+
surfaceSymlinkRefused: false,
|
|
303
|
+
patchSessionAllowed: false,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
// §6. Default-protected list resolution.
|
|
307
|
+
const permPolicy = loadPolicyPermissive(reaRoot);
|
|
308
|
+
const resolution = resolveProtectedPatterns({
|
|
309
|
+
...(permPolicy.protectedWrites !== undefined
|
|
310
|
+
? { protectedWrites: permPolicy.protectedWrites }
|
|
311
|
+
: {}),
|
|
312
|
+
protectedPathsRelax: permPolicy.protectedRelax,
|
|
313
|
+
});
|
|
314
|
+
for (const adv of resolution.advisories)
|
|
315
|
+
writeStderr(adv);
|
|
316
|
+
// §6 match (case-insensitive — matchAny lowercases the pattern side).
|
|
317
|
+
const directHit = matchAny(lowerNorm, resolution.patterns);
|
|
318
|
+
if (directHit !== null) {
|
|
319
|
+
writeStderr('SETTINGS PROTECTION: Modification blocked\n');
|
|
320
|
+
writeStderr('\n');
|
|
321
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
322
|
+
writeStderr(` Matched: ${directHit}\n`);
|
|
323
|
+
writeStderr(' Rule: This file is protected from agent modification, including\n');
|
|
324
|
+
writeStderr(' sessions with REA_HOOK_PATCH_SESSION set.\n');
|
|
325
|
+
return {
|
|
326
|
+
exitCode: 2,
|
|
327
|
+
stderr,
|
|
328
|
+
matched: directHit,
|
|
329
|
+
surfaceSymlinkRefused: false,
|
|
330
|
+
patchSessionAllowed: false,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
// §6c. Intermediate-symlink resolution against the hard-protected list.
|
|
334
|
+
const symRefused = checkProtectedSymlinkResolution(filePath, resolution.patterns, reaRoot);
|
|
335
|
+
if (symRefused !== null) {
|
|
336
|
+
writeStderr('SETTINGS PROTECTION: intermediate-symlink resolution blocked\n');
|
|
337
|
+
writeStderr('\n');
|
|
338
|
+
writeStderr(` Logical: ${safeFilePath}\n`);
|
|
339
|
+
writeStderr(` Resolved: ${symRefused.resolvedTarget}\n`);
|
|
340
|
+
writeStderr(` Matched: ${symRefused.pattern}\n`);
|
|
341
|
+
writeStderr(' Rule: an intermediate directory of the target path is a\n');
|
|
342
|
+
writeStderr(' symlink whose target falls inside a hard-protected\n');
|
|
343
|
+
writeStderr(' path. Refused to prevent symlinked-parent bypass.\n');
|
|
344
|
+
return {
|
|
345
|
+
exitCode: 2,
|
|
346
|
+
stderr,
|
|
347
|
+
matched: symRefused.pattern,
|
|
348
|
+
surfaceSymlinkRefused: false,
|
|
349
|
+
patchSessionAllowed: false,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
// §6b. REA_HOOK_PATCH_SESSION unlock for .claude/hooks/.
|
|
353
|
+
const patchSession = options.patchSessionOverride ?? process.env['REA_HOOK_PATCH_SESSION'] ?? '';
|
|
354
|
+
if (patchSession.length > 0) {
|
|
355
|
+
const patchHit = matchAny(lowerNorm, PATCH_SESSION_PATTERNS);
|
|
356
|
+
if (patchHit !== null) {
|
|
357
|
+
const safeReason = sanitizeForStderr(patchSession);
|
|
358
|
+
const shaBefore = sha256File(filePath);
|
|
359
|
+
const actorName = gitConfig(reaRoot, 'user.name');
|
|
360
|
+
const actorEmail = gitConfig(reaRoot, 'user.email');
|
|
361
|
+
const sessionId = options.sessionIdOverride ?? process.env['CLAUDE_SESSION_ID'] ?? 'external';
|
|
362
|
+
try {
|
|
363
|
+
await appendAuditRecord(reaRoot, {
|
|
364
|
+
session_id: sessionId,
|
|
365
|
+
tool_name: 'hooks.patch.session',
|
|
366
|
+
server_name: 'rea',
|
|
367
|
+
tier: Tier.Write,
|
|
368
|
+
status: InvocationStatus.Allowed,
|
|
369
|
+
autonomy_level: 'unknown',
|
|
370
|
+
duration_ms: 0,
|
|
371
|
+
metadata: {
|
|
372
|
+
reason: patchSession,
|
|
373
|
+
file: normalized,
|
|
374
|
+
sha_before: shaBefore,
|
|
375
|
+
actor: { name: actorName, email: actorEmail },
|
|
376
|
+
pid: process.pid,
|
|
377
|
+
ppid: process.ppid,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
// Fail closed — hash-chain integrity is the contract.
|
|
383
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
384
|
+
writeStderr('SETTINGS PROTECTION: audit-append failed; refusing hook-patch edit\n');
|
|
385
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
386
|
+
writeStderr(' Rule: hash-chained audit is required; no raw-jq fallback.\n');
|
|
387
|
+
writeStderr(` Detail: ${sanitizeForStderr(detail)}\n`);
|
|
388
|
+
return {
|
|
389
|
+
exitCode: 2,
|
|
390
|
+
stderr,
|
|
391
|
+
matched: patchHit,
|
|
392
|
+
surfaceSymlinkRefused: false,
|
|
393
|
+
patchSessionAllowed: false,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
writeStderr(`REA_HOOK_PATCH_SESSION: allowing edit to ${safeNormalized} (reason: ${safeReason})\n`);
|
|
397
|
+
return {
|
|
398
|
+
exitCode: 0,
|
|
399
|
+
stderr,
|
|
400
|
+
matched: null,
|
|
401
|
+
surfaceSymlinkRefused: false,
|
|
402
|
+
patchSessionAllowed: true,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// §6c-bis. Patch-session patterns blocked when env var is NOT set.
|
|
407
|
+
const patchHitBlocked = matchAny(lowerNorm, PATCH_SESSION_PATTERNS);
|
|
408
|
+
if (patchHitBlocked !== null) {
|
|
409
|
+
writeStderr('SETTINGS PROTECTION: Modification blocked\n');
|
|
410
|
+
writeStderr('\n');
|
|
411
|
+
writeStderr(` File: ${safeFilePath}\n`);
|
|
412
|
+
writeStderr(` Matched: ${patchHitBlocked}\n`);
|
|
413
|
+
writeStderr(' Rule: Files under this path are protected. To apply an upstream\n');
|
|
414
|
+
writeStderr(' hook finding, set REA_HOOK_PATCH_SESSION=<reason> and retry.\n');
|
|
415
|
+
return {
|
|
416
|
+
exitCode: 2,
|
|
417
|
+
stderr,
|
|
418
|
+
matched: patchHitBlocked,
|
|
419
|
+
surfaceSymlinkRefused: false,
|
|
420
|
+
patchSessionAllowed: false,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
exitCode: 0,
|
|
425
|
+
stderr,
|
|
426
|
+
matched: null,
|
|
427
|
+
surfaceSymlinkRefused: false,
|
|
428
|
+
patchSessionAllowed: false,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* §6c — intermediate-symlink resolution against the hard-protected list.
|
|
433
|
+
* Mirrors `hooks/settings-protection.sh` lines ~410-444.
|
|
434
|
+
*/
|
|
435
|
+
function checkProtectedSymlinkResolution(filePath, patterns, reaRoot) {
|
|
436
|
+
// Only attempt resolution if the target exists OR its parent dir exists.
|
|
437
|
+
let targetExists = false;
|
|
438
|
+
try {
|
|
439
|
+
targetExists = fs.existsSync(filePath);
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
/* fall through */
|
|
443
|
+
}
|
|
444
|
+
const parentDir = path.dirname(filePath);
|
|
445
|
+
let parentExists = false;
|
|
446
|
+
try {
|
|
447
|
+
parentExists = fs.statSync(parentDir).isDirectory();
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
/* fall through */
|
|
451
|
+
}
|
|
452
|
+
if (!targetExists && !parentExists)
|
|
453
|
+
return null;
|
|
454
|
+
if (!parentExists)
|
|
455
|
+
return null;
|
|
456
|
+
const resolvedParent = resolveParentRealpath(filePath);
|
|
457
|
+
if (resolvedParent.length === 0)
|
|
458
|
+
return null;
|
|
459
|
+
const canonRoot = resolveCanonRoot(reaRoot);
|
|
460
|
+
if (resolvedParent !== canonRoot && !resolvedParent.startsWith(canonRoot + '/')) {
|
|
461
|
+
return null;
|
|
462
|
+
}
|
|
463
|
+
const relativeResolved = resolvedParent === canonRoot ? '' : resolvedParent.slice(canonRoot.length + 1);
|
|
464
|
+
const resolvedTarget = relativeResolved.length > 0
|
|
465
|
+
? `${relativeResolved}/${path.basename(filePath)}`
|
|
466
|
+
: path.basename(filePath);
|
|
467
|
+
const resolvedTargetLc = resolvedTarget.toLowerCase();
|
|
468
|
+
for (const pattern of patterns) {
|
|
469
|
+
const patternLc = pattern.toLowerCase();
|
|
470
|
+
if (resolvedTargetLc === patternLc) {
|
|
471
|
+
return { pattern, resolvedTarget };
|
|
472
|
+
}
|
|
473
|
+
if (patternLc.endsWith('/') && resolvedTargetLc.startsWith(patternLc)) {
|
|
474
|
+
return { pattern, resolvedTarget };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
export async function runHookSettingsProtection(options = {}) {
|
|
480
|
+
const result = await runSettingsProtection({
|
|
481
|
+
...options,
|
|
482
|
+
stderrWrite: (s) => process.stderr.write(s),
|
|
483
|
+
});
|
|
484
|
+
process.exit(result.exitCode);
|
|
485
|
+
}
|