@hasna/hooks 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -12
- package/bin/index.js +2089 -470
- package/bin/serve.js +5248 -0
- package/dist/cf/provision.d.ts +24 -0
- package/dist/config.d.ts +18 -0
- package/dist/db/legacy-import.d.ts +1 -1
- package/dist/db/migrations/004_hooks_table.d.ts +9 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/storage-sync.d.ts +26 -6
- package/dist/index.d.ts +18 -2
- package/dist/index.js +5341 -286
- package/dist/lib/custom-install.d.ts +19 -0
- package/dist/lib/manifest.d.ts +70 -0
- package/dist/lib/resolve.d.ts +19 -0
- package/dist/lib/run.d.ts +37 -0
- package/dist/lib/store.d.ts +69 -0
- package/dist/lib/sync.d.ts +35 -0
- package/dist/serve.d.ts +36 -0
- package/dist/storage.d.ts +2 -2
- package/dist/storage.js +133 -42
- package/hooks/codewith-native-common.test.ts +1521 -3
- package/hooks/codewith-native-common.ts +1627 -37
- package/hooks/hook-scanoutput/README.md +151 -0
- package/hooks/hook-scanoutput/package.json +12 -0
- package/hooks/hook-scanoutput/src/hook.test.ts +217 -0
- package/hooks/hook-scanoutput/src/hook.ts +319 -0
- package/hooks/mention-context/README.md +109 -0
- package/hooks/mention-context/package.json +9 -0
- package/hooks/mention-context/src/hasna-mention-context.py +1218 -0
- package/hooks/mention-context/src/hasna-mention-warm.py +521 -0
- package/hooks/mention-context/src/hook.test.ts +68 -0
- package/hooks/mention-context/src/test_run_capture.py +345 -0
- package/hooks/pre-bash/README.md +72 -2
- package/hooks/worktree-guard/README.md +9 -2
- package/package.json +9 -5
|
@@ -134,11 +134,48 @@ export interface GitCommandInfo {
|
|
|
134
134
|
workTree?: string;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
// `$( ... )` and backtick substitutions are one operand of the surrounding command:
|
|
138
|
+
// their inner `;`, `|` and whitespace are not separators. Tokenizing them atomically is
|
|
139
|
+
// what lets the expansion-collapse rule below see `$(cmd)/*` as a single target token.
|
|
140
|
+
// If a substitution is left unterminated the command is malformed, so both tokenizers
|
|
141
|
+
// re-run with substitution tracking disabled rather than swallow the rest of the input.
|
|
142
|
+
function splitShellSegmentsPass(
|
|
143
|
+
command: string,
|
|
144
|
+
atomicSubstitutions: boolean
|
|
145
|
+
): { segments: string[]; isolation: boolean[]; depths: number[]; groups: number[]; piped: boolean[]; shortCircuit: boolean[]; unterminated: boolean } {
|
|
138
146
|
const segments: string[] = [];
|
|
147
|
+
const isolation: boolean[] = [];
|
|
148
|
+
const depths: number[] = [];
|
|
149
|
+
const groups: number[] = [];
|
|
150
|
+
const pipedFlags: boolean[] = [];
|
|
151
|
+
const shortCircuitFlags: boolean[] = [];
|
|
152
|
+
let precededByShortCircuit = false;
|
|
139
153
|
let current = "";
|
|
140
154
|
let quote: "'" | '"' | null = null;
|
|
141
155
|
let escaped = false;
|
|
156
|
+
let substitutionDepth = 0;
|
|
157
|
+
let substitutionQuote: "'" | '"' | null = null;
|
|
158
|
+
let inBacktick = false;
|
|
159
|
+
let parenDepth = 0;
|
|
160
|
+
let pipedFromPrevious = false;
|
|
161
|
+
// Every `(` opens a NEW shell. Two siblings are both depth 1 but are different processes,
|
|
162
|
+
// so depth alone cannot identify a frame.
|
|
163
|
+
let groupCounter = 0;
|
|
164
|
+
const groupStack: number[] = [0];
|
|
165
|
+
|
|
166
|
+
const flush = (nextSeparator: string | null) => {
|
|
167
|
+
if (current.trim()) {
|
|
168
|
+
segments.push(current.trim());
|
|
169
|
+
// A stage of a pipeline runs in its own process, as does anything inside `( … )`.
|
|
170
|
+
isolation.push(parenDepth > 0 || pipedFromPrevious || nextSeparator === "|");
|
|
171
|
+
depths.push(parenDepth);
|
|
172
|
+
groups.push(groupStack[groupStack.length - 1] ?? 0);
|
|
173
|
+
pipedFlags.push(pipedFromPrevious || nextSeparator === "|");
|
|
174
|
+
shortCircuitFlags.push(precededByShortCircuit);
|
|
175
|
+
}
|
|
176
|
+
current = "";
|
|
177
|
+
pipedFromPrevious = nextSeparator === "|";
|
|
178
|
+
};
|
|
142
179
|
|
|
143
180
|
for (let i = 0; i < command.length; i += 1) {
|
|
144
181
|
const ch = command[i];
|
|
@@ -152,6 +189,33 @@ function splitShellSegments(command: string): string[] {
|
|
|
152
189
|
current += ch;
|
|
153
190
|
continue;
|
|
154
191
|
}
|
|
192
|
+
if (atomicSubstitutions && substitutionDepth > 0) {
|
|
193
|
+
current += ch;
|
|
194
|
+
// Quotes inside the body are tracked so a quoted paren is not read as structure.
|
|
195
|
+
if (substitutionQuote) {
|
|
196
|
+
if (ch === substitutionQuote) substitutionQuote = null;
|
|
197
|
+
} else if (ch === "'" || ch === '"') {
|
|
198
|
+
substitutionQuote = ch;
|
|
199
|
+
} else if (ch === "(") substitutionDepth += 1;
|
|
200
|
+
else if (ch === ")") substitutionDepth -= 1;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (atomicSubstitutions && inBacktick) {
|
|
204
|
+
current += ch;
|
|
205
|
+
if (ch === "`") inBacktick = false;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (atomicSubstitutions && quote !== "'" && ch === "$" && command[i + 1] === "(") {
|
|
209
|
+
current += "$(";
|
|
210
|
+
substitutionDepth = 1;
|
|
211
|
+
i += 1;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (atomicSubstitutions && quote !== "'" && ch === "`") {
|
|
215
|
+
current += ch;
|
|
216
|
+
inBacktick = true;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
155
219
|
if (quote) {
|
|
156
220
|
current += ch;
|
|
157
221
|
if (ch === quote) quote = null;
|
|
@@ -163,23 +227,88 @@ function splitShellSegments(command: string): string[] {
|
|
|
163
227
|
continue;
|
|
164
228
|
}
|
|
165
229
|
if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
230
|
+
const doubled = (ch === "|" || ch === "&") && command[i + 1] === ch;
|
|
231
|
+
// `||` and `&&` are sequencing, not a pipe.
|
|
232
|
+
flush(ch === "|" && !doubled ? "|" : null);
|
|
233
|
+
// `a && X=1` and `a || X=1` run X= only if the left side decided so.
|
|
234
|
+
precededByShortCircuit = doubled && (ch === "|" || ch === "&");
|
|
235
|
+
if (ch === "(") {
|
|
236
|
+
parenDepth += 1;
|
|
237
|
+
groupCounter += 1;
|
|
238
|
+
groupStack.push(groupCounter);
|
|
239
|
+
} else if (ch === ")") {
|
|
240
|
+
parenDepth = Math.max(0, parenDepth - 1);
|
|
241
|
+
if (groupStack.length > 1) groupStack.pop();
|
|
242
|
+
}
|
|
243
|
+
if (doubled) i += 1;
|
|
169
244
|
continue;
|
|
170
245
|
}
|
|
171
246
|
current += ch;
|
|
172
247
|
}
|
|
173
248
|
|
|
174
|
-
|
|
175
|
-
return segments;
|
|
249
|
+
flush(null);
|
|
250
|
+
return { segments, isolation, depths, groups, piped: pipedFlags, shortCircuit: shortCircuitFlags, unterminated: substitutionDepth > 0 || inBacktick };
|
|
176
251
|
}
|
|
177
252
|
|
|
178
|
-
function
|
|
253
|
+
function splitShellSegments(command: string): string[] {
|
|
254
|
+
return splitShellSegmentsDetailed(command).map((segment) => segment.text);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A segment plus whether a `cd` in it changes the working directory of later segments. */
|
|
258
|
+
interface ShellSegment {
|
|
259
|
+
text: string;
|
|
260
|
+
/** Subshell nesting depth of this segment; a `cd` applies to this depth and deeper. */
|
|
261
|
+
depth: number;
|
|
262
|
+
/** Identity of the subshell this segment runs in; siblings at one depth differ. */
|
|
263
|
+
group: number;
|
|
264
|
+
/** This segment is a pipeline stage, so its `cd` affects nothing outside the stage. */
|
|
265
|
+
piped: boolean;
|
|
266
|
+
/** Reached only via `&&` / `||`, so whether it ran depends on the previous command. */
|
|
267
|
+
shortCircuit: boolean;
|
|
268
|
+
/**
|
|
269
|
+
* True when the segment runs in a subshell `( … )` or as a stage of a pipeline. A `cd`
|
|
270
|
+
* there affects only that child process, so treating it as persistent silently moves the
|
|
271
|
+
* guard's idea of cwd away from the directory the later `rm` actually runs in.
|
|
272
|
+
*/
|
|
273
|
+
isolated: boolean;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const segmentCache = new Map<string, ShellSegment[]>();
|
|
277
|
+
const MAX_SEGMENT_CACHE = 16;
|
|
278
|
+
|
|
279
|
+
function splitShellSegmentsDetailed(command: string): ShellSegment[] {
|
|
280
|
+
const cached = segmentCache.get(command);
|
|
281
|
+
if (cached) return cached;
|
|
282
|
+
const computed = splitShellSegmentsUncached(command);
|
|
283
|
+
if (segmentCache.size >= MAX_SEGMENT_CACHE) {
|
|
284
|
+
const oldest = segmentCache.keys().next().value;
|
|
285
|
+
if (oldest !== undefined) segmentCache.delete(oldest);
|
|
286
|
+
}
|
|
287
|
+
segmentCache.set(command, computed);
|
|
288
|
+
return computed;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function splitShellSegmentsUncached(command: string): ShellSegment[] {
|
|
292
|
+
const pass = splitShellSegmentsPass(command, true);
|
|
293
|
+
const chosen = pass.unterminated ? splitShellSegmentsPass(command, false) : pass;
|
|
294
|
+
return chosen.segments.map((text, index) => ({
|
|
295
|
+
text,
|
|
296
|
+
depth: chosen.depths[index] ?? 0,
|
|
297
|
+
group: chosen.groups[index] ?? 0,
|
|
298
|
+
piped: chosen.piped[index] ?? false,
|
|
299
|
+
shortCircuit: chosen.shortCircuit[index] ?? false,
|
|
300
|
+
isolated: chosen.isolation[index] ?? false,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function shellWordsPass(segment: string, atomicSubstitutions: boolean): { words: string[]; unterminated: boolean } {
|
|
179
305
|
const words: string[] = [];
|
|
180
306
|
let current = "";
|
|
181
307
|
let quote: "'" | '"' | null = null;
|
|
182
308
|
let escaped = false;
|
|
309
|
+
let substitutionDepth = 0;
|
|
310
|
+
let substitutionQuote: "'" | '"' | null = null;
|
|
311
|
+
let inBacktick = false;
|
|
183
312
|
|
|
184
313
|
const push = () => {
|
|
185
314
|
if (current.length > 0) {
|
|
@@ -191,14 +320,48 @@ function shellWords(segment: string): string[] {
|
|
|
191
320
|
for (let i = 0; i < segment.length; i += 1) {
|
|
192
321
|
const ch = segment[i];
|
|
193
322
|
if (escaped) {
|
|
194
|
-
|
|
323
|
+
// A backslash before a glob metacharacter is part of the pattern, not shell quoting.
|
|
324
|
+
current += /[[\]*?]/.test(ch) ? `\\${ch}` : ch;
|
|
195
325
|
escaped = false;
|
|
196
326
|
continue;
|
|
197
327
|
}
|
|
328
|
+
// Substitution bodies are copied verbatim - quotes, spaces AND backslashes. Consuming the
|
|
329
|
+
// escape here strips the backslash, and findExpansions then re-counts `\'` or `\(` as
|
|
330
|
+
// structure on the de-escaped text, which reopened the bug the quote fix closed.
|
|
331
|
+
if (atomicSubstitutions && substitutionDepth > 0) {
|
|
332
|
+
current += ch;
|
|
333
|
+
if (ch === "\\") {
|
|
334
|
+
current += segment[i + 1] ?? "";
|
|
335
|
+
i += 1;
|
|
336
|
+
} else if (substitutionQuote) {
|
|
337
|
+
if (ch === substitutionQuote) substitutionQuote = null;
|
|
338
|
+
} else if (ch === "'" || ch === '"') {
|
|
339
|
+
substitutionQuote = ch;
|
|
340
|
+
} else if (ch === "(") substitutionDepth += 1;
|
|
341
|
+
else if (ch === ")") substitutionDepth -= 1;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (atomicSubstitutions && inBacktick) {
|
|
345
|
+
current += ch;
|
|
346
|
+
if (ch === "\\") { current += segment[i + 1] ?? ""; i += 1; }
|
|
347
|
+
else if (ch === "`") inBacktick = false;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
198
350
|
if (ch === "\\" && quote !== "'") {
|
|
199
351
|
escaped = true;
|
|
200
352
|
continue;
|
|
201
353
|
}
|
|
354
|
+
if (atomicSubstitutions && quote !== "'" && ch === "$" && segment[i + 1] === "(") {
|
|
355
|
+
current += "$(";
|
|
356
|
+
substitutionDepth = 1;
|
|
357
|
+
i += 1;
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
if (atomicSubstitutions && quote !== "'" && ch === "`") {
|
|
361
|
+
current += ch;
|
|
362
|
+
inBacktick = true;
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
202
365
|
if (quote) {
|
|
203
366
|
if (ch === quote) {
|
|
204
367
|
quote = null;
|
|
@@ -218,13 +381,22 @@ function shellWords(segment: string): string[] {
|
|
|
218
381
|
current += ch;
|
|
219
382
|
}
|
|
220
383
|
push();
|
|
221
|
-
return words;
|
|
384
|
+
return { words, unterminated: substitutionDepth > 0 || inBacktick };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function shellWords(segment: string): string[] {
|
|
388
|
+
const atomic = shellWordsPass(segment, true);
|
|
389
|
+
if (!atomic.unterminated) return atomic.words;
|
|
390
|
+
return shellWordsPass(segment, false).words;
|
|
222
391
|
}
|
|
223
392
|
|
|
224
393
|
function expandHome(path: string): string {
|
|
394
|
+
// One home for every form. `~` used homedir() while `$HOME` and every protected root used
|
|
395
|
+
// process.env.HOME, so wherever the two differ the target and the rule were resolved against
|
|
396
|
+
// different directories and `rm -rf ~/.hasna` missed the ~/.hasna rule entirely.
|
|
225
397
|
const home = process.env.HOME || homedir();
|
|
226
|
-
if (path === "~") return
|
|
227
|
-
if (path.startsWith("~/")) return join(
|
|
398
|
+
if (path === "~") return home;
|
|
399
|
+
if (path.startsWith("~/")) return join(home, path.slice(2));
|
|
228
400
|
if (path === "$HOME" || path === "${HOME}") return home;
|
|
229
401
|
if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length));
|
|
230
402
|
if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length));
|
|
@@ -418,6 +590,59 @@ function activeRootsFor(input: CodewithHookInput, cwd: string): string[] {
|
|
|
418
590
|
return uniqueResolved(candidates, cwd);
|
|
419
591
|
}
|
|
420
592
|
|
|
593
|
+
/**
|
|
594
|
+
* Filesystem roots a recursive delete must never target wholesale: the FHS system
|
|
595
|
+
* directories plus their macOS equivalents, and `/` itself.
|
|
596
|
+
*
|
|
597
|
+
* `/` is here because of the 2026-07-24 station02 incident: `rm -rf "$(bun pm cache)"/*`
|
|
598
|
+
* ran as `rm -rf /*` after the substitution collapsed to empty, freed ~700 GB and
|
|
599
|
+
* permanently destroyed one repository's only source copy. Every entry is matched in
|
|
600
|
+
* "root" mode, so `rm -rf /usr` and `rm -rf /usr/*` block while `rm -rf /usr/local/lib/mine`
|
|
601
|
+
* stays allowed - the guard is about wholesale wipes, not targeted deletes.
|
|
602
|
+
*
|
|
603
|
+
* `/tmp` is deliberately absent: scratch cleanup there is routine and bounded.
|
|
604
|
+
* Machine-specific additions come from HASNA_PROTECTED_SYSTEM_ROOTS (colon-separated).
|
|
605
|
+
*/
|
|
606
|
+
export const SYSTEM_PROTECTED_ROOTS: readonly string[] = [
|
|
607
|
+
"/",
|
|
608
|
+
"/bin",
|
|
609
|
+
"/boot",
|
|
610
|
+
"/dev",
|
|
611
|
+
"/etc",
|
|
612
|
+
"/home",
|
|
613
|
+
"/lib",
|
|
614
|
+
"/lib32",
|
|
615
|
+
"/lib64",
|
|
616
|
+
"/libx32",
|
|
617
|
+
"/opt",
|
|
618
|
+
"/proc",
|
|
619
|
+
"/root",
|
|
620
|
+
"/run",
|
|
621
|
+
"/sbin",
|
|
622
|
+
"/srv",
|
|
623
|
+
"/sys",
|
|
624
|
+
"/usr",
|
|
625
|
+
"/var",
|
|
626
|
+
"/Applications",
|
|
627
|
+
"/Library",
|
|
628
|
+
"/System",
|
|
629
|
+
"/Users",
|
|
630
|
+
"/Volumes",
|
|
631
|
+
"/private",
|
|
632
|
+
];
|
|
633
|
+
|
|
634
|
+
function systemProtectedRulesFor(cwd: string): ProtectedPathRule[] {
|
|
635
|
+
const roots = uniqueResolved(
|
|
636
|
+
[...SYSTEM_PROTECTED_ROOTS, ...splitPathList(process.env.HASNA_PROTECTED_SYSTEM_ROOTS)],
|
|
637
|
+
cwd
|
|
638
|
+
);
|
|
639
|
+
return roots.map((root) => ({
|
|
640
|
+
root,
|
|
641
|
+
label: root === sep ? "filesystem root /" : `system root ${root}`,
|
|
642
|
+
mode: "root" as const,
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
|
|
421
646
|
function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null {
|
|
422
647
|
const rel = relative(resolve(workspaceRoot), resolve(target));
|
|
423
648
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
@@ -435,6 +660,10 @@ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedP
|
|
|
435
660
|
async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathContext> {
|
|
436
661
|
const home = process.env.HOME || homedir();
|
|
437
662
|
const rules: ProtectedPathRule[] = [
|
|
663
|
+
// System roots first so a root wipe is reported as the root wipe it is, rather than as
|
|
664
|
+
// whichever Hasna path happened to sit underneath it. Overlapping paths are deduplicated
|
|
665
|
+
// below with the Hasna rule's more specific label winning.
|
|
666
|
+
...systemProtectedRulesFor(cwd),
|
|
438
667
|
{ root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" },
|
|
439
668
|
];
|
|
440
669
|
const workspaceRoots = workspaceRootsFor(input, cwd);
|
|
@@ -479,11 +708,794 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool
|
|
|
479
708
|
return target === root;
|
|
480
709
|
}
|
|
481
710
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
711
|
+
// A trailing glob that matches every entry, so `dir/*` destroys all of `dir`.
|
|
712
|
+
const CATCH_ALL_GLOB = /^(?:\*|\*\*|\.\*|\.\[!\.\]\*)$/;
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Match one glob path component against one literal name, without a regular expression.
|
|
716
|
+
*
|
|
717
|
+
* Written as a linear matcher on purpose, for two reasons that both bit this branch:
|
|
718
|
+
*
|
|
719
|
+
* - Regex ESCAPING of `[`/`]` made `[e]tc` compile to a literal no directory can equal, so
|
|
720
|
+
* `rm -rf /[e]tc` - which bash expands to `/etc` - matched no protected root.
|
|
721
|
+
* - Regex COMPILATION of `*` as `[^/]*` backtracked exponentially: a ~70-character protected
|
|
722
|
+
* root component with a dozen `*b` groups took over 45s against this hook's 20s timeout,
|
|
723
|
+
* and a timed-out hook fails open. Two fail-opens in the same helper.
|
|
724
|
+
*
|
|
725
|
+
* A two-pointer wildcard match is O(pattern x name) worst case with no backtracking blowup,
|
|
726
|
+
* and bracket handling is explicit rather than delegated to regex syntax that does not mean
|
|
727
|
+
* the same thing. Unmatched constructs fall back to "matches", never to "does not match":
|
|
728
|
+
* an under-match is silent and fails open, which is exactly how `[e]tc` got through.
|
|
729
|
+
*/
|
|
730
|
+
function bracketExpressionEnd(pattern: string, open: number): number {
|
|
731
|
+
let i = open + 1;
|
|
732
|
+
if (pattern[i] === "!" || pattern[i] === "^") i += 1;
|
|
733
|
+
// A `]` in first position is a literal member, not the terminator.
|
|
734
|
+
if (pattern[i] === "]") i += 1;
|
|
735
|
+
while (i < pattern.length) {
|
|
736
|
+
const ch = pattern[i];
|
|
737
|
+
if (ch === "\\") { i += 2; continue; }
|
|
738
|
+
if (ch === "[" && (pattern[i + 1] === ":" || pattern[i + 1] === "=" || pattern[i + 1] === ".")) {
|
|
739
|
+
const kind = pattern[i + 1];
|
|
740
|
+
const classClose = pattern.indexOf(`${kind}]`, i + 2);
|
|
741
|
+
const plainClose = pattern.indexOf("]", i + 2);
|
|
742
|
+
// The `:]` must come before the next plain `]`, or this is not a class and that `]`
|
|
743
|
+
// closes the bracket. Searching to end-of-component let a `:]` belonging to a LATER
|
|
744
|
+
// bracket be taken as this one's, swallowing the real terminator - so `[u[:]` absorbed
|
|
745
|
+
// the next expression and `/[u[:][[:alpha:]]r`, which bash expands to /usr, matched
|
|
746
|
+
// nothing at all. 158 commands onto live system roots were allowed by that one line.
|
|
747
|
+
if (classClose === -1 || (plainClose !== -1 && plainClose < classClose)) {
|
|
748
|
+
return plainClose;
|
|
749
|
+
}
|
|
750
|
+
i = classClose + 2;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
if (ch === "]") return i;
|
|
754
|
+
i += 1;
|
|
755
|
+
}
|
|
756
|
+
return -1;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Does this bracket expression match `ch`?
|
|
761
|
+
*
|
|
762
|
+
* Returns TRUE whenever the expression contains anything this matcher does not model exactly.
|
|
763
|
+
* That direction is the entire design, and it is the correction for six consecutive rounds of
|
|
764
|
+
* one defect: every bracket bug on this branch has been an UNDER-match, and an under-match
|
|
765
|
+
* means a protected root goes unmatched and the delete is allowed. `[e]tc`, `[[:lower:]]`,
|
|
766
|
+
* `[e[:]tc`, `[![:foo:]]` and `[a\]e]` each named a real path in bash while the guard held a
|
|
767
|
+
* pattern that could match nothing at all.
|
|
768
|
+
*
|
|
769
|
+
* Over-matching costs a false block on a construct almost nobody writes. Under-matching costs
|
|
770
|
+
* a filesystem. So POSIX classes, equivalence and collating classes, and backslash escapes are
|
|
771
|
+
* all treated as matching rather than as not-matching.
|
|
772
|
+
*/
|
|
773
|
+
function bracketMatches(pattern: string, open: number, close: number, ch: string): boolean {
|
|
774
|
+
const body = pattern.slice(open + 1, close);
|
|
775
|
+
const negated = body.startsWith("!") || body.startsWith("^");
|
|
776
|
+
const members = negated ? body.slice(1) : body;
|
|
777
|
+
|
|
778
|
+
// Anything not modelled exactly: fail closed by matching.
|
|
779
|
+
if (/\\|\[[:=.]/.test(members)) return true;
|
|
780
|
+
|
|
781
|
+
let matched = false;
|
|
782
|
+
let first = true;
|
|
783
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
784
|
+
const member = members[i];
|
|
785
|
+
if (member === "]" && !first) break;
|
|
786
|
+
if (members[i + 1] === "-" && i + 2 < members.length && members[i + 2] !== "]") {
|
|
787
|
+
if (ch >= member && ch <= members[i + 2]) matched = true;
|
|
788
|
+
i += 2;
|
|
789
|
+
} else if (ch === member) {
|
|
790
|
+
matched = true;
|
|
791
|
+
}
|
|
792
|
+
first = false;
|
|
793
|
+
}
|
|
794
|
+
return negated ? !matched : matched;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Two-pointer wildcard match. Backtracking is limited to the last `*`, so it stays linear in
|
|
799
|
+
* practice - the compiled-regex version it replaced backtracked exponentially and blew past
|
|
800
|
+
* this hook's 20s timeout, which fails open.
|
|
801
|
+
*
|
|
802
|
+
* An unterminated or unparseable bracket makes the REST of the component match anything,
|
|
803
|
+
* rather than degrading `[` to a literal. The literal reading is an under-match, and
|
|
804
|
+
* `rm -rf /[e[:]tc` - which bash expands to `/etc` - slipped through on exactly that path.
|
|
805
|
+
*/
|
|
806
|
+
function globMatches(pattern: string, name: string): boolean {
|
|
807
|
+
let p = 0;
|
|
808
|
+
let n = 0;
|
|
809
|
+
let starPattern = -1;
|
|
810
|
+
let starName = 0;
|
|
811
|
+
|
|
812
|
+
while (n < name.length) {
|
|
813
|
+
const ch = pattern[p];
|
|
814
|
+
|
|
815
|
+
if (p < pattern.length && ch === "*") {
|
|
816
|
+
starPattern = p;
|
|
817
|
+
starName = n;
|
|
818
|
+
p += 1;
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
if (p < pattern.length && ch === "?") {
|
|
822
|
+
p += 1;
|
|
823
|
+
n += 1;
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (p < pattern.length && ch === "[") {
|
|
827
|
+
const close = bracketExpressionEnd(pattern, p);
|
|
828
|
+
if (close === -1) return true;
|
|
829
|
+
if (bracketMatches(pattern, p, close, name[n])) {
|
|
830
|
+
p = close + 1;
|
|
831
|
+
n += 1;
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
} else if (p < pattern.length) {
|
|
835
|
+
const literal = ch === "\\" && p + 1 < pattern.length ? pattern[p + 1] : ch;
|
|
836
|
+
const width = ch === "\\" && p + 1 < pattern.length ? 2 : 1;
|
|
837
|
+
if (literal === name[n]) {
|
|
838
|
+
p += width;
|
|
839
|
+
n += 1;
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
if (starPattern === -1) return false;
|
|
845
|
+
starName += 1;
|
|
846
|
+
n = starName;
|
|
847
|
+
p = starPattern + 1;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
while (pattern[p] === "*") p += 1;
|
|
851
|
+
return p >= pattern.length;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Does this glob keep no literal text that anchors it, so it can match essentially any name?
|
|
856
|
+
* `[a-z]*`, `?*`, `.??*` and `*.*` are unanchored; `*.log` and `tmp-*` are anchored.
|
|
857
|
+
*/
|
|
858
|
+
function isUnanchoredGlob(pattern: string): boolean {
|
|
859
|
+
if (!/[*?[]/.test(pattern)) return false;
|
|
860
|
+
// Computed once, not per bracket: a `[` with no `]` anywhere is a literal character, so a
|
|
861
|
+
// directory named `backup[2026` is anchored by its own name and is not a sweep.
|
|
862
|
+
const bracketsArePatterns = pattern.includes("]");
|
|
863
|
+
let residue = "";
|
|
864
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
865
|
+
const ch = pattern[i];
|
|
866
|
+
if (ch === "\\" && i + 1 < pattern.length) { residue += pattern[i + 1]; i += 1; continue; }
|
|
867
|
+
if (ch === "*" || ch === "?") continue;
|
|
868
|
+
if (ch === "[" && bracketsArePatterns) {
|
|
869
|
+
const close = bracketExpressionEnd(pattern, i);
|
|
870
|
+
// Unparseable: the rest matches anything, so nothing after it can anchor. Returning
|
|
871
|
+
// here also keeps this linear - re-scanning to end-of-pattern from every `[` was
|
|
872
|
+
// quadratic, and a 20k-bracket flood took 22s against the 20s timeout, failing open.
|
|
873
|
+
if (close === -1) return true;
|
|
874
|
+
i = close;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
residue += ch;
|
|
878
|
+
}
|
|
879
|
+
// A leading dot does not anchor: `.??*` sweeps a directory just as `*` does. Nor does
|
|
880
|
+
// punctuation alone: `*.*` takes every dotted entry at the root.
|
|
881
|
+
return residue.replace(/^\./, "").replace(/[.\-_]/g, "").length === 0;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
/** Does this component actually glob, or is it a literal that merely contains a bracket? */
|
|
886
|
+
function componentIsPattern(component: string): boolean {
|
|
887
|
+
if (/[*?]/.test(component)) return true;
|
|
888
|
+
return component.includes("[") && component.includes("]");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
export function globComponentMatches(pattern: string, literal: string): boolean {
|
|
892
|
+
if (!/[*?[]/.test(pattern)) return pattern === literal;
|
|
893
|
+
// `[` with no `]` anywhere and no other wildcard is a literal bracket, not an expression.
|
|
894
|
+
// Without this, a directory genuinely named `backup[2026` was escalated to "wipes the
|
|
895
|
+
// repository root" - fail-closed matching has to stop where bash stops globbing.
|
|
896
|
+
if (!pattern.includes("]") && !/[*?]/.test(pattern)) return pattern === literal;
|
|
897
|
+
// Fail closed on an ambiguous BOUNDARY, not just ambiguous contents.
|
|
898
|
+
//
|
|
899
|
+
// This is the defect that survived eight review rounds. Bracket CONTENTS already failed
|
|
900
|
+
// closed, but the boundary was still computed exactly - and every disagreement with bash
|
|
901
|
+
// about where a bracket ENDS misaligns the rest of the component and silently reports "no
|
|
902
|
+
// match", which allows the delete. Round 6 searched to end-of-component for the class
|
|
903
|
+
// terminator and swallowed later brackets; round 7 stopped at the first plain `]`, which is
|
|
904
|
+
// backwards (inside `[:`, a plain `]` does not terminate) and reopened the class net worse:
|
|
905
|
+
// 220 -> 380 live root-wipe escapes.
|
|
906
|
+
//
|
|
907
|
+
// Every one of those 380 contained `[:`, `[=` or `[.`. Plain brackets, ranges, negation,
|
|
908
|
+
// `*`, `?` and backslash escapes were measured clean across 44,867 dangerous patterns. So
|
|
909
|
+
// the guard stops trying to locate a boundary it cannot pin down: a component containing a
|
|
910
|
+
// POSIX class, equivalence class or collating symbol matches anything.
|
|
911
|
+
if (/\[[:=.]/.test(pattern)) return true;
|
|
912
|
+
if (CATCH_ALL_GLOB.test(pattern)) return true;
|
|
913
|
+
return globMatches(pattern, literal);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Could this glob pattern match `root` itself, or an ancestor of it?
|
|
918
|
+
*
|
|
919
|
+
* If it can, every expansion that lands there takes `root` with it. A pattern DEEPER than
|
|
920
|
+
* `root` cannot: `*/node_modules` from a repo root deletes `<child>/node_modules`, never the
|
|
921
|
+
* repo root, which is why matching only on "the first glob's parent directory" wrongly blocked
|
|
922
|
+
* `rm -rf */node_modules` - a daily monorepo command, and exactly the kind of false positive
|
|
923
|
+
* that gets a guard switched off.
|
|
924
|
+
*/
|
|
925
|
+
function globPatternCovers(patternParts: string[], rootParts: string[]): boolean {
|
|
926
|
+
if (patternParts.length > rootParts.length) return false;
|
|
927
|
+
return patternParts.every((part, index) => globComponentMatches(part, rootParts[index]));
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** Components of the pattern up to, but not including, its first glob component. */
|
|
931
|
+
function literalPrefixOf(parts: string[]): string {
|
|
932
|
+
const globIndex = parts.findIndex((part) => /[*?[]/.test(part));
|
|
933
|
+
return (globIndex === -1 ? parts : parts.slice(0, globIndex)).join(sep) || sep;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function pathHasGlob(targetPath: string): boolean {
|
|
937
|
+
return /[*?[]/.test(resolve(targetPath));
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Does a glob delete threaten this rule?
|
|
942
|
+
*
|
|
943
|
+
* Two ways, and both are needed:
|
|
944
|
+
* (a) the pattern can match the protected root or an ancestor of it - `rm -rf /*` matches
|
|
945
|
+
* `/home`, `rm -rf /*/*` matches `/home/hasna`;
|
|
946
|
+
* (b) the pattern is a wholesale wipe of the root's own contents - `rm -rf /home/*`, whose
|
|
947
|
+
* last component is a catch-all and whose prefix covers `/home`.
|
|
948
|
+
* For a tree rule, a pattern sitting inside the tree also threatens it.
|
|
949
|
+
*/
|
|
950
|
+
function globThreatensRule(targetPath: string, rule: ProtectedPathRule): boolean {
|
|
951
|
+
const parts = resolve(targetPath).split(sep);
|
|
952
|
+
const rootParts = resolve(rule.root).split(sep);
|
|
953
|
+
|
|
954
|
+
if (rule.mode === "tree") {
|
|
955
|
+
if (isInsidePath(literalPrefixOf(parts), rule.root)) return true;
|
|
956
|
+
// A pattern deeper than the root can still land inside it: `~/.h*/repos` matches
|
|
957
|
+
// ~/.hasna/repos. literalPrefixOf stops before the first glob, so it misses this.
|
|
958
|
+
if (parts.length > rootParts.length && globPatternCovers(parts.slice(0, rootParts.length), rootParts)) {
|
|
959
|
+
return true;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (globPatternCovers(parts, rootParts)) return true;
|
|
963
|
+
|
|
964
|
+
const last = parts[parts.length - 1];
|
|
965
|
+
if (CATCH_ALL_GLOB.test(last) && globPatternCovers(parts.slice(0, -1), rootParts)) return true;
|
|
966
|
+
|
|
967
|
+
// A glob in the last component sweeps the contents of its own parent. When that parent IS
|
|
968
|
+
// the protected root AND the pattern is unanchored, the sweep guts the root: `rm -rf [a-z]*`
|
|
969
|
+
// or `?*` at a repo root take almost everything.
|
|
970
|
+
//
|
|
971
|
+
// "Unanchored" means no literal character survives once wildcards are removed. That
|
|
972
|
+
// distinction is the whole point: `*.log`, `tmp-*`, `.turbo*` and `snapshot-[0-9]*` are
|
|
973
|
+
// anchored by their literal text and cannot take the root, and blocking them - which the
|
|
974
|
+
// blunt any-metacharacter version did - re-broke twelve everyday repo-root cleanups. A
|
|
975
|
+
// guard that blocks routine work gets switched off.
|
|
976
|
+
if (isUnanchoredGlob(last) && mutatesProtectedPath(parts.slice(0, -1).join(sep) || sep, rule)) return true;
|
|
977
|
+
|
|
978
|
+
// A catch-all in the FIRST component sweeps every top-level directory: `/*/bin` deletes
|
|
979
|
+
// /usr/bin, /var/bin and the rest, and a trailing literal makes the pattern deeper than any
|
|
980
|
+
// single root, so component matching alone misses it. Scoped to the filesystem root so
|
|
981
|
+
// ordinary sweeps deeper down - `/opt/*/logs`, `/var/*/tmp`, `*/node_modules` - stay allowed.
|
|
982
|
+
// At the filesystem root, ANY glob in the first component reaches several top-level
|
|
983
|
+
// directories: `/*r*/lib` matched 11 of 25 entries on the reference machine, and `/?*/bin`
|
|
984
|
+
// and `/[a-z]*/bin` reach /usr/bin exactly as `/*/bin` does. A single literal character is
|
|
985
|
+
// not an anchor at this depth, so the sweep rule does not ask for one. The cost is refusing
|
|
986
|
+
// `rm -rf /tmp*/x`, which is rare and safe to spell out literally.
|
|
987
|
+
if (rule.root === sep && parts.length > 1 && componentIsPattern(parts[1])) return true;
|
|
988
|
+
|
|
989
|
+
return false;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
const MAX_BRACE_EXPANSIONS = 64;
|
|
993
|
+
const MAX_BRACE_ROUNDS = 16;
|
|
994
|
+
|
|
995
|
+
/** Expand only the leftmost brace group of a token; null when there is none to expand. */
|
|
996
|
+
function expandLeftmostBrace(token: string): string[] | null {
|
|
997
|
+
// Skip `${…}` parameter expansions when looking for an alternation: their brace is not a
|
|
998
|
+
// brace group, and treating it as one abandoned expansion for the whole token, so
|
|
999
|
+
// `rm -rf "${HOME}"/{,.hasna}` was never expanded at all.
|
|
1000
|
+
let open = -1;
|
|
1001
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
1002
|
+
if (token[i] !== "{") continue;
|
|
1003
|
+
if (i > 0 && token[i - 1] === "$") {
|
|
1004
|
+
let depth = 0;
|
|
1005
|
+
for (; i < token.length; i += 1) {
|
|
1006
|
+
if (token[i] === "{") depth += 1;
|
|
1007
|
+
else if (token[i] === "}") { depth -= 1; if (depth === 0) break; }
|
|
1008
|
+
}
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
open = i;
|
|
1012
|
+
break;
|
|
1013
|
+
}
|
|
1014
|
+
if (open === -1) return null;
|
|
1015
|
+
|
|
1016
|
+
let depth = 0;
|
|
1017
|
+
let close = -1;
|
|
1018
|
+
const parts: string[] = [];
|
|
1019
|
+
let current = "";
|
|
1020
|
+
for (let i = open; i < token.length; i += 1) {
|
|
1021
|
+
const ch = token[i];
|
|
1022
|
+
if (ch === "\\") { current += ch + (token[i + 1] ?? ""); i += 1; continue; }
|
|
1023
|
+
if (ch === "{") {
|
|
1024
|
+
depth += 1;
|
|
1025
|
+
if (depth === 1) continue;
|
|
1026
|
+
} else if (ch === "}") {
|
|
1027
|
+
depth -= 1;
|
|
1028
|
+
if (depth === 0) { close = i; break; }
|
|
1029
|
+
} else if (ch === "," && depth === 1) {
|
|
1030
|
+
parts.push(current);
|
|
1031
|
+
current = "";
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
current += ch;
|
|
1035
|
+
}
|
|
1036
|
+
if (close === -1 || parts.length === 0) return null;
|
|
1037
|
+
parts.push(current);
|
|
1038
|
+
|
|
1039
|
+
const prefix = token.slice(0, open);
|
|
1040
|
+
const suffix = token.slice(close + 1);
|
|
1041
|
+
return parts.map((part) => `${prefix}${part}${suffix}`);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Expand `{a,b}` alternations, so `rm -rf /{bin,etc,home}` is seen as the three root deletes
|
|
1046
|
+
* it performs rather than as one literal path.
|
|
1047
|
+
*
|
|
1048
|
+
* Expansion is breadth-first and abandoned the moment it exceeds the cap, because brace
|
|
1049
|
+
* expansion is combinatorial: `/{a,b}` repeated 26 times is 2^26 paths. A recursive version
|
|
1050
|
+
* that capped only the finished list took 19.75s on that input, past this hook's 20s timeout
|
|
1051
|
+
* - and a hook that times out fails open, so a long enough brace string would have switched
|
|
1052
|
+
* the guard off and then run the delete.
|
|
1053
|
+
*
|
|
1054
|
+
* Abandoning does NOT return the raw token. Doing that was itself a bypass:
|
|
1055
|
+
* `rm -rf /{a0,…,a69,etc}` exceeded the cap and the unexpanded token resolved to a literal
|
|
1056
|
+
* path matching no protected root. Instead the brace-free prefix is returned as a catch-all
|
|
1057
|
+
* wipe, which is what an unbounded alternation under that prefix actually is - every
|
|
1058
|
+
* expansion is necessarily a child of it.
|
|
1059
|
+
*/
|
|
1060
|
+
function braceAbandonFallback(token: string): string[] {
|
|
1061
|
+
const open = token.indexOf("{");
|
|
1062
|
+
const prefix = open === -1 ? token : token.slice(0, open);
|
|
1063
|
+
const base = prefix.endsWith(sep) || prefix === "" ? prefix : `${prefix}${sep}`;
|
|
1064
|
+
const fallback = [`${base}*`];
|
|
1065
|
+
// An alternative that is itself absolute is NOT a child of the prefix: `rm -rf {/etc,a0,…}`
|
|
1066
|
+
// expands to `rm -rf /etc a0 …`, so the prefix-based fallback would miss `/etc` entirely.
|
|
1067
|
+
if (/[{,]\s*\//.test(token)) fallback.push(`${sep}*`);
|
|
1068
|
+
return fallback;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function expandBraces(token: string): string[] {
|
|
1072
|
+
if (!token.includes("{")) return [token];
|
|
1073
|
+
|
|
1074
|
+
let frontier = [token];
|
|
1075
|
+
for (let round = 0; round < MAX_BRACE_ROUNDS; round += 1) {
|
|
1076
|
+
const next: string[] = [];
|
|
1077
|
+
let expandedAny = false;
|
|
1078
|
+
for (const item of frontier) {
|
|
1079
|
+
const parts = expandLeftmostBrace(item);
|
|
1080
|
+
if (parts === null) {
|
|
1081
|
+
next.push(item);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
expandedAny = true;
|
|
1085
|
+
for (const part of parts) {
|
|
1086
|
+
if (next.length >= MAX_BRACE_EXPANSIONS) return braceAbandonFallback(token);
|
|
1087
|
+
next.push(part);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
if (!expandedAny) return next;
|
|
1091
|
+
frontier = next;
|
|
1092
|
+
}
|
|
1093
|
+
return braceAbandonFallback(token);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// `${VAR:?}` / `${VAR:?message}` aborts the shell when VAR is unset *or* empty, so this
|
|
1097
|
+
// form cannot collapse. It is the POSIX way to assert a path is present, and blocking it
|
|
1098
|
+
// would punish exactly the defensive code this guard asks for. `${VAR?}` without the colon
|
|
1099
|
+
// is NOT exempt: it permits an empty value, which is the whole hazard.
|
|
1100
|
+
const GUARDED_EXPANSION = /^\$\{[A-Za-z_][A-Za-z0-9_]*:\?/;
|
|
1101
|
+
const NON_EMPTY_PLACEHOLDER = "__hooks_guarded_expansion__";
|
|
1102
|
+
const MAX_EXPANSION_NESTING = 32;
|
|
1103
|
+
|
|
1104
|
+
// Builtins whose effect on a variable this scan cannot follow at all. Any of them clears
|
|
1105
|
+
// every guarantee, because guessing in the permissive direction is how `$X` stayed certified
|
|
1106
|
+
// non-empty while the shell had already emptied it.
|
|
1107
|
+
const OPAQUE_BUILTINS = new Set(["eval", "source", ".", "trap", "coproc", "exec"]);
|
|
1108
|
+
|
|
1109
|
+
// Compound-command keywords that can precede an assignment in the same segment.
|
|
1110
|
+
const COMPOUND_KEYWORDS = new Set(["{", "}", "then", "do", "else", "elif", "fi", "done", "!"]);
|
|
1111
|
+
|
|
1112
|
+
// Sentinel marking PWD as reassigned, so $PWD stops being treated as shell-maintained.
|
|
1113
|
+
const PWD_REASSIGNED = "\u0000PWD-REASSIGNED";
|
|
1114
|
+
|
|
1115
|
+
// Builtins that bind a BARE name, with no `=` in sight: `read D`, `getopts o D`.
|
|
1116
|
+
const NAME_BINDING_BUILTINS = new Set(["read", "getopts", "mapfile", "readarray"]);
|
|
1117
|
+
|
|
1118
|
+
// Builtins that take `NAME=value` operands. A BARE name here does not change the variable -
|
|
1119
|
+
// `export X` merely exports the existing value - so bare names must not withdraw anything.
|
|
1120
|
+
const VALUE_BINDING_BUILTINS = new Set(["export", "declare", "typeset", "readonly", "local", "let"]);
|
|
1121
|
+
|
|
1122
|
+
/** One shell expansion found in a token, with its exact source span. */
|
|
1123
|
+
interface FoundExpansion {
|
|
1124
|
+
text: string;
|
|
1125
|
+
start: number;
|
|
1126
|
+
end: number;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Locate shell expansions by scanning with a depth counter rather than by regex.
|
|
1131
|
+
*
|
|
1132
|
+
* A regex has to fix a nesting depth, and every fixed depth is a bypass:
|
|
1133
|
+
* `$(dirname "$(dirname "$(bun pm cache)")")` is three deep, and `${A:-${B}}` nests braces.
|
|
1134
|
+
*/
|
|
1135
|
+
function findExpansions(token: string): FoundExpansion[] {
|
|
1136
|
+
const found: FoundExpansion[] = [];
|
|
1137
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
1138
|
+
if (token[i] === "\\") {
|
|
1139
|
+
i += 1;
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
if (token[i] === "`") {
|
|
1143
|
+
const end = token.indexOf("`", i + 1);
|
|
1144
|
+
if (end === -1) break;
|
|
1145
|
+
found.push({ text: token.slice(i, end + 1), start: i, end: end + 1 });
|
|
1146
|
+
i = end;
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
if (token[i] !== "$") continue;
|
|
1150
|
+
|
|
1151
|
+
const next = token[i + 1];
|
|
1152
|
+
if (next === "(" || next === "{") {
|
|
1153
|
+
const open = next;
|
|
1154
|
+
const close = open === "(" ? ")" : "}";
|
|
1155
|
+
let depth = 0;
|
|
1156
|
+
let quote: "'" | '"' | null = null;
|
|
1157
|
+
let j = i + 1;
|
|
1158
|
+
for (; j < token.length; j += 1) {
|
|
1159
|
+
const ch = token[j];
|
|
1160
|
+
// An escaped character is data whether or not a quote is open: `$(echo \')`.
|
|
1161
|
+
if (ch === "\\") { j += 1; continue; }
|
|
1162
|
+
// A paren inside quotes is data, not structure: `awk -F'(' '{print $2}'`.
|
|
1163
|
+
if (quote) {
|
|
1164
|
+
if (ch === quote) quote = null;
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
if (ch === "'" || ch === '"') { quote = ch; continue; }
|
|
1168
|
+
if (ch === open) depth += 1;
|
|
1169
|
+
else if (ch === close) {
|
|
1170
|
+
depth -= 1;
|
|
1171
|
+
if (depth === 0) break;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
if (depth !== 0) break;
|
|
1175
|
+
found.push({ text: token.slice(i, j + 1), start: i, end: j + 1 });
|
|
1176
|
+
i = j;
|
|
1177
|
+
continue;
|
|
1178
|
+
}
|
|
1179
|
+
const simple = token.slice(i).match(/^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*?#$!-])/);
|
|
1180
|
+
if (simple) {
|
|
1181
|
+
found.push({ text: simple[0], start: i, end: i + simple[0].length });
|
|
1182
|
+
i += simple[0].length - 1;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
return found;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* True when the shell cannot hand this expansion back empty.
|
|
1190
|
+
*
|
|
1191
|
+
* Every entry is a guarantee, not a guess. Getting this wrong in the permissive direction
|
|
1192
|
+
* reopens the incident; getting it wrong in the strict direction blocks routine cleanup,
|
|
1193
|
+
* which gets the guard switched off. Both failures are real, so only provable cases qualify.
|
|
1194
|
+
*/
|
|
1195
|
+
function expansionCannotBeEmpty(text: string, nonEmptyNames: ReadonlySet<string>): boolean {
|
|
1196
|
+
// ${VAR:?} / ${VAR:?message} - POSIX aborts on unset or empty.
|
|
1197
|
+
if (GUARDED_EXPANSION.test(text)) return true;
|
|
1198
|
+
|
|
1199
|
+
// ${VAR:-default} with a non-empty default. `:-` substitutes the default when VAR is unset
|
|
1200
|
+
// OR empty, so the result is non-empty. Plain `${VAR-default}` does NOT qualify: it only
|
|
1201
|
+
// covers unset, so a set-but-empty VAR still yields "".
|
|
1202
|
+
// $PWD and $(pwd) are maintained by the shell, but only while nothing reassigns PWD.
|
|
1203
|
+
if (text === "$PWD" || text === "${PWD}" || /^\$\(\s*pwd\s*\)$/.test(text) || /^`\s*pwd\s*`$/.test(text)) {
|
|
1204
|
+
return !nonEmptyNames.has(PWD_REASSIGNED);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Assigned a non-empty literal earlier in this same command.
|
|
1208
|
+
const name = text.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
|
|
1209
|
+
return name !== null && nonEmptyNames.has(name[1]);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Value an expansion is guaranteed to take when the variable is unset or empty, or null when
|
|
1214
|
+
* there is no such guarantee.
|
|
1215
|
+
*
|
|
1216
|
+
* `${VAR:-default}` substitutes the default whenever VAR is unset OR empty, so the worst case
|
|
1217
|
+
* is the default itself - and the default is used verbatim rather than assumed harmless.
|
|
1218
|
+
* `${A:-/}` therefore collapses to `/` and blocks, where treating "has a default" as "is safe"
|
|
1219
|
+
* let it through. Plain `${VAR-default}` does NOT qualify: it only covers unset, so a
|
|
1220
|
+
* set-but-empty VAR still yields "".
|
|
1221
|
+
*/
|
|
1222
|
+
function expansionFallbackValue(
|
|
1223
|
+
text: string,
|
|
1224
|
+
nonEmptyNames: ReadonlySet<string>,
|
|
1225
|
+
depth = 0
|
|
1226
|
+
): string | null {
|
|
1227
|
+
const withDefault = text.match(/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([\s\S]*)\}$/);
|
|
1228
|
+
if (!withDefault) return null;
|
|
1229
|
+
// Bounded because this recurses once per nesting level while re-scanning the remainder:
|
|
1230
|
+
// `${A:-${A:- … }}` 40k deep overflowed the stack, the hook caught it and answered
|
|
1231
|
+
// {"continue":true}, and the `rm -rf /*` in the same command was never classified at all.
|
|
1232
|
+
// Past the cap there is no guarantee left to prove, so the value is treated as collapsible,
|
|
1233
|
+
// which blocks rather than allows.
|
|
1234
|
+
if (depth >= MAX_EXPANSION_NESTING) return "";
|
|
1235
|
+
const fallback = withDefault[1];
|
|
1236
|
+
if (fallback.length === 0) return "";
|
|
1237
|
+
|
|
1238
|
+
let value = "";
|
|
1239
|
+
let cursor = 0;
|
|
1240
|
+
for (const inner of findExpansions(fallback)) {
|
|
1241
|
+
value += fallback.slice(cursor, inner.start);
|
|
1242
|
+
const nested = expansionFallbackValue(inner.text, nonEmptyNames, depth + 1);
|
|
1243
|
+
if (nested !== null) value += nested;
|
|
1244
|
+
else if (expansionCannotBeEmpty(inner.text, nonEmptyNames)) value += NON_EMPTY_PLACEHOLDER;
|
|
1245
|
+
cursor = inner.end;
|
|
1246
|
+
}
|
|
1247
|
+
value += fallback.slice(cursor);
|
|
1248
|
+
return value;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* The shape that destroyed station02 on 2026-07-24.
|
|
1253
|
+
*
|
|
1254
|
+
* `bun pm cache` writes its path to stdout on success, but exits 1 with an empty stdout
|
|
1255
|
+
* when no package.json is found walking up from cwd. `rm -rf "$(bun pm cache)"/*` therefore
|
|
1256
|
+
* became `rm -rf /*`. Redirecting stderr does not help: the redirect discards the
|
|
1257
|
+
* diagnostic, not the path. The hazard is not this command - it is any expansion the shell
|
|
1258
|
+
* may hand back empty, immediately followed by a path separator.
|
|
1259
|
+
*
|
|
1260
|
+
* Returns the token with every expansion replaced by the empty string, i.e. the worst case
|
|
1261
|
+
* the shell can produce. Returns null when:
|
|
1262
|
+
* - the token contains no expansion; or
|
|
1263
|
+
* - the collapse is not absolute. A bare `rm -rf "$(cmd)"` collapses to `rm -rf ""`, which
|
|
1264
|
+
* POSIX rm rejects with "cannot remove ''" and a non-zero exit without deleting anything,
|
|
1265
|
+
* and blocking it would break routine `rm -rf "$tmpdir"` cleanup for no safety gain. A
|
|
1266
|
+
* relative collapse stays inside cwd and is already covered by the ordinary target check.
|
|
1267
|
+
* The whole catastrophic class is the one where the collapse leaves a leading `/`.
|
|
1268
|
+
*/
|
|
1269
|
+
export function emptyExpansionCollapse(
|
|
1270
|
+
token: string,
|
|
1271
|
+
nonEmptyNames: ReadonlySet<string> = new Set()
|
|
1272
|
+
): string | null {
|
|
1273
|
+
if (!/[$`]/.test(token)) return null;
|
|
1274
|
+
const expansions = findExpansions(token);
|
|
1275
|
+
if (expansions.length === 0) return null;
|
|
1276
|
+
|
|
1277
|
+
let sawCollapsible = false;
|
|
1278
|
+
let collapsed = "";
|
|
1279
|
+
let cursor = 0;
|
|
1280
|
+
for (const expansion of expansions) {
|
|
1281
|
+
collapsed += token.slice(cursor, expansion.start);
|
|
1282
|
+
const fallback = expansionFallbackValue(expansion.text, nonEmptyNames);
|
|
1283
|
+
if (fallback !== null) {
|
|
1284
|
+
// The default IS the worst case, so the resulting path still has to be checked -
|
|
1285
|
+
// `${A:-/}` yields `/`, which is the whole hazard, not a reason to skip the check.
|
|
1286
|
+
collapsed += fallback;
|
|
1287
|
+
sawCollapsible = true;
|
|
1288
|
+
} else if (expansionCannotBeEmpty(expansion.text, nonEmptyNames)) {
|
|
1289
|
+
collapsed += NON_EMPTY_PLACEHOLDER;
|
|
1290
|
+
} else {
|
|
1291
|
+
sawCollapsible = true;
|
|
1292
|
+
}
|
|
1293
|
+
cursor = expansion.end;
|
|
1294
|
+
}
|
|
1295
|
+
collapsed += token.slice(cursor);
|
|
1296
|
+
|
|
1297
|
+
if (!sawCollapsible || !collapsed.startsWith("/")) return null;
|
|
1298
|
+
return collapsed;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* One set per segment: the variables provably non-empty at the moment that segment runs.
|
|
1303
|
+
*
|
|
1304
|
+
* Built in a SINGLE forward pass. The previous version recomputed the whole segmentation and
|
|
1305
|
+
* rescanned every preceding segment on each call, and was called once per chunk - O(segments²).
|
|
1306
|
+
* 36 KB of `:; ` padding took 25.6s against this hook's 20s timeout, and a timed-out hook fails
|
|
1307
|
+
* open, so padding alone turned a blocked `rm -rf /*` into an unguarded one. That is the same
|
|
1308
|
+
* fail-open the wrapper caps were written to stop, reopened along a different axis.
|
|
1309
|
+
*
|
|
1310
|
+
* Every relaxation here is a way past the guard, so each condition is a guarantee:
|
|
1311
|
+
*
|
|
1312
|
+
* X=/tmp/build rm -rf "$X"/* a PREFIX assignment applies to the command's own
|
|
1313
|
+
* environment, not to the expansion, which bash performs
|
|
1314
|
+
* first; `$X` is still empty
|
|
1315
|
+
* rm -rf "$X"/* ; X=/tmp/build an assignment AFTER the delete counted
|
|
1316
|
+
* X=/tmp/build; X=$(cmd); rm … a later reassignment to something collapsible
|
|
1317
|
+
* X=/tmp/build; X=; rm … an explicit empty reassignment
|
|
1318
|
+
* X=/tmp/build; unset X; rm … an unset
|
|
1319
|
+
* (X=/tmp/build); rm … a subshell-scoped assignment escaping its subshell
|
|
1320
|
+
* X=/tmp/build | cat; rm … a pipeline-stage assignment doing the same
|
|
1321
|
+
*/
|
|
1322
|
+
function assignmentWalker(command: string): { at: (segmentIndex: number) => ReadonlySet<string> } {
|
|
1323
|
+
const segments = splitShellSegmentsDetailed(command);
|
|
1324
|
+
let cursor = 0;
|
|
1325
|
+
let current = new Set<string>();
|
|
1326
|
+
|
|
1327
|
+
// Advances a SINGLE set forward and hands it out only when a segment actually contains a
|
|
1328
|
+
// delete. Materialising one snapshot per segment was O(segments x names): 30k distinct
|
|
1329
|
+
// names took 24.4s against the 20s timeout, and a timed-out hook fails open. Almost every
|
|
1330
|
+
// command has one delete, so almost every command now copies nothing.
|
|
1331
|
+
const at = (segmentIndex: number): ReadonlySet<string> => {
|
|
1332
|
+
while (cursor < segmentIndex && cursor < segments.length) {
|
|
1333
|
+
applySegment(segments[cursor]);
|
|
1334
|
+
cursor += 1;
|
|
1335
|
+
}
|
|
1336
|
+
return current;
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
// Depth of open `if` / `while` / `until` / `case` blocks. Everything inside one may not run.
|
|
1340
|
+
let conditionalDepth = 0;
|
|
1341
|
+
// Brace-group nesting, and the depth at which a `&&`/`||` right-hand side was entered.
|
|
1342
|
+
let braceDepth = 0;
|
|
1343
|
+
let conditionalBraceDepth = 0;
|
|
1344
|
+
|
|
1345
|
+
function applySegment({ text, depth, isolated, shortCircuit }: ShellSegment): void {
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
// `{ X=; }`, `then X=`, `do X=` - strip the compound-command keyword so the assignment
|
|
1349
|
+
// inside is seen. cwdTrackedSegments already did this; this scan did not, so
|
|
1350
|
+
// `X=/tmp/build; { X=; }; rm -rf "$X"/*` kept X certified while bash emptied it.
|
|
1351
|
+
const rawTokens = shellWords(text);
|
|
1352
|
+
const tokens = rawTokens.filter((token, index) => !(index === 0 && COMPOUND_KEYWORDS.has(token)));
|
|
1353
|
+
// An assignment that may never execute must not CERTIFY, though it must still WITHDRAW -
|
|
1354
|
+
// the branch might run. Conditionality is a property of context, so it is tracked across
|
|
1355
|
+
// segments rather than read off the first token of this one. Deriving it from "a compound
|
|
1356
|
+
// keyword was stripped from token 0" closed about 5% of the class: inserting one statement
|
|
1357
|
+
// (`if false; then A=1; X=/tmp/build; fi`) or using `&&`/`||`/`case` restored certification,
|
|
1358
|
+
// and 297 of those shapes were bash-proven `rm -rf /*`.
|
|
1359
|
+
//
|
|
1360
|
+
// A brace group `{ …; }` is NOT conditional - bash runs it in the current shell - so it is
|
|
1361
|
+
// deliberately excluded here even though its keyword is stripped for tokenizing.
|
|
1362
|
+
// `for` opens because `done` closes it - omitting it while keeping `done` a closer let any
|
|
1363
|
+
// `for` loop inside a conditional zero the counter. `elif` does NOT open: `fi` closes an
|
|
1364
|
+
// if/elif/else chain exactly once, so counting elif left the depth permanently above zero
|
|
1365
|
+
// and nothing after the block could ever certify.
|
|
1366
|
+
const OPENERS = new Set(["if", "while", "until", "case", "select", "for"]);
|
|
1367
|
+
const CLOSERS = new Set(["fi", "done", "esac"]);
|
|
1368
|
+
// Keywords that introduce the NEXT command rather than being one, so the real command
|
|
1369
|
+
// token sits behind them: `then for f in …` opens a loop that `done` will close.
|
|
1370
|
+
const INTRODUCERS = new Set(["then", "do", "else", "elif", "!", "{", "}", "("]);
|
|
1371
|
+
|
|
1372
|
+
// Only a keyword in COMMAND POSITION is a keyword. `echo done`, `touch fi` and a `fi`
|
|
1373
|
+
// inside a heredoc body or after `#` are ordinary words, and treating them as closers
|
|
1374
|
+
// decremented the counter and re-certified the branch.
|
|
1375
|
+
let leadingIndex = 0;
|
|
1376
|
+
while (leadingIndex < rawTokens.length && INTRODUCERS.has(rawTokens[leadingIndex])) leadingIndex += 1;
|
|
1377
|
+
const leading = rawTokens[leadingIndex];
|
|
1378
|
+
const introducer = rawTokens[0];
|
|
1379
|
+
|
|
1380
|
+
if (leading !== undefined) {
|
|
1381
|
+
if (OPENERS.has(leading)) conditionalDepth += 1;
|
|
1382
|
+
else if (CLOSERS.has(leading)) conditionalDepth = Math.max(0, conditionalDepth - 1);
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// `&&`/`||` govern the WHOLE right-hand side, including a brace group. Marking only the
|
|
1386
|
+
// first segment after the operator let `false && { A=1; X=/tmp/build; }` certify X.
|
|
1387
|
+
if (introducer === "{") braceDepth += 1;
|
|
1388
|
+
if (introducer === "}" || rawTokens[rawTokens.length - 1] === "}") {
|
|
1389
|
+
braceDepth = Math.max(0, braceDepth - 1);
|
|
1390
|
+
if (braceDepth < conditionalBraceDepth) conditionalBraceDepth = 0;
|
|
1391
|
+
}
|
|
1392
|
+
if (shortCircuit && braceDepth > 0 && conditionalBraceDepth === 0) conditionalBraceDepth = braceDepth;
|
|
1393
|
+
|
|
1394
|
+
// Keyword accounting happened above, deliberately BEFORE this return: `if ls /opt | grep
|
|
1395
|
+
// -q node; then …; CACHE=…; fi` marks the `if` segment isolated (it is followed by `|`),
|
|
1396
|
+
// so returning first swallowed the opener while its `fi` still decremented - and the
|
|
1397
|
+
// assignment after it certified. That is the realized incident shape.
|
|
1398
|
+
if (depth > 0 || isolated) return;
|
|
1399
|
+
|
|
1400
|
+
const conditional = conditionalDepth > 0
|
|
1401
|
+
|| shortCircuit
|
|
1402
|
+
|| conditionalBraceDepth > 0
|
|
1403
|
+
|| (leading !== undefined && OPENERS.has(leading))
|
|
1404
|
+
|| (introducer !== undefined && (introducer === "then" || introducer === "do" || introducer === "elif"));
|
|
1405
|
+
// A function body runs later and elsewhere, so nothing in it can be relied on.
|
|
1406
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)/.test(text) || rawTokens[0] === "function") {
|
|
1407
|
+
current = new Set();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (tokens.length === 0) return;
|
|
1411
|
+
|
|
1412
|
+
if (tokens[0] === "unset") {
|
|
1413
|
+
for (const name of tokens.slice(1)) {
|
|
1414
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) current.delete(name);
|
|
1415
|
+
}
|
|
1416
|
+
return;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Any construct that can rebind a name withdraws the guarantee. Scanned across ALL
|
|
1420
|
+
// tokens, not just the first: `IFS= read -r D` hides the builtin behind a prefix
|
|
1421
|
+
// assignment and `while read D` behind a keyword, and both kept D certified non-empty.
|
|
1422
|
+
//
|
|
1423
|
+
// Single pass, no slicing. Allocating `tokens.slice(position + 1)` per token made this
|
|
1424
|
+
// O(tokens^2): 20k `export A=1 ` took 20.9s against the 20s timeout, and a timed-out hook
|
|
1425
|
+
// fails open - the fourth time a bound in this file reopened that same hole.
|
|
1426
|
+
//
|
|
1427
|
+
// WITHDRAWAL is scanned at any position, because a rebinding can hide anywhere.
|
|
1428
|
+
// CERTIFICATION is granted only from token 0, because a mention is not an execution:
|
|
1429
|
+
// `# export CACHE=/tmp/x` in a comment certified CACHE as non-empty, which is the realized
|
|
1430
|
+
// incident shape exactly - a documented cleanup script is the likeliest way to write it.
|
|
1431
|
+
const withdraw = (name: string) => {
|
|
1432
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return;
|
|
1433
|
+
current.delete(name);
|
|
1434
|
+
};
|
|
1435
|
+
|
|
1436
|
+
let pendingNameBinder = false;
|
|
1437
|
+
let pendingValueBinder = false;
|
|
1438
|
+
let valueBinderIsCommand = false;
|
|
1439
|
+
let sawNameref = false;
|
|
1440
|
+
let opaque = false;
|
|
1441
|
+
let sawNonAssignment = false;
|
|
1442
|
+
|
|
1443
|
+
for (const [position, token] of tokens.entries()) {
|
|
1444
|
+
if (OPAQUE_BUILTINS.has(token)) { opaque = true; break; }
|
|
1445
|
+
|
|
1446
|
+
if (NAME_BINDING_BUILTINS.has(token) || token === "for") {
|
|
1447
|
+
pendingNameBinder = true;
|
|
1448
|
+
pendingValueBinder = false;
|
|
1449
|
+
sawNonAssignment = true;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (VALUE_BINDING_BUILTINS.has(token)) {
|
|
1453
|
+
pendingValueBinder = true;
|
|
1454
|
+
pendingNameBinder = false;
|
|
1455
|
+
// Only a builtin in command position can actually bind anything.
|
|
1456
|
+
valueBinderIsCommand = position === 0;
|
|
1457
|
+
sawNameref = false;
|
|
1458
|
+
sawNonAssignment = true;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (token === "printf") { sawNonAssignment = true; continue; }
|
|
1462
|
+
if (token === "-v") { pendingNameBinder = true; continue; }
|
|
1463
|
+
if (token === "-n" && pendingValueBinder) { sawNameref = true; continue; }
|
|
1464
|
+
|
|
1465
|
+
if (pendingNameBinder) {
|
|
1466
|
+
if (!token.startsWith("-")) withdraw(token);
|
|
1467
|
+
continue;
|
|
1468
|
+
}
|
|
1469
|
+
if (pendingValueBinder) {
|
|
1470
|
+
if (token.startsWith("-")) continue;
|
|
1471
|
+
const bound = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/);
|
|
1472
|
+
if (!bound) continue;
|
|
1473
|
+
withdraw(bound[1]);
|
|
1474
|
+
// `declare -n D=E` aliases D to E, so D's value is E's, not this literal.
|
|
1475
|
+
if (!conditional && valueBinderIsCommand && !sawNameref && bound[2].length > 0 && !/[$`]/.test(bound[2])) {
|
|
1476
|
+
current.add(bound[1]);
|
|
1477
|
+
}
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
// Plain `NAME=value`, only while still in the command's assignment prefix.
|
|
1482
|
+
const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/);
|
|
1483
|
+
if (!assignment) { sawNonAssignment = true; continue; }
|
|
1484
|
+
if (sawNonAssignment) continue;
|
|
1485
|
+
const [, name, value] = assignment;
|
|
1486
|
+
// A PREFIX assignment applies to the command's environment, not to this expansion.
|
|
1487
|
+
const isPrefixAssignment = position < tokens.length - 1
|
|
1488
|
+
&& !/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[position + 1] ?? "");
|
|
1489
|
+
current.delete(name);
|
|
1490
|
+
if (name === "PWD") current.add(PWD_REASSIGNED);
|
|
1491
|
+
if (isPrefixAssignment) continue;
|
|
1492
|
+
if (!conditional && value.length > 0 && !/[$`]/.test(value)) current.add(name);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
if (opaque) current = new Set();
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
return { at };
|
|
487
1499
|
}
|
|
488
1500
|
|
|
489
1501
|
function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
@@ -677,8 +1689,7 @@ async function verifiedManagedRepoRoot(
|
|
|
677
1689
|
|
|
678
1690
|
function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
679
1691
|
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
680
|
-
|
|
681
|
-
if (contentBase && mutatesProtectedPath(contentBase, rule)) return true;
|
|
1692
|
+
if (pathHasGlob(targetPath)) return globThreatensRule(targetPath, rule);
|
|
682
1693
|
return threatensProtectedPath(targetPath, rule);
|
|
683
1694
|
}
|
|
684
1695
|
|
|
@@ -690,6 +1701,31 @@ function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManaged
|
|
|
690
1701
|
interface DestructiveShellTarget {
|
|
691
1702
|
path: string;
|
|
692
1703
|
operation: string;
|
|
1704
|
+
/** Same target with every shell expansion collapsed to empty; see emptyExpansionCollapse. */
|
|
1705
|
+
collapsed?: string;
|
|
1706
|
+
/** Target of a command sent to another host, so relative paths cannot be resolved here. */
|
|
1707
|
+
remote?: boolean;
|
|
1708
|
+
/** Working directory in effect for this target, after any `cd` earlier in the command. */
|
|
1709
|
+
baseCwd?: string;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// `$PWD`, `${PWD}`, `$(pwd)` and `` `pwd` `` all stand for the working directory the guard is
|
|
1713
|
+
// already tracking. They are certified non-empty, so no collapse fires - which left them as
|
|
1714
|
+
// opaque path components matching no protected root, and `rm -rf "$PWD"/*` was allowed where
|
|
1715
|
+
// the identical `rm -rf *` blocked.
|
|
1716
|
+
const PWD_EXPANSION = /\$\{PWD\}|\$PWD|\$\(\s*pwd\s*\)|`\s*pwd\s*`/g;
|
|
1717
|
+
|
|
1718
|
+
function substituteWorkingDirectory(path: string, cwd: string): string {
|
|
1719
|
+
return PWD_EXPANSION.test(path) ? path.replace(PWD_EXPANSION, cwd) : path;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function destructiveTarget(
|
|
1723
|
+
path: string,
|
|
1724
|
+
operation: string,
|
|
1725
|
+
nonEmptyNames: ReadonlySet<string> = new Set()
|
|
1726
|
+
): DestructiveShellTarget {
|
|
1727
|
+
const collapsed = emptyExpansionCollapse(path, nonEmptyNames);
|
|
1728
|
+
return collapsed === null ? { path, operation } : { path, operation, collapsed };
|
|
693
1729
|
}
|
|
694
1730
|
|
|
695
1731
|
function rmCommandTargets(command: string): DestructiveShellTarget[] {
|
|
@@ -724,7 +1760,7 @@ function rmCommandTargets(command: string): DestructiveShellTarget[] {
|
|
|
724
1760
|
}
|
|
725
1761
|
|
|
726
1762
|
if (recursive) {
|
|
727
|
-
targets.push(...segmentTargets.map((path) => (
|
|
1763
|
+
targets.push(...segmentTargets.map((path) => destructiveTarget(path, force ? "rm -rf" : "rm -r")));
|
|
728
1764
|
}
|
|
729
1765
|
}
|
|
730
1766
|
return targets;
|
|
@@ -785,7 +1821,7 @@ function rsyncDeleteTargets(command: string): DestructiveShellTarget[] {
|
|
|
785
1821
|
}
|
|
786
1822
|
|
|
787
1823
|
if (hasDelete && operands.length > 0) {
|
|
788
|
-
targets.push(
|
|
1824
|
+
targets.push(destructiveTarget(operands[operands.length - 1], "rsync --delete"));
|
|
789
1825
|
}
|
|
790
1826
|
}
|
|
791
1827
|
return targets;
|
|
@@ -823,10 +1859,10 @@ function findDestructiveTargets(command: string): DestructiveShellTarget[] {
|
|
|
823
1859
|
}
|
|
824
1860
|
|
|
825
1861
|
if (hasDelete || hasExecRm) {
|
|
826
|
-
targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => (
|
|
1862
|
+
targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => destructiveTarget(
|
|
827
1863
|
path,
|
|
828
|
-
|
|
829
|
-
|
|
1864
|
+
hasDelete ? "find -delete" : "find -exec rm"
|
|
1865
|
+
)));
|
|
830
1866
|
}
|
|
831
1867
|
}
|
|
832
1868
|
return targets;
|
|
@@ -953,13 +1989,477 @@ function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShe
|
|
|
953
1989
|
return targets;
|
|
954
1990
|
}
|
|
955
1991
|
|
|
956
|
-
|
|
1992
|
+
const SHELL_INTERPRETERS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "busybox"]);
|
|
1993
|
+
|
|
1994
|
+
// Also take a script via `-c`, but with a username operand in front of the flag.
|
|
1995
|
+
const USER_SWITCH_COMMANDS = new Set(["su", "runuser"]);
|
|
1996
|
+
|
|
1997
|
+
// ssh options that consume the following argument, so the first bare operand really is the host.
|
|
1998
|
+
const SSH_OPTIONS_WITH_VALUE = new Set([
|
|
1999
|
+
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m",
|
|
2000
|
+
"-O", "-o", "-P", "-p", "-Q", "-R", "-S", "-W", "-w",
|
|
2001
|
+
]);
|
|
2002
|
+
|
|
2003
|
+
interface ShellCommandLayer {
|
|
2004
|
+
command: string;
|
|
2005
|
+
/** True once the layer is being executed on another host via ssh. */
|
|
2006
|
+
remote: boolean;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
function commandName(token: string): string {
|
|
2010
|
+
return token.includes("/") ? token.slice(token.lastIndexOf("/") + 1) : token;
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function isShellInterpreterToken(token: string): boolean {
|
|
2014
|
+
return SHELL_INTERPRETERS.has(commandName(token));
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Shell options that consume the following word, so its value is not mistaken for the script
|
|
2018
|
+
// operand. Without this, `bash -o errexit -c '...'` reads `errexit` as the script file and the
|
|
2019
|
+
// `-c` script is never scanned.
|
|
2020
|
+
const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "+o", "--rcfile", "--init-file"]);
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Script passed via `-c`. For a shell, the first bare operand is the script *file* and the
|
|
2024
|
+
* scan stops there; `su`/`runuser` take a username operand first, so one is skipped.
|
|
2025
|
+
*/
|
|
2026
|
+
function interpreterScriptFrom(tokens: string[], shellIndex: number, allowedOperands = 0): string | null {
|
|
2027
|
+
let operands = 0;
|
|
2028
|
+
for (let i = shellIndex + 1; i < tokens.length; i += 1) {
|
|
2029
|
+
const token = tokens[i];
|
|
2030
|
+
// -c, and combined short forms such as -lc / -euxc.
|
|
2031
|
+
if (/^-[A-Za-z]*c$/.test(token)) return tokens[i + 1] ?? null;
|
|
2032
|
+
if (SHELL_OPTIONS_WITH_VALUE.has(token)) {
|
|
2033
|
+
i += 1;
|
|
2034
|
+
continue;
|
|
2035
|
+
}
|
|
2036
|
+
if (!token.startsWith("-")) {
|
|
2037
|
+
operands += 1;
|
|
2038
|
+
if (operands > allowedOperands) return null;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
return null;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
/**
|
|
2045
|
+
* Bodies of `$( … )` and backtick substitutions, as scripts in their own right.
|
|
2046
|
+
*
|
|
2047
|
+
* Required because the tokenizer treats substitutions atomically so the collapse rule can see
|
|
2048
|
+
* them whole. Without feeding the bodies back in, `echo $(rm -rf /*)` contains no `rm` token
|
|
2049
|
+
* at all and every rule misses it - the delete runs, its output is simply discarded.
|
|
2050
|
+
*/
|
|
2051
|
+
function substitutionBodies(segment: string, onTruncated?: () => void): string[] {
|
|
2052
|
+
const bodies: string[] = [];
|
|
2053
|
+
const visit = (text: string, depth: number): void => {
|
|
2054
|
+
// Exhausting this bound must not silently drop a delete: `${x:-${x:- … $(rm -rf /*)}}`
|
|
2055
|
+
// nested past the old hardcoded 4 was never classified at all.
|
|
2056
|
+
if (depth > MAX_EXPANSION_NESTING) {
|
|
2057
|
+
onTruncated?.();
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
for (const expansion of findExpansions(text)) {
|
|
2061
|
+
if (expansion.text.startsWith("$(") || expansion.text.startsWith("`")) {
|
|
2062
|
+
const body = (expansion.text.startsWith("`")
|
|
2063
|
+
? expansion.text.slice(1, -1)
|
|
2064
|
+
: expansion.text.slice(2, -1)).trim();
|
|
2065
|
+
if (body.length > 0) {
|
|
2066
|
+
bodies.push(body);
|
|
2067
|
+
visit(body, depth + 1);
|
|
2068
|
+
}
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
// `${x:-$(rm -rf /*)}` runs the substitution when x is unset. findExpansions returns
|
|
2072
|
+
// the outer ${...} and swallows the inner one, so the body has to be re-scanned.
|
|
2073
|
+
if (expansion.text.startsWith("${")) visit(expansion.text.slice(2, -1), depth + 1);
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
visit(segment, 0);
|
|
2077
|
+
return bodies;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
function sshRemoteCommandFrom(tokens: string[], sshIndex: number): string | null {
|
|
2081
|
+
for (let i = sshIndex + 1; i < tokens.length; i += 1) {
|
|
2082
|
+
const token = tokens[i];
|
|
2083
|
+
if (token === "--") continue;
|
|
2084
|
+
if (token.startsWith("-")) {
|
|
2085
|
+
if (SSH_OPTIONS_WITH_VALUE.has(token)) i += 1;
|
|
2086
|
+
continue;
|
|
2087
|
+
}
|
|
2088
|
+
// First bare operand is [user@]host; everything after it is the remote command.
|
|
2089
|
+
const remote = tokens.slice(i + 1).join(" ").trim();
|
|
2090
|
+
return remote.length > 0 ? remote : null;
|
|
2091
|
+
}
|
|
2092
|
+
return null;
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
/**
|
|
2096
|
+
* Scripts this command hands to another interpreter or to another host.
|
|
2097
|
+
*
|
|
2098
|
+
* Required, not optional: the realized 2026-07-24 incident arrived as
|
|
2099
|
+
* `ssh station02 bash -c '...'`, and the `rm` token only exists inside the quoted script.
|
|
2100
|
+
* A scan of the outer command alone sees `ssh`, `bash` and a single opaque operand.
|
|
2101
|
+
*/
|
|
2102
|
+
function isSshToken(token: string): boolean {
|
|
2103
|
+
return token === "ssh" || token.endsWith("/ssh");
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function wrappedShellLayers(command: string, remote: boolean, onTruncated?: () => void): ShellCommandLayer[] {
|
|
2107
|
+
const layers: ShellCommandLayer[] = [];
|
|
2108
|
+
for (const segment of splitShellSegments(command)) {
|
|
2109
|
+
const tokens = shellWords(segment);
|
|
2110
|
+
// `ssh host bash -c '...'`: everything after the ssh token executes on the other machine.
|
|
2111
|
+
let sshSeen = false;
|
|
2112
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
2113
|
+
const token = tokens[i];
|
|
2114
|
+
if (isShellInterpreterToken(token) || USER_SWITCH_COMMANDS.has(commandName(token))) {
|
|
2115
|
+
const allowedOperands = USER_SWITCH_COMMANDS.has(commandName(token)) ? 1 : 0;
|
|
2116
|
+
const script = interpreterScriptFrom(tokens, i, allowedOperands);
|
|
2117
|
+
if (script) layers.push({ command: script, remote: remote || sshSeen });
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
if (token === "eval") {
|
|
2121
|
+
const script = tokens.slice(i + 1).join(" ").trim();
|
|
2122
|
+
if (script) layers.push({ command: script, remote: remote || sshSeen });
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (isSshToken(token)) {
|
|
2126
|
+
sshSeen = true;
|
|
2127
|
+
const script = sshRemoteCommandFrom(tokens, i);
|
|
2128
|
+
if (script) layers.push({ command: script, remote: true });
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// A substitution body executes wherever it appears, including in assignments and in
|
|
2133
|
+
// arguments to commands that do nothing with the result.
|
|
2134
|
+
for (const body of substitutionBodies(segment, onTruncated)) {
|
|
2135
|
+
layers.push({ command: body, remote: remote || sshSeen });
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
return layers;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
const MAX_WRAPPER_DEPTH = 8;
|
|
2142
|
+
const MAX_SHELL_LAYERS = 256;
|
|
2143
|
+
|
|
2144
|
+
function shellCommandLayers(command: string): { layers: ShellCommandLayer[]; truncated: boolean } {
|
|
2145
|
+
const layers: ShellCommandLayer[] = [{ command, remote: false }];
|
|
2146
|
+
const seen = new Set([command]);
|
|
2147
|
+
let frontier: ShellCommandLayer[] = layers;
|
|
2148
|
+
let truncated = false;
|
|
2149
|
+
|
|
2150
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
2151
|
+
const next: ShellCommandLayer[] = [];
|
|
2152
|
+
for (const layer of frontier) {
|
|
2153
|
+
for (const inner of wrappedShellLayers(layer.command, layer.remote, () => { truncated = true; })) {
|
|
2154
|
+
if (seen.has(inner.command)) continue;
|
|
2155
|
+
if (layers.length + next.length >= MAX_SHELL_LAYERS) {
|
|
2156
|
+
truncated = true;
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
seen.add(inner.command);
|
|
2160
|
+
next.push(inner);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
if (next.length === 0) break;
|
|
2164
|
+
layers.push(...next);
|
|
2165
|
+
frontier = next;
|
|
2166
|
+
// More wrappers remain below the depth limit.
|
|
2167
|
+
if (depth === MAX_WRAPPER_DEPTH - 1 && next.some((layer) => wrappedShellLayers(layer.command, layer.remote).length > 0)) {
|
|
2168
|
+
truncated = true;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
return { layers, truncated };
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
// Verbs whose presence makes an unanalysable command unsafe to wave through.
|
|
2176
|
+
// `rm` followed ANYWHERE by a recursive flag. Anchoring it to the very next token missed
|
|
2177
|
+
// `rm -f -r /*`, `rm -v -f -r /*`, `rm --one-file-system -rf /*` and `rm <path> -rf`, each of
|
|
2178
|
+
// which sailed past the oversized-command gate unanalysed.
|
|
2179
|
+
const DESTRUCTIVE_VERB = /(?:^|[^\w.-])(?:[\w/.-]*\/)?(?:rm\b[^;&|\n]*?(?:\s-[A-Za-z]*[rR][A-Za-z]*(?=[\s=;&|]|$)|\s--recursive\b|\s--dir\b)|rsync\s[^;&|]*--delete|find\s[^;&|]*(?:-delete|-execdir?\s)|git\s[^;&|]*(?:clean\s+-\S*[fd]|reset\s+--hard))/;
|
|
2180
|
+
|
|
2181
|
+
/**
|
|
2182
|
+
* A command too deeply wrapped or too wide to analyse within the caps is refused when it
|
|
2183
|
+
* contains a destructive verb, instead of being allowed by default.
|
|
2184
|
+
*
|
|
2185
|
+
* The caps exist so a pathological command cannot stall the hook past its 20s timeout - and
|
|
2186
|
+
* a timed-out hook fails open. But dropping work silently turns "too complex to analyse"
|
|
2187
|
+
* into "allowed", which is the same passes-silently-while-protecting-nothing failure this
|
|
2188
|
+
* guard exists to prevent. Padding with 32 dummy `sh -c` wrappers pushed the real delete
|
|
2189
|
+
* past the cap and it returned continue.
|
|
2190
|
+
*/
|
|
2191
|
+
function truncatedAnalysisBlockReason(command: string): string | null {
|
|
2192
|
+
if (!DESTRUCTIVE_VERB.test(command)) return null;
|
|
957
2193
|
return [
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
2194
|
+
"Blocked: this command nests more shell wrappers than the safety guard can analyse,",
|
|
2195
|
+
"and it contains a recursive delete. The guard refuses rather than guess, because an",
|
|
2196
|
+
"unanalysable delete is exactly the shape that destroyed a machine on 2026-07-24.",
|
|
2197
|
+
"Run the delete directly instead of through nested bash -c / ssh / eval wrappers,",
|
|
2198
|
+
"with a literal, non-empty target path.",
|
|
2199
|
+
].join(" ");
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Remote layers run against another machine's filesystem, so a relative or cwd-derived
|
|
2204
|
+
* target here would be a guess. Absolute targets (including `~` / `$HOME` forms, which the
|
|
2205
|
+
* fleet shares) and empty-collapse targets (always absolute by construction) still apply.
|
|
2206
|
+
*/
|
|
2207
|
+
function keepRemoteTarget(target: DestructiveShellTarget): boolean {
|
|
2208
|
+
return target.collapsed !== undefined || isAbsolute(expandHome(target.path));
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
interface CommandChunk {
|
|
2212
|
+
segment: string;
|
|
2213
|
+
/** Index of this segment in the layer, so assignment visibility can be ordered. */
|
|
2214
|
+
segmentIndex: number;
|
|
2215
|
+
/** Working directories this segment may run in: the tracked cwd, plus the cwd a `cd`
|
|
2216
|
+
* whose operand collapsed to empty would have left behind. */
|
|
2217
|
+
cwds: string[];
|
|
2218
|
+
/** An absolute `cd` inside this layer fixed the directory, so it is known even remotely. */
|
|
2219
|
+
explicitCwd: boolean;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const MAX_CWD_VARIANTS = 4;
|
|
2223
|
+
// Linux PATH_MAX. A tracked cwd longer than this cannot correspond to a real directory.
|
|
2224
|
+
const MAX_TRACKED_CWD_LENGTH = 4096;
|
|
2225
|
+
// Beyond this many `cd`s the guard stops modelling the shell and fails closed; see below.
|
|
2226
|
+
const MAX_CD_OPERATIONS = 2000;
|
|
2227
|
+
// Far above any command a person or agent writes; below the size where tokenizing alone
|
|
2228
|
+
// exceeds the hook's 20s budget.
|
|
2229
|
+
// Measured on this file's own paths: 1 MB -> 264ms, 16 MB -> 3.5s, 64 MB -> 14.3s against a
|
|
2230
|
+
// 20s budget. The previous 1 MB threshold bought nothing and cost the fail-closed property.
|
|
2231
|
+
const MAX_ANALYSABLE_COMMAND_LENGTH = 32_000_000;
|
|
2232
|
+
|
|
2233
|
+
/**
|
|
2234
|
+
* Raised whenever the guard stops being able to model the command exactly.
|
|
2235
|
+
*
|
|
2236
|
+
* Every bound in this file must funnel through here. Three bounds added in one round each
|
|
2237
|
+
* invented their own fallback - skip the operand, keep the last directory, add `/` to the
|
|
2238
|
+
* candidate set - and all three turned into root wipes, because "I cannot model this" was
|
|
2239
|
+
* quietly answered as "so carry on". A degraded analysis carrying a recursive delete is
|
|
2240
|
+
* refused instead.
|
|
2241
|
+
*/
|
|
2242
|
+
interface AnalysisState {
|
|
2243
|
+
degraded: boolean;
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Segments of one layer paired with the working directories in effect when they run.
|
|
2248
|
+
*
|
|
2249
|
+
* Without this, `cd / && rm -rf *` reads as a glob over wherever the agent started, which is
|
|
2250
|
+
* the cheapest possible way around a guard that only inspects the literal target. The
|
|
2251
|
+
* collapsed variant covers `cd "$(cmd)"/ && rm -rf ./*`, which is the incident's shape moved
|
|
2252
|
+
* one command to the left.
|
|
2253
|
+
*/
|
|
2254
|
+
function cwdTrackedSegments(command: string, baseCwd: string, nonEmptyNames: ReadonlySet<string>, analysis: AnalysisState): CommandChunk[] {
|
|
2255
|
+
const chunks: CommandChunk[] = [];
|
|
2256
|
+
const home = process.env.HOME || homedir();
|
|
2257
|
+
// One entry per subshell nesting depth. A `cd` inside `( … )` DOES apply to the rest of
|
|
2258
|
+
// that subshell - it just does not escape to the parent - so skipping isolated `cd`
|
|
2259
|
+
// outright left `(cd / && rm -rf *)`, the standard "cd without moving my shell" idiom,
|
|
2260
|
+
// completely unguarded. Depth 0 is the parent shell.
|
|
2261
|
+
let cdOperations = 0;
|
|
2262
|
+
let stack: Array<{ group: number; cwds: string[]; previous: string[]; dirStack: string[][]; explicit: boolean }> = [
|
|
2263
|
+
{ group: 0, cwds: [baseCwd], previous: [baseCwd], dirStack: [], explicit: false },
|
|
962
2264
|
];
|
|
2265
|
+
|
|
2266
|
+
const frameFor = (depth: number, group: number) => {
|
|
2267
|
+
// Leaving a subshell discards everything it did.
|
|
2268
|
+
if (stack.length > depth + 1) stack = stack.slice(0, depth + 1);
|
|
2269
|
+
while (stack.length <= depth) {
|
|
2270
|
+
const parent = stack[stack.length - 1];
|
|
2271
|
+
stack.push({ group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit });
|
|
2272
|
+
}
|
|
2273
|
+
// A DIFFERENT group at the same depth is a sibling subshell - a separate process that
|
|
2274
|
+
// never saw the previous one's `cd`. Reusing the frame let `(cd /elsewhere); (rm -rf *)`
|
|
2275
|
+
// point the guard at an attacker-chosen directory while bash deleted the real cwd.
|
|
2276
|
+
const frame = stack[depth];
|
|
2277
|
+
if (frame.group !== group) {
|
|
2278
|
+
const parent = stack[depth - 1] ?? stack[0];
|
|
2279
|
+
stack[depth] = { group, cwds: parent.cwds, previous: parent.previous, dirStack: [...parent.dirStack], explicit: parent.explicit };
|
|
2280
|
+
}
|
|
2281
|
+
return stack[depth];
|
|
2282
|
+
};
|
|
2283
|
+
|
|
2284
|
+
splitShellSegmentsDetailed(command).forEach(({ text: segment, depth, group, piped, shortCircuit }, segmentIndex) => {
|
|
2285
|
+
const frame = frameFor(depth, group);
|
|
2286
|
+
// A leading `{` from a brace group is not part of the command.
|
|
2287
|
+
const tokens = shellWords(segment).filter((token, index) => !(index === 0 && (token === "{" || token === "}")));
|
|
2288
|
+
const verb = tokens[0];
|
|
2289
|
+
|
|
2290
|
+
// `popd` returns the shell to where `pushd` came from. It was unhandled, so the pushd
|
|
2291
|
+
// target stayed as the tracked cwd for the rest of the command and
|
|
2292
|
+
// `pushd /tmp; popd; rm -rf *` deleted the original directory unguarded.
|
|
2293
|
+
if (verb === "popd") {
|
|
2294
|
+
if (piped) return;
|
|
2295
|
+
const restored = frame.dirStack.pop();
|
|
2296
|
+
if (restored) {
|
|
2297
|
+
frame.previous = frame.cwds;
|
|
2298
|
+
frame.cwds = restored;
|
|
2299
|
+
frame.explicit = restored.some((dir) => dir !== baseCwd);
|
|
2300
|
+
}
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
if (verb === "cd" || verb === "pushd") {
|
|
2305
|
+
// A `cd` in a pipeline stage runs in its own process and moves nothing else. One
|
|
2306
|
+
// reached via `&&`/`||` may not run at all: `cd /home/hasna; false && cd /tmp;
|
|
2307
|
+
// rm -rf *` left the guard in /tmp while bash stayed in the home directory.
|
|
2308
|
+
if (piped) return;
|
|
2309
|
+
if (shortCircuit) {
|
|
2310
|
+
analysis.degraded = true;
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
// `pushd -n` records the directory WITHOUT moving the shell, so the tracked cwd must
|
|
2314
|
+
// not follow it. Previously `-n` was read as the directory operand.
|
|
2315
|
+
if (verb === "pushd" && tokens.includes("-n")) return;
|
|
2316
|
+
// `pushd` saves the current directory before moving.
|
|
2317
|
+
if (verb === "pushd") frame.dirStack.push(frame.cwds);
|
|
2318
|
+
// Skip cd's own flags (-P, -L, --) to reach the directory operand.
|
|
2319
|
+
let i = 1;
|
|
2320
|
+
while (i < tokens.length && (tokens[i] === "-P" || tokens[i] === "-L" || tokens[i] === "-e" || tokens[i] === "-@" || tokens[i] === "--")) i += 1;
|
|
2321
|
+
const operand = tokens[i];
|
|
2322
|
+
const priorCwds = frame.cwds;
|
|
2323
|
+
cdOperations += 1;
|
|
2324
|
+
// Both cd bounds below mark the analysis degraded rather than inventing a fallback.
|
|
2325
|
+
// Skipping an over-long operand allowed `cd ////…(4200); rm -rf *`, and adding `/` to
|
|
2326
|
+
// the candidate set caught only sweep targets - `cd /home/hasna; cd .x2000; cd ..;
|
|
2327
|
+
// rm -rf hasna` still destroyed the Hasna home.
|
|
2328
|
+
// Once the budget is spent the guard can no longer model a chain of RELATIVE cds. It
|
|
2329
|
+
// must not simply keep the last known directory - that was the fail-open the PATH_MAX
|
|
2330
|
+
// cap produced - so `/` joins the candidate set and any relative delete is judged
|
|
2331
|
+
// against the filesystem root too. `rm -rf *` then blocks; `rm -rf dist` still resolves
|
|
2332
|
+
// to /dist and passes.
|
|
2333
|
+
//
|
|
2334
|
+
// An ABSOLUTE cd is never dropped: it is a real landing the guard can still model
|
|
2335
|
+
// exactly, and skipping it lost `cd ~` after a flood, which allowed `rm -rf .hasna`.
|
|
2336
|
+
if (cdOperations > MAX_CD_OPERATIONS && !isAbsolute(expandHome(operand ?? ""))) {
|
|
2337
|
+
analysis.degraded = true;
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
if (operand === undefined || operand === "~") {
|
|
2342
|
+
frame.cwds = [home];
|
|
2343
|
+
frame.explicit = true;
|
|
2344
|
+
} else if (operand === "-" || operand === "$OLDPWD" || operand === "${OLDPWD}") {
|
|
2345
|
+
frame.cwds = frame.previous;
|
|
2346
|
+
frame.explicit = frame.previous.some((dir) => dir !== baseCwd);
|
|
2347
|
+
} else {
|
|
2348
|
+
const collapsed = emptyExpansionCollapse(operand, nonEmptyNames);
|
|
2349
|
+
const next = new Set<string>();
|
|
2350
|
+
for (const current of frame.cwds) {
|
|
2351
|
+
// Only operands that can GROW the path are capped.
|
|
2352
|
+
//
|
|
2353
|
+
// `..` and `.` shrink or hold, and skipping them froze the model permanently: after
|
|
2354
|
+
// one crossing, `cd d0 … cd d1999; cd ..x2100` left the guard on the long path while
|
|
2355
|
+
// bash had walked back to `/`, so `rm -rf *` was allowed. That was a fail-open
|
|
2356
|
+
// introduced by the cap itself - the seventh time a bound in this file produced one.
|
|
2357
|
+
//
|
|
2358
|
+
// An absolute operand replaces the path, but resolving a 4KB operand 70k times still
|
|
2359
|
+
// took 24s against the 20s timeout, so its own length is capped too. No real
|
|
2360
|
+
// directory exceeds PATH_MAX, which is why this is a correctness bound and not just
|
|
2361
|
+
// a throttle.
|
|
2362
|
+
const expanded = expandHome(operand);
|
|
2363
|
+
const shrinksOnly = /^[./]+$/.test(expanded);
|
|
2364
|
+
const wouldGrow = !shrinksOnly && !isAbsolute(expanded);
|
|
2365
|
+
if (wouldGrow && current.length > MAX_TRACKED_CWD_LENGTH) {
|
|
2366
|
+
analysis.degraded = true;
|
|
2367
|
+
next.add(current);
|
|
2368
|
+
continue;
|
|
2369
|
+
}
|
|
2370
|
+
if (expanded.length > MAX_TRACKED_CWD_LENGTH) {
|
|
2371
|
+
analysis.degraded = true;
|
|
2372
|
+
next.add(current);
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
next.add(resolveFrom(current, operand));
|
|
2376
|
+
if (collapsed !== null) next.add(resolveFrom(current, collapsed));
|
|
2377
|
+
}
|
|
2378
|
+
frame.cwds = [...next].slice(0, MAX_CWD_VARIANTS);
|
|
2379
|
+
if (isAbsolute(expandHome(operand)) || collapsed !== null) frame.explicit = true;
|
|
2380
|
+
}
|
|
2381
|
+
frame.previous = priorCwds;
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
chunks.push({ segment, segmentIndex, cwds: frame.cwds, explicitCwd: frame.explicit });
|
|
2386
|
+
});
|
|
2387
|
+
return chunks;
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
2391
|
+
* `for d in /*; do rm -rf "$d"; done` deletes the filesystem root one entry at a time while
|
|
2392
|
+
* the delete's own target is an innocuous `$d`. Only exact `$VAR` / `${VAR}` targets bound by
|
|
2393
|
+
* a `for ... in` in the same layer are substituted, so this cannot fire on unrelated commands.
|
|
2394
|
+
*/
|
|
2395
|
+
function forLoopBindings(command: string): Map<string, string[]> {
|
|
2396
|
+
const bindings = new Map<string, string[]>();
|
|
2397
|
+
for (const segment of splitShellSegments(command)) {
|
|
2398
|
+
const tokens = shellWords(segment);
|
|
2399
|
+
const forIndex = tokens.indexOf("for");
|
|
2400
|
+
if (forIndex === -1) continue;
|
|
2401
|
+
const name = tokens[forIndex + 1];
|
|
2402
|
+
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) continue;
|
|
2403
|
+
if (tokens[forIndex + 2] !== "in") continue;
|
|
2404
|
+
const words = tokens.slice(forIndex + 3).filter((token) => token !== "do");
|
|
2405
|
+
if (words.length > 0) bindings.set(name, words);
|
|
2406
|
+
}
|
|
2407
|
+
return bindings;
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
function loopBoundWords(path: string, bindings: Map<string, string[]>): string[] | null {
|
|
2411
|
+
const match = path.match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
|
|
2412
|
+
if (!match) return null;
|
|
2413
|
+
return bindings.get(match[1]) ?? null;
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
function destructiveShellTargets(command: string, cwd: string, analysis: AnalysisState): DestructiveShellTarget[] {
|
|
2417
|
+
const targets: DestructiveShellTarget[] = [];
|
|
2418
|
+
for (const layer of shellCommandLayers(command).layers) {
|
|
2419
|
+
const bindings = forLoopBindings(layer.command);
|
|
2420
|
+
|
|
2421
|
+
const assignments = assignmentWalker(layer.command);
|
|
2422
|
+
|
|
2423
|
+
for (const chunk of cwdTrackedSegments(layer.command, cwd, new Set(), analysis)) {
|
|
2424
|
+
const raw = [
|
|
2425
|
+
...rmCommandTargets(chunk.segment),
|
|
2426
|
+
...rsyncDeleteTargets(chunk.segment),
|
|
2427
|
+
...findDestructiveTargets(chunk.segment),
|
|
2428
|
+
...gitDestructiveTargets(chunk.segment, chunk.cwds[0]),
|
|
2429
|
+
];
|
|
2430
|
+
if (raw.length === 0) continue;
|
|
2431
|
+
// The walker advances one set IN PLACE - that is what removed the O(segments x names)
|
|
2432
|
+
// copy, not this lookup being lazy.
|
|
2433
|
+
const nonEmptyNames = assignments.at(chunk.segmentIndex);
|
|
2434
|
+
|
|
2435
|
+
const expanded = raw.flatMap((target) => {
|
|
2436
|
+
const words = loopBoundWords(target.path, bindings);
|
|
2437
|
+
const paths = words ?? expandBraces(target.path);
|
|
2438
|
+
return paths.length === 1 && paths[0] === target.path && words === null
|
|
2439
|
+
? [destructiveTarget(target.path, target.operation, nonEmptyNames)]
|
|
2440
|
+
: paths.map((word) => destructiveTarget(word, target.operation, nonEmptyNames));
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
const chunkTargets = expanded.flatMap((target) =>
|
|
2444
|
+
chunk.cwds.map((chunkCwd) => ({
|
|
2445
|
+
...target,
|
|
2446
|
+
path: substituteWorkingDirectory(target.path, chunkCwd),
|
|
2447
|
+
baseCwd: chunkCwd,
|
|
2448
|
+
}))
|
|
2449
|
+
);
|
|
2450
|
+
|
|
2451
|
+
if (!layer.remote) {
|
|
2452
|
+
targets.push(...chunkTargets);
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
targets.push(
|
|
2456
|
+
...chunkTargets
|
|
2457
|
+
.filter((target) => chunk.explicitCwd || keepRemoteTarget(target))
|
|
2458
|
+
.map((target) => ({ ...target, remote: true }))
|
|
2459
|
+
);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
return targets;
|
|
963
2463
|
}
|
|
964
2464
|
|
|
965
2465
|
function isApplyPatchTool(toolName: string): boolean {
|
|
@@ -1006,11 +2506,45 @@ function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; o
|
|
|
1006
2506
|
return paths;
|
|
1007
2507
|
}
|
|
1008
2508
|
|
|
1009
|
-
function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule): string {
|
|
2509
|
+
function scopedBlockReason(operation: string, targetPath: string, rule: ProtectedPathRule, remote?: boolean): string {
|
|
2510
|
+
// A POSIX class, equivalence class or collating symbol makes the pattern's extent
|
|
2511
|
+
// unpinnable, so the guard treats it as matching anything. Saying only "targets /" would be
|
|
2512
|
+
// wrong and confusing when the command reads `rm -rf /var[.]log` - the operator needs to
|
|
2513
|
+
// know it was refused for being unanalysable, not for naming the filesystem root.
|
|
2514
|
+
const unpinnable = /\[[:=.]/.test(targetPath)
|
|
2515
|
+
? [
|
|
2516
|
+
"This target contains a POSIX character class, equivalence class or collating symbol,",
|
|
2517
|
+
"whose extent cannot be determined without replicating the shell exactly. It is therefore",
|
|
2518
|
+
"treated as matching any name. Use a literal path, or a plain glob, if this was not intended.",
|
|
2519
|
+
]
|
|
2520
|
+
: [];
|
|
1010
2521
|
return [
|
|
1011
|
-
`Blocked scoped dangerous operation: ${operation} targets ${targetPath}.`,
|
|
2522
|
+
`Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`,
|
|
1012
2523
|
`Protected scope: ${rule.label} (${rule.root}).`,
|
|
2524
|
+
...unpinnable,
|
|
1013
2525
|
"This guard is scoped; destructive commands outside protected roots are not blocked.",
|
|
2526
|
+
"Delete a specific named subdirectory instead of the root or its contents.",
|
|
2527
|
+
].join(" ");
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function collapseBlockReason(
|
|
2531
|
+
operation: string,
|
|
2532
|
+
rawTarget: string,
|
|
2533
|
+
collapsedTarget: string,
|
|
2534
|
+
rule: ProtectedPathRule,
|
|
2535
|
+
remote?: boolean
|
|
2536
|
+
): string {
|
|
2537
|
+
return [
|
|
2538
|
+
`Blocked unsafe expansion in a destructive command: ${operation} target ${rawTarget}`,
|
|
2539
|
+
`collapses to ${collapsedTarget}${remote ? " on a remote host" : ""} when the expansion returns empty`,
|
|
2540
|
+
"(a command substitution that fails or prints nothing, or an unset variable),",
|
|
2541
|
+
`which would destroy ${rule.label} (${rule.root}).`,
|
|
2542
|
+
"This is the 2026-07-24 station02 failure: `bun pm cache` exits non-zero with empty stdout when no",
|
|
2543
|
+
"package.json is found walking up from cwd, so `rm -rf \"$(bun pm cache)\"/*` ran as `rm -rf /*`.",
|
|
2544
|
+
"Redirecting stderr does not help - it discards the diagnostic, not the path.",
|
|
2545
|
+
"Safe alternative: resolve the path first, verify it is non-empty and not a protected root, then delete it,",
|
|
2546
|
+
'e.g. `dir="$(bun pm cache)" || exit 1; case "$dir" in /|"") exit 1;; esac; rm -rf -- "$dir"`.',
|
|
2547
|
+
"This guard blocks the shape, not the command: any expansion immediately followed by `/` can collapse to the filesystem root.",
|
|
1014
2548
|
].join(" ");
|
|
1015
2549
|
}
|
|
1016
2550
|
|
|
@@ -1020,11 +2554,37 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
1020
2554
|
const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd);
|
|
1021
2555
|
|
|
1022
2556
|
if (input.tool_name === "Bash") {
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
2557
|
+
const command = getCommand(input);
|
|
2558
|
+
const analysis: AnalysisState = { degraded: false };
|
|
2559
|
+
|
|
2560
|
+
// A command large enough that merely tokenizing it blows the hook's 20s budget cannot be
|
|
2561
|
+
// analysed at all, and a timed-out hook fails open. 70k repetitions of `cd /<4KB>` is a
|
|
2562
|
+
// 280 MB string: no per-rule bound helps, because the cost is reading the input. Refuse it
|
|
2563
|
+
// when it carries a recursive delete, rather than letting the timeout decide.
|
|
2564
|
+
if (command.length > MAX_ANALYSABLE_COMMAND_LENGTH) {
|
|
2565
|
+
// Decided here either way. Falling through to the full scan for a command with no
|
|
2566
|
+
// delete in it still spent 46s tokenizing, which stalls every Bash call behind the
|
|
2567
|
+
// hook's timeout for no benefit.
|
|
2568
|
+
const reason = truncatedAnalysisBlockReason(command);
|
|
2569
|
+
return reason ? { block: true, operation: "oversized command", reason } : { block: false };
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
if (shellCommandLayers(command).truncated) {
|
|
2573
|
+
const reason = truncatedAnalysisBlockReason(command);
|
|
2574
|
+
if (reason) {
|
|
2575
|
+
return { block: true, operation: "unanalysable nested command", reason };
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
for (const target of destructiveShellTargets(command, cwd, analysis)) {
|
|
2580
|
+
const targetCwd = target.baseCwd ?? cwd;
|
|
2581
|
+
const targetPath = resolveFrom(targetCwd, target.path);
|
|
2582
|
+
const rulesFor = (path: string) => {
|
|
2583
|
+
const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(path, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
|
|
2584
|
+
return extraRule ? [...rules, extraRule] : rules;
|
|
2585
|
+
};
|
|
2586
|
+
|
|
2587
|
+
for (const rule of rulesFor(targetPath)) {
|
|
1028
2588
|
if (threatensRule(targetPath, rule, currentManagedRepoRoot)) {
|
|
1029
2589
|
return {
|
|
1030
2590
|
block: true,
|
|
@@ -1032,10 +2592,38 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
1032
2592
|
protectedPath: rule.root,
|
|
1033
2593
|
protectedLabel: rule.label,
|
|
1034
2594
|
operation: target.operation,
|
|
1035
|
-
reason: scopedBlockReason(target.operation, targetPath, rule),
|
|
2595
|
+
reason: scopedBlockReason(target.operation, targetPath, rule, target.remote),
|
|
1036
2596
|
};
|
|
1037
2597
|
}
|
|
1038
2598
|
}
|
|
2599
|
+
|
|
2600
|
+
// Second pass over the same target as the shell would produce it if every expansion
|
|
2601
|
+
// came back empty. The managed-worktree escape hatch is not applied here: an empty
|
|
2602
|
+
// collapse leaves the worktree entirely, so it can never be the intended target.
|
|
2603
|
+
if (target.collapsed === undefined) continue;
|
|
2604
|
+
const collapsedPath = resolveFrom(targetCwd, target.collapsed);
|
|
2605
|
+
for (const rule of rulesFor(collapsedPath)) {
|
|
2606
|
+
if (threatensRule(collapsedPath, rule, null)) {
|
|
2607
|
+
return {
|
|
2608
|
+
block: true,
|
|
2609
|
+
targetPath: collapsedPath,
|
|
2610
|
+
protectedPath: rule.root,
|
|
2611
|
+
protectedLabel: rule.label,
|
|
2612
|
+
operation: target.operation,
|
|
2613
|
+
reason: collapseBlockReason(target.operation, target.path, collapsedPath, rule, target.remote),
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
// Raised during the scan above by any bound that stopped modelling the command
|
|
2620
|
+
// exactly. Checked here rather than at each bound so there is ONE fail-closed answer:
|
|
2621
|
+
// three bounds that each invented their own fallback all became root wipes.
|
|
2622
|
+
if (analysis.degraded) {
|
|
2623
|
+
const reason = truncatedAnalysisBlockReason(command);
|
|
2624
|
+
if (reason) {
|
|
2625
|
+
return { block: true, operation: "unanalysable command", reason };
|
|
2626
|
+
}
|
|
1039
2627
|
}
|
|
1040
2628
|
}
|
|
1041
2629
|
|
|
@@ -1126,7 +2714,9 @@ export function isTopLevelSession(input: CodewithHookInput): boolean {
|
|
|
1126
2714
|
}
|
|
1127
2715
|
|
|
1128
2716
|
export function defaultWorktreesRoot(): string {
|
|
1129
|
-
|
|
2717
|
+
// Same home for every resolution in this file; see expandHome.
|
|
2718
|
+
return process.env.HASNA_REPOS_WORKTREES_ROOT
|
|
2719
|
+
|| join(process.env.HOME || homedir(), ".hasna", "repos", "worktrees");
|
|
1130
2720
|
}
|
|
1131
2721
|
|
|
1132
2722
|
export function isInsidePath(child: string, parent: string): boolean {
|