@bookedsolid/rea 0.22.0 → 0.23.1

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