@bookedsolid/rea 0.22.0 → 0.23.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 (55) hide show
  1. package/README.md +15 -0
  2. package/THREAT_MODEL.md +582 -0
  3. package/dist/audit/append.js +1 -1
  4. package/dist/cli/doctor.js +11 -12
  5. package/dist/cli/hook.d.ts +37 -3
  6. package/dist/cli/hook.js +167 -5
  7. package/dist/cli/init.js +14 -26
  8. package/dist/cli/install/canonical.js +18 -3
  9. package/dist/cli/install/commit-msg.js +1 -2
  10. package/dist/cli/install/copy.js +4 -13
  11. package/dist/cli/install/fs-safe.js +5 -16
  12. package/dist/cli/install/gitignore.js +1 -5
  13. package/dist/cli/install/pre-push.js +3 -8
  14. package/dist/cli/install/settings-merge.js +79 -16
  15. package/dist/cli/upgrade.js +14 -10
  16. package/dist/gateway/downstream.js +1 -2
  17. package/dist/gateway/live-state.js +3 -1
  18. package/dist/gateway/log.js +1 -3
  19. package/dist/gateway/middleware/audit.js +1 -1
  20. package/dist/gateway/middleware/injection.js +3 -9
  21. package/dist/gateway/middleware/policy.js +3 -1
  22. package/dist/gateway/middleware/redact.js +1 -1
  23. package/dist/gateway/observability/codex-telemetry.js +1 -2
  24. package/dist/gateway/reviewers/claude-self.js +10 -6
  25. package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
  26. package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
  27. package/dist/hooks/bash-scanner/index.d.ts +41 -0
  28. package/dist/hooks/bash-scanner/index.js +62 -0
  29. package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
  30. package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
  31. package/dist/hooks/bash-scanner/parser.d.ts +42 -0
  32. package/dist/hooks/bash-scanner/parser.js +92 -0
  33. package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
  34. package/dist/hooks/bash-scanner/protected-scan.js +815 -0
  35. package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
  36. package/dist/hooks/bash-scanner/verdict.js +49 -0
  37. package/dist/hooks/bash-scanner/walker.d.ts +165 -0
  38. package/dist/hooks/bash-scanner/walker.js +7954 -0
  39. package/dist/hooks/push-gate/base.js +2 -6
  40. package/dist/hooks/push-gate/codex-runner.js +3 -1
  41. package/dist/hooks/push-gate/index.js +9 -10
  42. package/dist/policy/loader.js +4 -1
  43. package/dist/registry/tofu-gate.js +2 -2
  44. package/hooks/blocked-paths-bash-gate.sh +142 -272
  45. package/hooks/protected-paths-bash-gate.sh +227 -511
  46. package/package.json +3 -2
  47. package/profiles/bst-internal-no-codex.yaml +1 -1
  48. package/profiles/bst-internal.yaml +1 -1
  49. package/profiles/client-engagement.yaml +1 -1
  50. package/profiles/lit-wc.yaml +1 -1
  51. package/profiles/minimal.yaml +1 -1
  52. package/profiles/open-source-no-codex.yaml +1 -1
  53. package/profiles/open-source.yaml +1 -1
  54. package/scripts/postinstall.mjs +1 -2
  55. package/scripts/run-vitest.mjs +117 -0
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Protected-paths policy composition. Mirrors the bash semantics in
3
+ * `hooks/_lib/protected-paths.sh` byte-for-byte:
4
+ *
5
+ * 1. Build the effective protected set:
6
+ * - If policy.protected_writes is set: that list, plus kill-switch
7
+ * invariants always added.
8
+ * - Else: the historical default (REA_PROTECTED_PATTERNS_FULL).
9
+ * Then subtract policy.protected_paths_relax — but kill-switch
10
+ * invariants in the relax list are silently dropped from the
11
+ * relax set with a stderr advisory (not from the protected set).
12
+ *
13
+ * 2. The match check:
14
+ * a. Explicit `protected_writes` overrides win FIRST (helix-020 G2).
15
+ * Matched against the path BEFORE the extension-surface check.
16
+ * b. Extension-surface paths (`.husky/{commit-msg,pre-push,
17
+ * pre-commit}.d/<fragment>`) are NOT protected by default
18
+ * (helix-018 Option B / 0.16.4).
19
+ * c. Default protected list applies, with kill-switch invariants
20
+ * always enforced.
21
+ *
22
+ * 3. Pattern matching:
23
+ * - case-insensitive (macOS APFS — helix-015 #2)
24
+ * - trailing `/` is a prefix-match
25
+ * - everything else is exact-match
26
+ *
27
+ * 4. Path normalization runs BEFORE matching:
28
+ * - URL decode, backslash → slash, leading `./` strip
29
+ * - `..` walk-up via the parser-friendly equivalent of
30
+ * `cd -P / pwd -P` (we rely on `node:fs.realpathSync` for the
31
+ * symlink resolution; non-existent parents walk up to the
32
+ * nearest existing ancestor — helix-022 #1)
33
+ * - case-insensitive lowercase comparison
34
+ * - sentinel `__rea_unresolved_expansion__` for $-substitution
35
+ * - sentinel `__rea_outside_root__` for paths escaping REA_ROOT
36
+ */
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import { allowVerdict, blockVerdict } from './verdict.js';
40
+ /**
41
+ * Hardcoded historical default — when policy.protected_writes is not
42
+ * set this is the protected list. Mirrors REA_PROTECTED_PATTERNS_FULL
43
+ * in `hooks/_lib/protected-paths.sh`.
44
+ */
45
+ const HISTORICAL_DEFAULT_PROTECTED_PATTERNS = [
46
+ '.claude/settings.json',
47
+ '.claude/settings.local.json',
48
+ '.husky/',
49
+ '.rea/policy.yaml',
50
+ '.rea/HALT',
51
+ '.rea/last-review.cache.json',
52
+ '.rea/last-review.json',
53
+ ];
54
+ /**
55
+ * Kill-switch invariants — never relaxable. These represent the
56
+ * integrity of the governance layer; if a consumer could relax them
57
+ * an agent could disable rea entirely.
58
+ */
59
+ const KILL_SWITCH_INVARIANTS = [
60
+ '.claude/settings.json',
61
+ '.rea/policy.yaml',
62
+ '.rea/HALT',
63
+ '.rea/last-review.cache.json',
64
+ '.rea/last-review.json',
65
+ ];
66
+ /**
67
+ * Compute the effective protected pattern sets from policy. Pure — no
68
+ * filesystem access.
69
+ */
70
+ export function computeEffectivePatterns(ctx) {
71
+ const writes = ctx.policy.protected_writes;
72
+ const writesIsSet = writes !== undefined;
73
+ const relax = ctx.policy.protected_paths_relax ?? [];
74
+ // 1. Compose BASE list.
75
+ let base;
76
+ if (writesIsSet && writes !== undefined) {
77
+ base = [...writes];
78
+ // Add kill-switch invariants if not already present (case-insensitive).
79
+ for (const inv of KILL_SWITCH_INVARIANTS) {
80
+ const invLc = inv.toLowerCase();
81
+ if (!base.some((b) => b.toLowerCase() === invLc)) {
82
+ base.push(inv);
83
+ }
84
+ }
85
+ }
86
+ else {
87
+ base = [...HISTORICAL_DEFAULT_PROTECTED_PATTERNS];
88
+ }
89
+ // 2. Validate relax: drop kill-switch invariants with a stderr advisory.
90
+ const validRelax = [];
91
+ for (const r of relax) {
92
+ if (isKillSwitchInvariant(r)) {
93
+ ctx.stderr?.(`rea: protected_paths_relax: ${r} is a kill-switch invariant and cannot be relaxed; ignoring.\n`);
94
+ }
95
+ else {
96
+ validRelax.push(r);
97
+ }
98
+ }
99
+ // 3. Subtract relax from base.
100
+ const effective = [];
101
+ for (const pat of base) {
102
+ const patLc = pat.toLowerCase();
103
+ if (!validRelax.some((r) => r.toLowerCase() === patLc)) {
104
+ effective.push(pat);
105
+ }
106
+ }
107
+ // 4. Compute override subset (subset of policy.protected_writes that
108
+ // survived the relax filter). Kill-switch invariants added defensively
109
+ // in step 1 are NOT included — only consumer-declared entries count
110
+ // as explicit overrides.
111
+ const override = [];
112
+ if (writesIsSet && writes !== undefined) {
113
+ for (const w of writes) {
114
+ const wLc = w.toLowerCase();
115
+ if (!validRelax.some((r) => r.toLowerCase() === wLc)) {
116
+ override.push(w);
117
+ }
118
+ }
119
+ }
120
+ return { full: effective, override };
121
+ }
122
+ function isKillSwitchInvariant(p) {
123
+ const lc = p.toLowerCase();
124
+ return KILL_SWITCH_INVARIANTS.some((inv) => inv.toLowerCase() === lc);
125
+ }
126
+ /**
127
+ * Test whether a normalized lowercase project-relative path falls
128
+ * inside the documented husky extension surface
129
+ * (`.husky/{commit-msg,pre-push,pre-commit}.d/<fragment>`).
130
+ *
131
+ * The bare directory itself (`.husky/pre-push.d/`) and the dir node
132
+ * (`.husky/pre-push.d`) do NOT match — only fragments inside.
133
+ */
134
+ function isExtensionSurface(pathLc) {
135
+ const surfaces = ['.husky/commit-msg.d/', '.husky/pre-push.d/', '.husky/pre-commit.d/'];
136
+ for (const s of surfaces) {
137
+ if (pathLc.startsWith(s) && pathLc.length > s.length) {
138
+ return true;
139
+ }
140
+ }
141
+ return false;
142
+ }
143
+ /**
144
+ * Test a path against a pattern list. Match rules:
145
+ * - exact (case-insensitive) when the pattern doesn't end with `/`
146
+ * - prefix-match when the pattern ends with `/`
147
+ * - "directory-shape" inputs (trailing `/` OR walker-flagged
148
+ * isDirTarget) match any protected path that would live inside,
149
+ * so `cp -t .rea` catches `.rea/HALT` even without a trailing
150
+ * slash. Codex round 1 F-7.
151
+ * - "destructive" inputs (walker-flagged isDestructive) match via
152
+ * PROTECTED-ANCESTRY: an input target T matches when any protected
153
+ * pattern P is a strict descendant of T, because removing T
154
+ * recursively removes P. So `rm -rf .rea` matches `.rea/HALT`
155
+ * even though `.rea` itself is neither a pattern nor input-dir-
156
+ * shaped. Codex round 4 Finding 1.
157
+ *
158
+ * Returns the matched pattern (preserving original case) or null.
159
+ */
160
+ function matchPatterns(pathLc, patterns, options) {
161
+ // Strip a single trailing slash on the input so `.rea/` and `.rea`
162
+ // both compare against the same forms. We DO keep a flag tracking
163
+ // whether the input was directory-shaped (trailing `/` or argv form
164
+ // like `cp -t .rea/`) for the second-pass check below.
165
+ const inputHadTrailingSlash = pathLc.endsWith('/');
166
+ const inputIsDir = inputHadTrailingSlash || (options?.forceDirSemantics ?? false);
167
+ const isDestructive = options?.isDestructive ?? false;
168
+ const inputBase = inputHadTrailingSlash ? pathLc.slice(0, -1) : pathLc;
169
+ for (const pat of patterns) {
170
+ const patLc = pat.toLowerCase();
171
+ if (patLc.endsWith('/')) {
172
+ if (pathLc.startsWith(patLc))
173
+ return pat;
174
+ if (pathLc === patLc.slice(0, -1))
175
+ return pat;
176
+ // Reverse-prefix: input is `.rea/` (a dir-write target) and
177
+ // pattern `.rea/HALT` would normally not match a bare-dir
178
+ // input. But writing INTO `.rea/` is an attack on protected
179
+ // files inside it — block. discord-ops Round 13 #2.
180
+ if (inputIsDir && patLc.startsWith(inputBase + '/'))
181
+ return pat;
182
+ // Codex round 4 Finding 1: protected-ancestry. Destructive
183
+ // operations (rm -rf, rmtree, FileUtils.rm_rf, find -delete)
184
+ // against an ancestor of a protected dir-pattern remove
185
+ // EVERYTHING under it. `rm -rf .` reaches `.husky/`.
186
+ if (isDestructive && patLc.startsWith(inputBase + '/'))
187
+ return pat;
188
+ }
189
+ else if (pathLc === patLc) {
190
+ return pat;
191
+ }
192
+ else if (inputIsDir && patLc.startsWith(inputBase + '/')) {
193
+ // Input is a directory; pattern is a file (e.g. `.rea/HALT`)
194
+ // inside it. Conservative refusal: writes to this dir might
195
+ // hit the protected file. discord-ops Round 13 #2 / `cp -t .rea/`
196
+ // or `cp --target-directory=.rea` (codex round 1 F-7).
197
+ return pat;
198
+ }
199
+ else if (isDestructive && patLc.startsWith(inputBase + '/')) {
200
+ // Codex round 4 Finding 1: protected-ancestry. The input is
201
+ // target `.rea` (not flagged dir-shape), the pattern is
202
+ // `.rea/HALT`. A destructive op against `.rea` removes
203
+ // `.rea/HALT`. Treat as a hit.
204
+ return pat;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+ /**
210
+ * The full match-check, mirroring `rea_path_is_protected` in the bash
211
+ * lib. Returns the matched pattern + which match-tier (`override`,
212
+ * `default`) hit, or null if not protected.
213
+ */
214
+ function checkPathProtected(pathLc, effective, options) {
215
+ // Tier 1: explicit override wins.
216
+ const overrideHit = matchPatterns(pathLc, effective.override, options);
217
+ if (overrideHit !== null)
218
+ return { pattern: overrideHit, tier: 'override' };
219
+ // Tier 2: extension-surface allow-list short-circuits.
220
+ // Codex round 4 Finding 1: but a DESTRUCTIVE op against the husky
221
+ // extension-surface dir itself (e.g. `rm -rf .husky/pre-push.d`)
222
+ // doesn't reach the per-fragment allow-list — we still want to
223
+ // block ancestry hits against protected siblings (e.g. .husky/).
224
+ // The extension-surface short-circuit only applies to the precise
225
+ // fragment paths, not their parents. So we pass through to tier 3
226
+ // when isDestructive AND the pathLc isn't itself a fragment but
227
+ // could be an ancestor of one. The simplest semantic: skip the
228
+ // short-circuit entirely on destructive operations. False positives
229
+ // are acceptable — destructive ops on .husky/ are rare and policy-
230
+ // relevant. Pre-fix: `rm -rf .husky` allowed because tier 2 didn't
231
+ // apply (the path isn't a fragment) but tier 3 didn't trigger
232
+ // ancestry without the destructive flag.
233
+ if (!(options?.isDestructive ?? false) && isExtensionSurface(pathLc))
234
+ return null;
235
+ // Tier 3: full effective protected set.
236
+ const defaultHit = matchPatterns(pathLc, effective.full, options);
237
+ if (defaultHit !== null)
238
+ return { pattern: defaultHit, tier: 'default' };
239
+ return null;
240
+ }
241
+ /**
242
+ * Normalize a write target from raw walker output to a project-relative
243
+ * lowercase path suitable for `checkPathProtected`. Mirrors
244
+ * `_normalize_target` + `rea_resolved_relative_form` in the bash hook.
245
+ *
246
+ * Returns one normalized form (logical) and optionally the symlink-
247
+ * resolved form. The caller checks the policy against BOTH and
248
+ * blocks on either match. Either may be a sentinel string for
249
+ * outside-root / expansion-uncertainty.
250
+ */
251
+ function normalizeTarget(reaRoot, raw, form) {
252
+ // 1. Strip surrounding matching quotes (the parser already strips
253
+ // them for SglQuoted/DblQuoted, but a literal node can still hold
254
+ // `'.rea/HALT'` in pathological cases). Defensive.
255
+ let t = raw;
256
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
257
+ t = t.slice(1, -1);
258
+ }
259
+ if (t.length >= 2 && t.startsWith("'") && t.endsWith("'")) {
260
+ t = t.slice(1, -1);
261
+ }
262
+ // 1b. Codex round 1 F-15: strip backslash-escapes that prefix
263
+ // ordinary path chars. Bash strips one level at runtime, so
264
+ // `\.rea/HALT` and `.rea/HALT` are the same target. Pre-fix
265
+ // `printf x > \.rea/HALT` allowed because `\.` was preserved
266
+ // in the literal token.
267
+ t = stripBashBackslashEscapes(t);
268
+ // 1c. Codex round 1 F-16: ANSI-C `$'…'` quoting expands escape
269
+ // sequences (`\n` `\t` `\xNN` etc.) at parse time. mvdan-sh
270
+ // emits the EXPANDED form, so we usually don't need to do this
271
+ // ourselves — but if we ever encounter the literal `$'` prefix
272
+ // in our raw input, it's a sign that ParamExp normalization
273
+ // dropped the special handling. Treat as dynamic to be safe.
274
+ if (t.startsWith("$'") || t.includes("$'")) {
275
+ return {
276
+ pathLc: '__rea_unresolved_expansion__',
277
+ sentinel: 'expansion',
278
+ original: raw,
279
+ resolvedLc: null,
280
+ };
281
+ }
282
+ // 2. Sentinel: $-expansion / `cmd` / $(cmd) inside the path.
283
+ if (t.includes('$') || t.includes('`')) {
284
+ return {
285
+ pathLc: '__rea_unresolved_expansion__',
286
+ sentinel: 'expansion',
287
+ original: raw,
288
+ resolvedLc: null,
289
+ };
290
+ }
291
+ // 2b. Codex round 1 F-14: glob metachars (`*`, `?`, `[`, `{`) in
292
+ // redirect targets are runtime-expanded; refuse on uncertainty.
293
+ // We scope this to redirect-form detections — argv-based forms
294
+ // (chmod, cp, rm, etc.) commonly take legitimately-globbed
295
+ // positional args (`chmod +x bin/*.sh`) that we don't want to
296
+ // refuse blanket-style. Their expansion at runtime DOES still
297
+ // create the same conservative blocking concern, but in practice
298
+ // bash redirect targets are the high-confidence attack vector;
299
+ // argv globs that plausibly hit a protected path are caught by
300
+ // individual per-utility detection (e.g. `chmod 000 .rea/H*` →
301
+ // when `.rea/HALT` exists the glob WOULD have expanded; we just
302
+ // can't enumerate it). Future enhancement: enumerate glob matches
303
+ // against the FS at scan-time.
304
+ if (form === 'redirect' && containsGlobMetachar(t)) {
305
+ return {
306
+ pathLc: '__rea_unresolved_expansion__',
307
+ sentinel: 'expansion',
308
+ original: raw,
309
+ resolvedLc: null,
310
+ };
311
+ }
312
+ // 2c. Codex round 1 F-24: `~/` or bare `~` expands to $HOME at
313
+ // runtime, which may equal reaRoot (any project rooted at the
314
+ // user's home dir). Treat as dynamic to be safe — refuse on
315
+ // uncertainty rather than guess at HOME.
316
+ if (t === '~' || t.startsWith('~/') || t.startsWith('~')) {
317
+ return {
318
+ pathLc: '__rea_unresolved_expansion__',
319
+ sentinel: 'expansion',
320
+ original: raw,
321
+ resolvedLc: null,
322
+ };
323
+ }
324
+ // 3. URL-decode + backslash translation + leading-./ strip.
325
+ let normalized = t;
326
+ try {
327
+ normalized = decodeURIComponent(t);
328
+ }
329
+ catch {
330
+ // Malformed URI escape — leave alone.
331
+ normalized = t;
332
+ }
333
+ normalized = normalized.replace(/\\/g, '/');
334
+ while (normalized.startsWith('./')) {
335
+ normalized = normalized.slice(2);
336
+ }
337
+ // 4. Resolve `..` segments. Build absolute path then walk-and-collapse.
338
+ let abs = normalized;
339
+ if (!abs.startsWith('/')) {
340
+ abs = path.join(reaRoot, abs);
341
+ }
342
+ const hadDotDot = normalized.includes('..');
343
+ const collapsed = collapseDotDot(abs);
344
+ // 5. Outside-root sentinel — fires ONLY for paths that contained
345
+ // `..` segments and resolved outside REA_ROOT (helix-022 #1
346
+ // semantic). A bare absolute path like `/tmp/foo` is just an
347
+ // out-of-scope target — we don't enforce the protected list
348
+ // against it (the protected list is project-relative). The bash
349
+ // gate pre-0.23.0 had the same behavior.
350
+ if (!isInsideRoot(collapsed, reaRoot)) {
351
+ if (hadDotDot) {
352
+ return {
353
+ pathLc: '__rea_outside_root__',
354
+ sentinel: 'outside_root',
355
+ original: raw,
356
+ resolvedLc: null,
357
+ };
358
+ }
359
+ // Plain absolute path outside root — return a path that won't
360
+ // match anything in the protected list. We use a unique sentinel
361
+ // so the caller can distinguish "outside root, not protected" from
362
+ // "outside root, refused" — but `pathLc` here is just a non-
363
+ // matching string.
364
+ return {
365
+ pathLc: `__outside_root_allowed:${collapsed.toLowerCase()}`,
366
+ sentinel: null,
367
+ original: raw,
368
+ resolvedLc: null,
369
+ };
370
+ }
371
+ const projectRelative = collapsed === reaRoot ? '' : collapsed.slice(reaRoot.length + 1);
372
+ // Preserve trailing-slash signal from the input — `cp -t .rea/` and
373
+ // `cp --target-directory=.rea/` need the dir-write semantic to stick
374
+ // through normalization. `collapseDotDot` strips trailing slashes
375
+ // because it splits-and-rejoins on `/`, so we re-attach when the
376
+ // original had one.
377
+ const inputHadTrailingSlash = normalized.endsWith('/');
378
+ const pathLc = (inputHadTrailingSlash && projectRelative.length > 0 && !projectRelative.endsWith('/')
379
+ ? projectRelative + '/'
380
+ : projectRelative).toLowerCase();
381
+ // 6. Symlink-resolved form. Walk to the nearest existing ancestor,
382
+ // realpath-it, then re-attach the unresolved tail. Mirrors
383
+ // `resolve_parent_realpath` in `hooks/_lib/path-normalize.sh`
384
+ // (helix-022 #1).
385
+ //
386
+ // Codex round 2 R2-2: cycle / depth-cap detection. When the
387
+ // resolver returns SYMLINK_DYNAMIC_SENTINEL, treat the target as
388
+ // dynamic — refuse on uncertainty via the `expansion` sentinel.
389
+ let resolvedLc = null;
390
+ try {
391
+ const resolved = resolveSymlinksWalkUp(collapsed);
392
+ if (resolved === SYMLINK_DYNAMIC_SENTINEL) {
393
+ return {
394
+ pathLc: '__rea_unresolved_expansion__',
395
+ sentinel: 'expansion',
396
+ original: raw,
397
+ resolvedLc: null,
398
+ };
399
+ }
400
+ if (resolved !== null) {
401
+ // macOS /var ↔ /private/var canonicalization (helix-021): the
402
+ // realpath of REA_ROOT itself may differ from REA_ROOT. Compute
403
+ // the relative form using the realpath of REA_ROOT.
404
+ const realRoot = realpathSafe(reaRoot) ?? reaRoot;
405
+ let resolvedRelative = null;
406
+ if (resolved === realRoot) {
407
+ resolvedRelative = '';
408
+ }
409
+ else if (resolved.startsWith(realRoot + '/')) {
410
+ resolvedRelative = resolved.slice(realRoot.length + 1);
411
+ }
412
+ else if (resolved.startsWith(reaRoot + '/')) {
413
+ resolvedRelative = resolved.slice(reaRoot.length + 1);
414
+ }
415
+ if (resolvedRelative !== null) {
416
+ const candidate = resolvedRelative.toLowerCase();
417
+ if (candidate !== pathLc) {
418
+ resolvedLc = candidate;
419
+ }
420
+ }
421
+ }
422
+ }
423
+ catch {
424
+ // Symlink resolution is best-effort. If it fails the logical form
425
+ // is still checked.
426
+ }
427
+ return { pathLc, sentinel: null, original: raw, resolvedLc };
428
+ }
429
+ /**
430
+ * Resolve `..` and `.` segments without filesystem access. Standard
431
+ * lexical normalization.
432
+ */
433
+ function collapseDotDot(absPath) {
434
+ const parts = absPath.split('/');
435
+ const out = [];
436
+ for (const p of parts) {
437
+ if (p === '' || p === '.')
438
+ continue;
439
+ if (p === '..') {
440
+ out.pop();
441
+ }
442
+ else {
443
+ out.push(p);
444
+ }
445
+ }
446
+ return '/' + out.join('/');
447
+ }
448
+ function isInsideRoot(absPath, reaRoot) {
449
+ if (absPath === reaRoot)
450
+ return true;
451
+ // Use realpath-aware equivalence: macOS /var ↔ /private/var.
452
+ const realRoot = realpathSafe(reaRoot);
453
+ if (realRoot && absPath === realRoot)
454
+ return true;
455
+ if (absPath.startsWith(reaRoot + '/'))
456
+ return true;
457
+ if (realRoot && absPath.startsWith(realRoot + '/'))
458
+ return true;
459
+ return false;
460
+ }
461
+ function realpathSafe(p) {
462
+ try {
463
+ return fs.realpathSync(p);
464
+ }
465
+ catch {
466
+ return null;
467
+ }
468
+ }
469
+ /**
470
+ * Walk to the nearest existing ancestor, realpath-it, then re-attach
471
+ * the unresolved tail.
472
+ *
473
+ * Return values:
474
+ * - string: the resolved absolute path
475
+ * - null: nothing resolves (should never happen — FS root always exists)
476
+ * - SYMLINK_DYNAMIC_SENTINEL: cycle or depth cap hit; caller MUST treat
477
+ * this as a dynamic / unresolvable target and refuse on uncertainty
478
+ *
479
+ * helix-022 #1: pre-fix the bash hook walked up via stat-loop and
480
+ * stopped at the nearest existing parent, reattaching the unresolved
481
+ * tail. We do the same here using `node:fs.realpathSync` on the
482
+ * existing prefix.
483
+ *
484
+ * Codex round 1 F-2: dangling symlinks. `fs.existsSync` follows the
485
+ * symlink — if the target is missing, it returns FALSE, so the leaf
486
+ * (which IS a real link in the directory) gets walked PAST and
487
+ * re-attached unresolved. Pre-fix `ln -s .rea/HALT innocent_link;
488
+ * printf x > innocent_link` was allowed because innocent_link's
489
+ * realpath wasn't computed (the link target didn't exist YET). The
490
+ * write would create .rea/HALT.
491
+ *
492
+ * Fix: at each level, also check `lstatSync` — if the entry exists
493
+ * as a symlink (whether or not the target resolves), follow it via
494
+ * `readlinkSync` and re-resolve. This catches dangling and broken
495
+ * links by their LINK content, not their target's existence.
496
+ *
497
+ * Codex round 2 R2-2: prior recursion had no cycle guard or depth cap.
498
+ * A symlink loop `a → b → a` against a protected target caused unbounded
499
+ * recursion (Node would eventually stack-overflow but the path-of-least-
500
+ * resistance failure was a long hang). Even non-cyclic deep chains could
501
+ * stress the resolver. We now thread a `visited` Set + `depth` counter
502
+ * through every recursive call. On cycle detection or depth-cap hit we
503
+ * return a sentinel that the caller maps to `dynamic: true` so the
504
+ * compositor BLOCKS on uncertainty.
505
+ */
506
+ const SYMLINK_DYNAMIC_SENTINEL = Symbol('symlink-dynamic');
507
+ const SYMLINK_DEPTH_CAP = 32;
508
+ function resolveSymlinksWalkUp(absPath) {
509
+ return resolveSymlinksWalkUpInner(absPath, new Set(), 0);
510
+ }
511
+ function resolveSymlinksWalkUpInner(absPath, visited, depth) {
512
+ if (depth >= SYMLINK_DEPTH_CAP) {
513
+ return SYMLINK_DYNAMIC_SENTINEL;
514
+ }
515
+ if (visited.has(absPath)) {
516
+ return SYMLINK_DYNAMIC_SENTINEL;
517
+ }
518
+ visited.add(absPath);
519
+ const parts = absPath.split('/').filter((p) => p.length > 0);
520
+ // Find the longest existing-or-symlink prefix.
521
+ for (let i = parts.length; i >= 0; i -= 1) {
522
+ const prefix = '/' + parts.slice(0, i).join('/');
523
+ // Check via lstat first so dangling symlinks register.
524
+ const lstat = lstatSafe(prefix);
525
+ if (lstat !== null) {
526
+ // Entry exists in the directory (file, dir, OR dangling link).
527
+ let resolved;
528
+ if (lstat.isSymbolicLink()) {
529
+ // Codex F-2: follow the link manually so dangling targets are
530
+ // re-evaluated through the protected-list match.
531
+ const linkTarget = readlinkSafe(prefix);
532
+ if (linkTarget === null)
533
+ return null;
534
+ // If the link target is relative, resolve it against the link's
535
+ // dirname.
536
+ const linkDir = '/' + parts.slice(0, i - 1).join('/');
537
+ const targetAbs = linkTarget.startsWith('/')
538
+ ? linkTarget
539
+ : path.resolve(linkDir, linkTarget);
540
+ // Codex round 2 R2-2: thread visited + depth into the recursion
541
+ // so cycles bottom out at the depth cap with the dynamic sentinel.
542
+ const recursive = resolveSymlinksWalkUpInner(targetAbs, visited, depth + 1);
543
+ if (recursive === SYMLINK_DYNAMIC_SENTINEL)
544
+ return SYMLINK_DYNAMIC_SENTINEL;
545
+ if (recursive === null)
546
+ return null;
547
+ const tail = parts.slice(i).join('/');
548
+ resolved =
549
+ tail.length === 0 ? recursive : recursive === '/' ? '/' + tail : recursive + '/' + tail;
550
+ return resolved;
551
+ }
552
+ const real = realpathSafe(prefix);
553
+ if (real === null)
554
+ return null;
555
+ const tail = parts.slice(i).join('/');
556
+ if (tail.length === 0)
557
+ return real;
558
+ return real === '/' ? '/' + tail : real + '/' + tail;
559
+ }
560
+ }
561
+ return null;
562
+ }
563
+ function lstatSafe(p) {
564
+ try {
565
+ return fs.lstatSync(p);
566
+ }
567
+ catch {
568
+ return null;
569
+ }
570
+ }
571
+ function readlinkSafe(p) {
572
+ try {
573
+ return fs.readlinkSync(p);
574
+ }
575
+ catch {
576
+ return null;
577
+ }
578
+ }
579
+ /**
580
+ * Strip a single layer of bash backslash-escaping from a literal path
581
+ * token. Bash collapses `\X` to `X` for any non-special X at runtime,
582
+ * but mvdan-sh sometimes preserves the backslash in the parsed Lit
583
+ * token. Codex round 1 F-15.
584
+ *
585
+ * We don't try to be clever — bash's actual rules are messy and
586
+ * context-dependent. We strip every `\X` to `X` for X in
587
+ * `[A-Za-z0-9./_~-]`. The compositor then runs its existing checks
588
+ * against the resulting form.
589
+ */
590
+ function stripBashBackslashEscapes(s) {
591
+ return s.replace(/\\([A-Za-z0-9./_~\-])/g, '$1');
592
+ }
593
+ /**
594
+ * True if the string contains a glob metacharacter. Conservative —
595
+ * we treat `?` `*` `[` `{` as globby; the bash brace expansion produces
596
+ * multiple args from one source token. Codex round 1 F-14.
597
+ *
598
+ * We DO NOT distinguish between literal `*` (escaped with backslash —
599
+ * already handled by stripBashBackslashEscapes above) and glob `*`,
600
+ * because by the time we're here both have collapsed to the same
601
+ * literal char. The fail-closed posture is acceptable: globs in
602
+ * redirect targets are rare in legitimate code.
603
+ */
604
+ function containsGlobMetachar(s) {
605
+ return /[*?[{]/.test(s);
606
+ }
607
+ /**
608
+ * Build the operator-facing block reason for a successful match.
609
+ */
610
+ function buildBlockReason(args) {
611
+ return [
612
+ 'PROTECTED PATH (bash): write to a package-managed file blocked',
613
+ '',
614
+ ` Pattern matched: ${args.pattern}`,
615
+ ` Resolved target: ${args.hitForm}`,
616
+ ` Original token: ${args.originalToken}`,
617
+ ` Detected as: ${args.detectedForm}`,
618
+ '',
619
+ ' Rule: protected paths (kill-switch, policy.yaml, settings.json,',
620
+ ' .husky/*) are unreachable via Bash redirects too — not just',
621
+ ' Write/Edit/MultiEdit. To modify, a human must edit directly.',
622
+ ].join('\n');
623
+ }
624
+ /**
625
+ * Run a list of detected writes against the protected-paths policy.
626
+ * Returns the FIRST blocking verdict, or allow if every detection is
627
+ * clean.
628
+ *
629
+ * Order: walk detections in order of appearance. The walker emits
630
+ * them in source order, so the operator sees the EARLIEST violation
631
+ * in the error message.
632
+ */
633
+ export function scanForProtectedViolations(ctx, detections) {
634
+ if (detections.length === 0)
635
+ return allowVerdict();
636
+ const effective = computeEffectivePatterns(ctx);
637
+ for (const d of detections) {
638
+ // Dynamic targets fail closed.
639
+ if (d.dynamic) {
640
+ // xargs-stdin and depth-cap detections always have empty path
641
+ // and dynamic=true; we surface a path-specific reason for them.
642
+ if (d.form === 'xargs_unresolvable') {
643
+ return blockVerdict({
644
+ reason: [
645
+ 'PROTECTED PATH (bash): xargs destination is fed via stdin and cannot be statically resolved.',
646
+ '',
647
+ ' rea refuses on uncertainty. Rewrite without xargs (use a loop with explicit',
648
+ ' destinations) or pipe to a known-safe destination directory.',
649
+ ].join('\n'),
650
+ hitPattern: '(xargs unresolvable stdin)',
651
+ detectedForm: d.form,
652
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
653
+ });
654
+ }
655
+ if (d.form === 'nested_shell_inner') {
656
+ return blockVerdict({
657
+ reason: [
658
+ 'PROTECTED PATH (bash): nested-shell payload is dynamic or exceeds the recursion depth cap (8).',
659
+ '',
660
+ ' rea refuses on uncertainty. Inline the command instead of wrapping in `bash -c`',
661
+ ' with a $-expanded or deeply-nested payload.',
662
+ ].join('\n'),
663
+ hitPattern: '(nested-shell unresolvable)',
664
+ detectedForm: d.form,
665
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
666
+ });
667
+ }
668
+ // Codex round 11 F11-1: find -exec/-execdir/-ok/-okdir with `{}`
669
+ // placeholder. The placeholder is a runtime-resolved file path
670
+ // from find's own match set; we cannot statically determine
671
+ // which protected paths it will produce.
672
+ if (d.form === 'find_exec_placeholder_unresolvable') {
673
+ return blockVerdict({
674
+ reason: [
675
+ 'PROTECTED PATH (bash): find -exec with `{}` placeholder targets runtime-resolved paths.',
676
+ '',
677
+ ' rea refuses on uncertainty. Rewrite without `{}` (use an explicit destination)',
678
+ ' or limit the find seed/-name predicates so the matched paths are statically',
679
+ ' knowable (note: even `-name SAFE` cannot be honored because find resolves',
680
+ ' matches at runtime against the live filesystem).',
681
+ ].join('\n'),
682
+ hitPattern: '(find -exec placeholder unresolvable)',
683
+ detectedForm: d.form,
684
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
685
+ });
686
+ }
687
+ // Codex round 11 F11-5: parallel reading from stdin (no ::: separator).
688
+ if (d.form === 'parallel_stdin_unresolvable') {
689
+ return blockVerdict({
690
+ reason: [
691
+ 'PROTECTED PATH (bash): parallel without `:::` reads inputs from stdin and cannot be statically resolved.',
692
+ '',
693
+ ' rea refuses on uncertainty. Use `parallel CMD ::: arg1 arg2` with explicit',
694
+ ' inputs, or pipe to a non-parallel form (`for x; do CMD; done`).',
695
+ ].join('\n'),
696
+ hitPattern: '(parallel stdin unresolvable)',
697
+ detectedForm: d.form,
698
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
699
+ });
700
+ }
701
+ // Codex round 11 F11-4: archive extraction whose member set is
702
+ // unknown at static-analysis time (`tar -xzf foo.tar.gz` with no
703
+ // explicit member list — the archive may contain `.rea/HALT`).
704
+ if (d.form === 'archive_extract_unresolvable') {
705
+ return blockVerdict({
706
+ reason: [
707
+ 'PROTECTED PATH (bash): archive extraction targets are unresolvable — the archive may contain protected paths.',
708
+ '',
709
+ ' rea refuses on uncertainty. Either:',
710
+ ' 1. List the explicit members on the command line so the scanner can verify',
711
+ ' none collide with protected patterns, OR',
712
+ ' 2. Extract into a sandbox directory under `tmp/` or `dist/`, never the',
713
+ ' project root, so the protected-paths cannot be overwritten.',
714
+ ].join('\n'),
715
+ hitPattern: '(archive extract unresolvable)',
716
+ detectedForm: d.form,
717
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
718
+ });
719
+ }
720
+ return blockVerdict({
721
+ reason: [
722
+ 'PROTECTED PATH (bash): unresolved shell expansion in target.',
723
+ '',
724
+ ` Token: ${d.path}`,
725
+ ` Detected as: ${d.form}`,
726
+ '',
727
+ ' Rule: $-substitution and `command-substitution` in redirect targets are',
728
+ ' refused at static-analysis time. Resolve the variable to a literal',
729
+ ' path before the redirect.',
730
+ ].join('\n'),
731
+ hitPattern: '(dynamic target)',
732
+ detectedForm: d.form,
733
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
734
+ });
735
+ }
736
+ if (d.path.length === 0)
737
+ continue;
738
+ const norm = normalizeTarget(ctx.reaRoot, d.path, d.form);
739
+ if (norm.sentinel === 'expansion') {
740
+ return blockVerdict({
741
+ reason: [
742
+ 'PROTECTED PATH (bash): unresolved shell expansion in target.',
743
+ '',
744
+ ` Token: ${norm.original}`,
745
+ ` Detected as: ${d.form}`,
746
+ ].join('\n'),
747
+ hitPattern: '(dynamic target)',
748
+ detectedForm: d.form,
749
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
750
+ });
751
+ }
752
+ if (norm.sentinel === 'outside_root') {
753
+ return blockVerdict({
754
+ reason: [
755
+ 'PROTECTED PATH (bash): path traversal escapes project root.',
756
+ '',
757
+ ` Logical: ${norm.original}`,
758
+ ` Detected as: ${d.form}`,
759
+ '',
760
+ ' Rule: bash redirects whose target resolves outside REA_ROOT are refused.',
761
+ ' Use a project-relative path without `..` segments.',
762
+ ].join('\n'),
763
+ hitPattern: '(outside-root)',
764
+ detectedForm: d.form,
765
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
766
+ });
767
+ }
768
+ // Logical-form match.
769
+ // Codex round 1 F-7: walker-flagged dir targets (-t / --target-
770
+ // directory / cp_t_flag / mv_t_flag / install_t_flag / ln_t_flag)
771
+ // get directory-shape match semantics so a write INTO `.rea`
772
+ // catches `.rea/HALT` even without a trailing slash.
773
+ // Codex round 4 Finding 1: destructive flag plumbed through so
774
+ // protected-ancestry matching can treat `rm -rf .rea` as a hit on
775
+ // `.rea/HALT`.
776
+ const matchOptions = {};
777
+ if (d.isDirTarget === true)
778
+ matchOptions.forceDirSemantics = true;
779
+ if (d.isDestructive === true)
780
+ matchOptions.isDestructive = true;
781
+ const dirOptions = Object.keys(matchOptions).length > 0 ? matchOptions : undefined;
782
+ const logicalHit = checkPathProtected(norm.pathLc, effective, dirOptions);
783
+ if (logicalHit !== null) {
784
+ return blockVerdict({
785
+ reason: buildBlockReason({
786
+ pattern: logicalHit.pattern,
787
+ hitForm: norm.pathLc,
788
+ detectedForm: d.form,
789
+ originalToken: norm.original,
790
+ }),
791
+ hitPattern: logicalHit.pattern,
792
+ detectedForm: d.form,
793
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
794
+ });
795
+ }
796
+ // Symlink-resolved-form match.
797
+ if (norm.resolvedLc !== null) {
798
+ const resolvedHit = checkPathProtected(norm.resolvedLc, effective, dirOptions);
799
+ if (resolvedHit !== null) {
800
+ return blockVerdict({
801
+ reason: buildBlockReason({
802
+ pattern: resolvedHit.pattern,
803
+ hitForm: norm.resolvedLc,
804
+ detectedForm: d.form,
805
+ originalToken: norm.original,
806
+ }),
807
+ hitPattern: resolvedHit.pattern,
808
+ detectedForm: d.form,
809
+ ...(d.position.line > 0 ? { sourcePosition: d.position } : {}),
810
+ });
811
+ }
812
+ }
813
+ }
814
+ return allowVerdict();
815
+ }