@bookedsolid/rea 0.33.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.
Files changed (37) hide show
  1. package/dist/cli/hook.js +49 -0
  2. package/dist/hooks/_lib/path-normalize.d.ts +81 -0
  3. package/dist/hooks/_lib/path-normalize.js +171 -0
  4. package/dist/hooks/_lib/payload.js +1 -1
  5. package/dist/hooks/_lib/protected-paths.d.ts +0 -0
  6. package/dist/hooks/_lib/protected-paths.js +232 -0
  7. package/dist/hooks/_lib/segments.d.ts +102 -0
  8. package/dist/hooks/_lib/segments.js +290 -0
  9. package/dist/hooks/blocked-paths-bash-gate/index.d.ts +55 -0
  10. package/dist/hooks/blocked-paths-bash-gate/index.js +175 -0
  11. package/dist/hooks/blocked-paths-enforcer/index.d.ts +51 -0
  12. package/dist/hooks/blocked-paths-enforcer/index.js +287 -0
  13. package/dist/hooks/dangerous-bash-interceptor/index.d.ts +103 -0
  14. package/dist/hooks/dangerous-bash-interceptor/index.js +669 -0
  15. package/dist/hooks/local-review-gate/index.d.ts +145 -0
  16. package/dist/hooks/local-review-gate/index.js +374 -0
  17. package/dist/hooks/protected-paths-bash-gate/index.d.ts +47 -0
  18. package/dist/hooks/protected-paths-bash-gate/index.js +168 -0
  19. package/dist/hooks/secret-scanner/index.d.ts +143 -0
  20. package/dist/hooks/secret-scanner/index.js +404 -0
  21. package/dist/hooks/settings-protection/index.d.ts +74 -0
  22. package/dist/hooks/settings-protection/index.js +485 -0
  23. package/hooks/blocked-paths-bash-gate.sh +118 -116
  24. package/hooks/blocked-paths-enforcer.sh +152 -256
  25. package/hooks/dangerous-bash-interceptor.sh +168 -386
  26. package/hooks/local-review-gate.sh +523 -410
  27. package/hooks/protected-paths-bash-gate.sh +123 -210
  28. package/hooks/secret-scanner.sh +210 -200
  29. package/hooks/settings-protection.sh +171 -549
  30. package/package.json +1 -1
  31. package/templates/blocked-paths-bash-gate.dogfood-staged.sh +177 -0
  32. package/templates/blocked-paths-enforcer.dogfood-staged.sh +180 -0
  33. package/templates/dangerous-bash-interceptor.dogfood-staged.sh +196 -0
  34. package/templates/local-review-gate.dogfood-staged.sh +573 -0
  35. package/templates/protected-paths-bash-gate.dogfood-staged.sh +186 -0
  36. package/templates/secret-scanner.dogfood-staged.sh +240 -0
  37. 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
+ }