@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.
- package/README.md +15 -0
- package/THREAT_MODEL.md +753 -0
- package/dist/audit/append.js +1 -1
- package/dist/cli/doctor.js +11 -12
- package/dist/cli/hook.d.ts +37 -3
- package/dist/cli/hook.js +167 -5
- package/dist/cli/init.js +14 -26
- package/dist/cli/install/canonical.js +18 -3
- package/dist/cli/install/commit-msg.js +1 -2
- package/dist/cli/install/copy.js +4 -13
- package/dist/cli/install/fs-safe.js +5 -16
- package/dist/cli/install/gitignore.js +1 -5
- package/dist/cli/install/pre-push.js +3 -8
- package/dist/cli/install/settings-merge.js +79 -16
- package/dist/cli/upgrade.js +14 -10
- package/dist/gateway/downstream.js +1 -2
- package/dist/gateway/live-state.js +3 -1
- package/dist/gateway/log.js +1 -3
- package/dist/gateway/middleware/audit.js +1 -1
- package/dist/gateway/middleware/injection.js +3 -9
- package/dist/gateway/middleware/policy.js +3 -1
- package/dist/gateway/middleware/redact.js +1 -1
- package/dist/gateway/observability/codex-telemetry.js +1 -2
- package/dist/gateway/reviewers/claude-self.js +10 -6
- package/dist/hooks/bash-scanner/blocked-scan.d.ts +26 -0
- package/dist/hooks/bash-scanner/blocked-scan.js +467 -0
- package/dist/hooks/bash-scanner/index.d.ts +41 -0
- package/dist/hooks/bash-scanner/index.js +62 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.d.ts +31 -0
- package/dist/hooks/bash-scanner/parse-fail-closed.js +27 -0
- package/dist/hooks/bash-scanner/parser.d.ts +42 -0
- package/dist/hooks/bash-scanner/parser.js +92 -0
- package/dist/hooks/bash-scanner/protected-scan.d.ts +76 -0
- package/dist/hooks/bash-scanner/protected-scan.js +868 -0
- package/dist/hooks/bash-scanner/verdict.d.ts +80 -0
- package/dist/hooks/bash-scanner/verdict.js +49 -0
- package/dist/hooks/bash-scanner/walker.d.ts +165 -0
- package/dist/hooks/bash-scanner/walker.js +9087 -0
- package/dist/hooks/push-gate/base.js +2 -6
- package/dist/hooks/push-gate/codex-runner.js +3 -1
- package/dist/hooks/push-gate/index.js +9 -10
- package/dist/policy/loader.js +4 -1
- package/dist/registry/tofu-gate.js +2 -2
- package/hooks/blocked-paths-bash-gate.sh +142 -272
- package/hooks/protected-paths-bash-gate.sh +227 -511
- package/package.json +3 -2
- package/profiles/bst-internal-no-codex.yaml +1 -1
- package/profiles/bst-internal.yaml +1 -1
- package/profiles/client-engagement.yaml +1 -1
- package/profiles/lit-wc.yaml +1 -1
- package/profiles/minimal.yaml +1 -1
- package/profiles/open-source-no-codex.yaml +1 -1
- package/profiles/open-source.yaml +1 -1
- package/scripts/postinstall.mjs +1 -2
- 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
|
+
}
|