@hasna/hooks 0.4.0 → 0.5.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 +15 -4
- package/hooks/codewith-native-common.test.ts +1870 -13
- package/hooks/codewith-native-common.ts +2039 -61
- package/hooks/pre-bash/README.md +72 -2
- package/hooks/worktree-guard/README.md +53 -2
- package/hooks/worktree-guard/src/hook.ts +50 -8
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync, writeSync } from "fs";
|
|
2
2
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve, sep } from "path";
|
|
3
3
|
import { homedir, tmpdir } from "os";
|
|
4
4
|
|
|
@@ -57,7 +57,15 @@ export function readInput(): CodewithHookInput {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function respond(output: CodewithHookOutput): void {
|
|
60
|
-
process.stdout.write
|
|
60
|
+
// Written synchronously: `process.stdout.write` is async on a pipe, so a verdict
|
|
61
|
+
// larger than the pipe buffer is silently truncated if the process exits before it
|
|
62
|
+
// drains — and a truncated verdict is unparseable, so the caller sees no decision.
|
|
63
|
+
const payload = `${JSON.stringify(output)}\n`;
|
|
64
|
+
try {
|
|
65
|
+
writeSync(1, payload);
|
|
66
|
+
} catch {
|
|
67
|
+
process.stdout.write(payload);
|
|
68
|
+
}
|
|
61
69
|
}
|
|
62
70
|
|
|
63
71
|
export function warn(message: string): void {
|
|
@@ -126,11 +134,48 @@ export interface GitCommandInfo {
|
|
|
126
134
|
workTree?: string;
|
|
127
135
|
}
|
|
128
136
|
|
|
129
|
-
|
|
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 } {
|
|
130
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;
|
|
131
153
|
let current = "";
|
|
132
154
|
let quote: "'" | '"' | null = null;
|
|
133
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
|
+
};
|
|
134
179
|
|
|
135
180
|
for (let i = 0; i < command.length; i += 1) {
|
|
136
181
|
const ch = command[i];
|
|
@@ -144,6 +189,33 @@ function splitShellSegments(command: string): string[] {
|
|
|
144
189
|
current += ch;
|
|
145
190
|
continue;
|
|
146
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
|
+
}
|
|
147
219
|
if (quote) {
|
|
148
220
|
current += ch;
|
|
149
221
|
if (ch === quote) quote = null;
|
|
@@ -155,23 +227,88 @@ function splitShellSegments(command: string): string[] {
|
|
|
155
227
|
continue;
|
|
156
228
|
}
|
|
157
229
|
if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|
|
161
244
|
continue;
|
|
162
245
|
}
|
|
163
246
|
current += ch;
|
|
164
247
|
}
|
|
165
248
|
|
|
166
|
-
|
|
167
|
-
return segments;
|
|
249
|
+
flush(null);
|
|
250
|
+
return { segments, isolation, depths, groups, piped: pipedFlags, shortCircuit: shortCircuitFlags, unterminated: substitutionDepth > 0 || inBacktick };
|
|
168
251
|
}
|
|
169
252
|
|
|
170
|
-
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 } {
|
|
171
305
|
const words: string[] = [];
|
|
172
306
|
let current = "";
|
|
173
307
|
let quote: "'" | '"' | null = null;
|
|
174
308
|
let escaped = false;
|
|
309
|
+
let substitutionDepth = 0;
|
|
310
|
+
let substitutionQuote: "'" | '"' | null = null;
|
|
311
|
+
let inBacktick = false;
|
|
175
312
|
|
|
176
313
|
const push = () => {
|
|
177
314
|
if (current.length > 0) {
|
|
@@ -183,14 +320,48 @@ function shellWords(segment: string): string[] {
|
|
|
183
320
|
for (let i = 0; i < segment.length; i += 1) {
|
|
184
321
|
const ch = segment[i];
|
|
185
322
|
if (escaped) {
|
|
186
|
-
|
|
323
|
+
// A backslash before a glob metacharacter is part of the pattern, not shell quoting.
|
|
324
|
+
current += /[[\]*?]/.test(ch) ? `\\${ch}` : ch;
|
|
187
325
|
escaped = false;
|
|
188
326
|
continue;
|
|
189
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
|
+
}
|
|
190
350
|
if (ch === "\\" && quote !== "'") {
|
|
191
351
|
escaped = true;
|
|
192
352
|
continue;
|
|
193
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
|
+
}
|
|
194
365
|
if (quote) {
|
|
195
366
|
if (ch === quote) {
|
|
196
367
|
quote = null;
|
|
@@ -210,13 +381,22 @@ function shellWords(segment: string): string[] {
|
|
|
210
381
|
current += ch;
|
|
211
382
|
}
|
|
212
383
|
push();
|
|
213
|
-
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;
|
|
214
391
|
}
|
|
215
392
|
|
|
216
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.
|
|
217
397
|
const home = process.env.HOME || homedir();
|
|
218
|
-
if (path === "~") return
|
|
219
|
-
if (path.startsWith("~/")) return join(
|
|
398
|
+
if (path === "~") return home;
|
|
399
|
+
if (path.startsWith("~/")) return join(home, path.slice(2));
|
|
220
400
|
if (path === "$HOME" || path === "${HOME}") return home;
|
|
221
401
|
if (path.startsWith("$HOME/")) return join(home, path.slice("$HOME/".length));
|
|
222
402
|
if (path.startsWith("${HOME}/")) return join(home, path.slice("${HOME}/".length));
|
|
@@ -410,6 +590,59 @@ function activeRootsFor(input: CodewithHookInput, cwd: string): string[] {
|
|
|
410
590
|
return uniqueResolved(candidates, cwd);
|
|
411
591
|
}
|
|
412
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
|
+
|
|
413
646
|
function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedPathRule | null {
|
|
414
647
|
const rel = relative(resolve(workspaceRoot), resolve(target));
|
|
415
648
|
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return null;
|
|
@@ -427,6 +660,10 @@ function hasnaDivisionRuleFor(target: string, workspaceRoot: string): ProtectedP
|
|
|
427
660
|
async function protectedPathContextFor(input: CodewithHookInput, cwd: string): Promise<ProtectedPathContext> {
|
|
428
661
|
const home = process.env.HOME || homedir();
|
|
429
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),
|
|
430
667
|
{ root: join(home, ".hasna"), label: "Hasna state root ~/.hasna", mode: "tree" },
|
|
431
668
|
];
|
|
432
669
|
const workspaceRoots = workspaceRootsFor(input, cwd);
|
|
@@ -471,11 +708,794 @@ function mutatesProtectedPath(targetPath: string, rule: ProtectedPathRule): bool
|
|
|
471
708
|
return target === root;
|
|
472
709
|
}
|
|
473
710
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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 };
|
|
479
1499
|
}
|
|
480
1500
|
|
|
481
1501
|
function shouldSkipHasnaTreeRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
@@ -516,12 +1536,41 @@ function hasUnsafeTargetComponent(worktreesRoot: string, target: string): boolea
|
|
|
516
1536
|
return false;
|
|
517
1537
|
}
|
|
518
1538
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1539
|
+
/**
|
|
1540
|
+
* Candidate worktree roots for an absolute target, canonical shape first.
|
|
1541
|
+
*
|
|
1542
|
+
* The canonical root sits at `<worktrees-root>/<repo-name>/<worktree-name>`
|
|
1543
|
+
* (CANONICAL_WORKTREE_SEGMENTS). The deprecated station-id lease layout sits one
|
|
1544
|
+
* level deeper. Order matters: a canonical worktree that happens to contain a
|
|
1545
|
+
* subdirectory must resolve to the canonical root, never to the subdirectory.
|
|
1546
|
+
*/
|
|
1547
|
+
function managedWorktreeRootCandidates(worktreesRoot: string, target: string): string[] {
|
|
1548
|
+
const parts = relative(worktreesRoot, target).split(sep).filter(Boolean);
|
|
1549
|
+
const depths = [CANONICAL_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS];
|
|
1550
|
+
return depths
|
|
1551
|
+
.filter((depth) => parts.length >= depth)
|
|
1552
|
+
.map((depth) => resolve(worktreesRoot, ...parts.slice(0, depth)));
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* Worktree roots that could own `target`, for the scoped dangerous-operation
|
|
1557
|
+
* carve-out only.
|
|
1558
|
+
*
|
|
1559
|
+
* This is a structural lookup ("could a real managed worktree own this path?"),
|
|
1560
|
+
* not a policy check ("is this path canonical?"). It therefore keeps the
|
|
1561
|
+
* deprecated station-id lease layout as a candidate, so that worktrees created
|
|
1562
|
+
* before rule 8 keep their `~/.hasna` write carve-out during migration. Policy
|
|
1563
|
+
* enforcement lives in managedWorktreeInfo() / worktree-guard.
|
|
1564
|
+
*
|
|
1565
|
+
* Both depths are returned when both are plausible, because path shape alone
|
|
1566
|
+
* cannot tell a canonical root from a legacy lease container. Each candidate is
|
|
1567
|
+
* still verified against Git provenance by the caller, which fails closed.
|
|
1568
|
+
*/
|
|
1569
|
+
function managedLeaseRootCandidates(worktreesRoot: string, target: string): string[] {
|
|
1570
|
+
return managedWorktreeRootCandidates(worktreesRoot, target).filter((candidate) => {
|
|
1571
|
+
const info = managedWorktreeInfo(candidate);
|
|
1572
|
+
return info.managed || info.layout === "legacy-station-lease";
|
|
1573
|
+
});
|
|
525
1574
|
}
|
|
526
1575
|
|
|
527
1576
|
async function verifiedLinkedWorktreeRoot(leaseRoot: string): Promise<string | null> {
|
|
@@ -587,8 +1636,19 @@ async function managedRepoRootForAbsoluteTarget(
|
|
|
587
1636
|
return null;
|
|
588
1637
|
}
|
|
589
1638
|
|
|
590
|
-
const leaseRoot
|
|
591
|
-
|
|
1639
|
+
for (const leaseRoot of managedLeaseRootCandidates(worktreesRoot, target)) {
|
|
1640
|
+
const repoRoot = await verifiedManagedRepoRoot(leaseRoot, target, physicalWorktreesRoot, repoRootCache);
|
|
1641
|
+
if (repoRoot) return repoRoot;
|
|
1642
|
+
}
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
async function verifiedManagedRepoRoot(
|
|
1647
|
+
leaseRoot: string,
|
|
1648
|
+
target: string,
|
|
1649
|
+
physicalWorktreesRoot: string,
|
|
1650
|
+
repoRootCache: Map<string, Promise<string | null>>,
|
|
1651
|
+
): Promise<string | null> {
|
|
592
1652
|
let repoRootPromise = repoRootCache.get(leaseRoot);
|
|
593
1653
|
if (!repoRootPromise) {
|
|
594
1654
|
repoRootPromise = verifiedLinkedWorktreeRoot(leaseRoot);
|
|
@@ -629,8 +1689,7 @@ async function managedRepoRootForAbsoluteTarget(
|
|
|
629
1689
|
|
|
630
1690
|
function threatensRule(targetPath: string, rule: ProtectedPathRule, currentManagedRepoRoot: string | null): boolean {
|
|
631
1691
|
if (shouldSkipHasnaTreeRule(targetPath, rule, currentManagedRepoRoot)) return false;
|
|
632
|
-
|
|
633
|
-
if (contentBase && mutatesProtectedPath(contentBase, rule)) return true;
|
|
1692
|
+
if (pathHasGlob(targetPath)) return globThreatensRule(targetPath, rule);
|
|
634
1693
|
return threatensProtectedPath(targetPath, rule);
|
|
635
1694
|
}
|
|
636
1695
|
|
|
@@ -642,6 +1701,31 @@ function mutatesRule(targetPath: string, rule: ProtectedPathRule, currentManaged
|
|
|
642
1701
|
interface DestructiveShellTarget {
|
|
643
1702
|
path: string;
|
|
644
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 };
|
|
645
1729
|
}
|
|
646
1730
|
|
|
647
1731
|
function rmCommandTargets(command: string): DestructiveShellTarget[] {
|
|
@@ -676,7 +1760,7 @@ function rmCommandTargets(command: string): DestructiveShellTarget[] {
|
|
|
676
1760
|
}
|
|
677
1761
|
|
|
678
1762
|
if (recursive) {
|
|
679
|
-
targets.push(...segmentTargets.map((path) => (
|
|
1763
|
+
targets.push(...segmentTargets.map((path) => destructiveTarget(path, force ? "rm -rf" : "rm -r")));
|
|
680
1764
|
}
|
|
681
1765
|
}
|
|
682
1766
|
return targets;
|
|
@@ -737,7 +1821,7 @@ function rsyncDeleteTargets(command: string): DestructiveShellTarget[] {
|
|
|
737
1821
|
}
|
|
738
1822
|
|
|
739
1823
|
if (hasDelete && operands.length > 0) {
|
|
740
|
-
targets.push(
|
|
1824
|
+
targets.push(destructiveTarget(operands[operands.length - 1], "rsync --delete"));
|
|
741
1825
|
}
|
|
742
1826
|
}
|
|
743
1827
|
return targets;
|
|
@@ -775,10 +1859,10 @@ function findDestructiveTargets(command: string): DestructiveShellTarget[] {
|
|
|
775
1859
|
}
|
|
776
1860
|
|
|
777
1861
|
if (hasDelete || hasExecRm) {
|
|
778
|
-
targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => (
|
|
1862
|
+
targets.push(...(roots.length > 0 ? roots : ["."]).map((path) => destructiveTarget(
|
|
779
1863
|
path,
|
|
780
|
-
|
|
781
|
-
|
|
1864
|
+
hasDelete ? "find -delete" : "find -exec rm"
|
|
1865
|
+
)));
|
|
782
1866
|
}
|
|
783
1867
|
}
|
|
784
1868
|
return targets;
|
|
@@ -905,13 +1989,477 @@ function gitDestructiveTargets(command: string, baseCwd: string): DestructiveShe
|
|
|
905
1989
|
return targets;
|
|
906
1990
|
}
|
|
907
1991
|
|
|
908
|
-
|
|
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;
|
|
909
2193
|
return [
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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 },
|
|
914
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;
|
|
915
2463
|
}
|
|
916
2464
|
|
|
917
2465
|
function isApplyPatchTool(toolName: string): boolean {
|
|
@@ -958,11 +2506,45 @@ function extractFileToolPaths(input: CodewithHookInput): Array<{ path: string; o
|
|
|
958
2506
|
return paths;
|
|
959
2507
|
}
|
|
960
2508
|
|
|
961
|
-
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
|
+
: [];
|
|
962
2521
|
return [
|
|
963
|
-
`Blocked scoped dangerous operation: ${operation} targets ${targetPath}.`,
|
|
2522
|
+
`Blocked scoped dangerous operation: ${operation} targets ${targetPath}${remote ? " on a remote host" : ""}.`,
|
|
964
2523
|
`Protected scope: ${rule.label} (${rule.root}).`,
|
|
2524
|
+
...unpinnable,
|
|
965
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.",
|
|
966
2548
|
].join(" ");
|
|
967
2549
|
}
|
|
968
2550
|
|
|
@@ -972,11 +2554,37 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
972
2554
|
const { rules, workspaceRoots, currentManagedRepoRoot } = await protectedPathContextFor(input, cwd);
|
|
973
2555
|
|
|
974
2556
|
if (input.tool_name === "Bash") {
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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)) {
|
|
980
2588
|
if (threatensRule(targetPath, rule, currentManagedRepoRoot)) {
|
|
981
2589
|
return {
|
|
982
2590
|
block: true,
|
|
@@ -984,11 +2592,39 @@ export async function classifyDangerousOperation(input: CodewithHookInput): Prom
|
|
|
984
2592
|
protectedPath: rule.root,
|
|
985
2593
|
protectedLabel: rule.label,
|
|
986
2594
|
operation: target.operation,
|
|
987
|
-
reason: scopedBlockReason(target.operation, targetPath, rule),
|
|
2595
|
+
reason: scopedBlockReason(target.operation, targetPath, rule, target.remote),
|
|
2596
|
+
};
|
|
2597
|
+
}
|
|
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),
|
|
988
2614
|
};
|
|
989
2615
|
}
|
|
990
2616
|
}
|
|
991
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
|
+
}
|
|
2627
|
+
}
|
|
992
2628
|
}
|
|
993
2629
|
|
|
994
2630
|
const managedRepoRootCache = new Map<string, Promise<string | null>>();
|
|
@@ -1078,7 +2714,9 @@ export function isTopLevelSession(input: CodewithHookInput): boolean {
|
|
|
1078
2714
|
}
|
|
1079
2715
|
|
|
1080
2716
|
export function defaultWorktreesRoot(): string {
|
|
1081
|
-
|
|
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");
|
|
1082
2720
|
}
|
|
1083
2721
|
|
|
1084
2722
|
export function isInsidePath(child: string, parent: string): boolean {
|
|
@@ -1086,22 +2724,265 @@ export function isInsidePath(child: string, parent: string): boolean {
|
|
|
1086
2724
|
return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
1087
2725
|
}
|
|
1088
2726
|
|
|
1089
|
-
|
|
2727
|
+
/**
|
|
2728
|
+
* Canonical managed-worktree path shape.
|
|
2729
|
+
*
|
|
2730
|
+
* Source of truth: Hasna Agent Operating Rules rule 8, as published by the
|
|
2731
|
+
* @hasna/identities 0.4.4 global agent rules, verbatim:
|
|
2732
|
+
*
|
|
2733
|
+
* "must happen in a task-specific worktree at
|
|
2734
|
+
* $HOME/.hasna/repos/worktrees/<repo-name>/<worktree-name>
|
|
2735
|
+
* (repo name then worktree name; no station-id or machine segment,
|
|
2736
|
+
* never flat under the worktrees root)"
|
|
2737
|
+
*
|
|
2738
|
+
* So, relative to the worktrees root, a compliant worktree root is exactly two
|
|
2739
|
+
* segments deep: <repo-name>/<worktree-name>.
|
|
2740
|
+
*/
|
|
2741
|
+
export const CANONICAL_WORKTREE_SEGMENTS = 2;
|
|
2742
|
+
|
|
2743
|
+
/**
|
|
2744
|
+
* Depth of the DEPRECATED station-id lease layout
|
|
2745
|
+
* (`<station-id>/<repo-slug>-<hex>/wt_<hex>`) that predates rule 8.
|
|
2746
|
+
*
|
|
2747
|
+
* Read-only migration tolerance: it is never a compliant target shape, and it is
|
|
2748
|
+
* never reported as `managed`. It is recognised only so that (a) guard messages
|
|
2749
|
+
* can name it precisely and (b) the scoped dangerous-operation carve-out keeps
|
|
2750
|
+
* working for worktrees created before the canonical shape was mandated.
|
|
2751
|
+
*/
|
|
2752
|
+
export const LEGACY_LEASE_WORKTREE_SEGMENTS = 3;
|
|
2753
|
+
|
|
2754
|
+
// Any ordinary directory name, bounded by the filesystem's own limit rather than an
|
|
2755
|
+
// allowlist — repo and worktree names are user data, and an over-narrow pattern would
|
|
2756
|
+
// reject legitimate work (real fleet names include `_base`). Refused: a leading `.`,
|
|
2757
|
+
// so `.`, `..` and `.git` can never be read as a segment; a leading `-`, so a segment
|
|
2758
|
+
// can never read as an option in the remediation command; and control characters.
|
|
2759
|
+
const WORKTREE_SEGMENT_PATTERN = /^[^.\-\/\x00-\x1f][^\/\x00-\x1f]{0,254}$/;
|
|
2760
|
+
const LEGACY_LEASE_REPO_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/;
|
|
2761
|
+
const LEGACY_LEASE_ID_PATTERN = /^wt_[0-9a-fA-F]{16,64}$/;
|
|
2762
|
+
|
|
2763
|
+
/**
|
|
2764
|
+
* Whether the deprecated station-id lease layout still gets its migration tolerance.
|
|
2765
|
+
*
|
|
2766
|
+
* Default on, so the change does not strand worktrees created before rule 8. It is a
|
|
2767
|
+
* kill switch, not a policy knob: the layout is non-compliant either way, and the
|
|
2768
|
+
* tolerance only softens the verdict from blocked to warned. Set
|
|
2769
|
+
* `HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE=0` once those worktrees are re-homed; the
|
|
2770
|
+
* whole branch goes away after that.
|
|
2771
|
+
*
|
|
2772
|
+
* Known limitation while it is on: the tolerance keys off the path name, so a newly
|
|
2773
|
+
* created worktree deliberately named to match also gets the warn tier. That is an
|
|
2774
|
+
* opt-out from a guardrail by a cooperating agent, not a security boundary — the
|
|
2775
|
+
* boundary is the provenance proof above, which applies to both tiers.
|
|
2776
|
+
*/
|
|
2777
|
+
export function legacyWorktreeToleranceEnabled(): boolean {
|
|
2778
|
+
return process.env.HASNA_HOOKS_LEGACY_WORKTREE_TOLERANCE !== "0";
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
export type ManagedWorktreeLayout = "canonical" | "legacy-station-lease";
|
|
2782
|
+
|
|
2783
|
+
export interface ManagedWorktreeInfo {
|
|
2784
|
+
managed: boolean;
|
|
2785
|
+
/** The worktrees root the path was classified against. */
|
|
2786
|
+
root: string;
|
|
2787
|
+
/** Recognised layout, set for compliant and for deprecated-but-recognised paths. */
|
|
2788
|
+
layout?: ManagedWorktreeLayout;
|
|
2789
|
+
/** True when the layout is recognised but no longer permitted by rule 8. */
|
|
2790
|
+
deprecated?: boolean;
|
|
2791
|
+
repo?: string;
|
|
2792
|
+
worktree?: string;
|
|
2793
|
+
/** Absolute path of the worktree root that owns `cwd`. */
|
|
2794
|
+
worktreeRoot?: string;
|
|
2795
|
+
reason?: string;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
/** The canonical worktree path template, for user-facing guard messages. */
|
|
2799
|
+
export function canonicalWorktreeTemplate(root: string = defaultWorktreesRoot()): string {
|
|
2800
|
+
return join(root, "<repo-name>", "<worktree-name>");
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
/**
|
|
2804
|
+
* Prove that `worktreeRoot` owns its own git history, synchronously.
|
|
2805
|
+
*
|
|
2806
|
+
* Shape is not evidence and neither is the mere presence of `.git`. A `.git` file is
|
|
2807
|
+
* two lines of text: pointing it at a shared checkout's `.git` grafts a second working
|
|
2808
|
+
* tree onto that checkout, so `git commit`/`git push` from the forged directory lands
|
|
2809
|
+
* on the shared checkout — the exact outcome rule 10 forbids. So a `.git` file must
|
|
2810
|
+
* carry real linked-worktree provenance:
|
|
2811
|
+
*
|
|
2812
|
+
* - its `gitdir:` target must live under `<common-dir>/worktrees/`, and
|
|
2813
|
+
* - that target's `gitdir` back-pointer must resolve to this very control file.
|
|
2814
|
+
*
|
|
2815
|
+
* A `.git` directory is accepted only as a self-contained repository. A `commondir`
|
|
2816
|
+
* grafts it onto another repository's history outright, and symlinked `objects` or
|
|
2817
|
+
* `refs` graft it onto another repository's refs — reaching the same end state as a
|
|
2818
|
+
* forged `.git` file without writing anything inside the victim.
|
|
2819
|
+
*
|
|
2820
|
+
* This is a structural proof only. It is deliberately close to, but not the same as,
|
|
2821
|
+
* the async verifiedLinkedWorktreeRoot() used for the write carve-out, which is
|
|
2822
|
+
* stricter still (regular-file control file, nlink === 1, worktrees dir directly
|
|
2823
|
+
* under the common dir).
|
|
2824
|
+
*/
|
|
2825
|
+
function worktreeProvenanceReason(worktreeRoot: string): string | null {
|
|
2826
|
+
const controlPath = join(worktreeRoot, ".git");
|
|
2827
|
+
let control;
|
|
2828
|
+
try {
|
|
2829
|
+
control = lstatSync(controlPath);
|
|
2830
|
+
} catch {
|
|
2831
|
+
return `${worktreeRoot} is not a git worktree root (no .git)`;
|
|
2832
|
+
}
|
|
2833
|
+
if (control.isSymbolicLink()) return `worktree .git is a symlink at ${worktreeRoot}`;
|
|
2834
|
+
|
|
2835
|
+
if (control.isDirectory()) {
|
|
2836
|
+
if (existsSync(join(controlPath, "commondir"))) {
|
|
2837
|
+
return `worktree .git is grafted onto another repository at ${worktreeRoot}`;
|
|
2838
|
+
}
|
|
2839
|
+
if (!existsSync(join(controlPath, "HEAD"))) return `worktree .git is not a repository at ${worktreeRoot}`;
|
|
2840
|
+
// A self-contained repository owns its object and ref storage. Symlinking either
|
|
2841
|
+
// into another repository makes commits here land on that repository's refs.
|
|
2842
|
+
for (const store of ["objects", "refs"]) {
|
|
2843
|
+
let metadata;
|
|
2844
|
+
try {
|
|
2845
|
+
metadata = lstatSync(join(controlPath, store));
|
|
2846
|
+
} catch {
|
|
2847
|
+
return `worktree .git is missing ${store} at ${worktreeRoot}`;
|
|
2848
|
+
}
|
|
2849
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
2850
|
+
return `worktree .git ${store} is grafted onto another repository at ${worktreeRoot}`;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
return null;
|
|
2854
|
+
}
|
|
2855
|
+
if (!control.isFile()) return `worktree .git is not a file or directory at ${worktreeRoot}`;
|
|
2856
|
+
|
|
2857
|
+
try {
|
|
2858
|
+
const pointer = readFileSync(controlPath, "utf-8").trim();
|
|
2859
|
+
const match = pointer.match(/^gitdir:\s*(.+)$/);
|
|
2860
|
+
if (!match?.[1]) return `worktree .git is not a git worktree pointer at ${worktreeRoot}`;
|
|
2861
|
+
const gitDir = resolveFrom(worktreeRoot, match[1].trim());
|
|
2862
|
+
const commonDir = resolveFrom(gitDir, readFileSync(join(gitDir, "commondir"), "utf-8").trim());
|
|
2863
|
+
const physicalGitDir = realpathSync(gitDir);
|
|
2864
|
+
const physicalWorktreesDir = realpathSync(join(commonDir, "worktrees"));
|
|
2865
|
+
if (physicalGitDir === physicalWorktreesDir || !isInsidePath(physicalGitDir, physicalWorktreesDir)) {
|
|
2866
|
+
return `worktree .git points outside its repository's worktrees directory at ${worktreeRoot}`;
|
|
2867
|
+
}
|
|
2868
|
+
const backPointer = resolveFrom(gitDir, readFileSync(join(gitDir, "gitdir"), "utf-8").trim());
|
|
2869
|
+
if (realpathSync(backPointer) !== realpathSync(controlPath)) {
|
|
2870
|
+
return `worktree .git is not registered by its repository at ${worktreeRoot}`;
|
|
2871
|
+
}
|
|
2872
|
+
} catch {
|
|
2873
|
+
return `worktree .git provenance could not be verified at ${worktreeRoot}`;
|
|
2874
|
+
}
|
|
2875
|
+
return null;
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
/**
|
|
2879
|
+
* Verify that `<root>/<repo>/<worktree>` is a real, non-symlinked, provenance-checked
|
|
2880
|
+
* git worktree root.
|
|
2881
|
+
*
|
|
2882
|
+
* Path shape alone is not evidence: `<root>/<flat-worktree>/<subdir>` has exactly the
|
|
2883
|
+
* same shape as `<root>/<repo>/<worktree>`, so without this check a `cd` into any
|
|
2884
|
+
* subdirectory of a flat worktree would launder it into a compliant-looking path.
|
|
2885
|
+
* Symlinks are refused at every level (hence lstat, not existsSync, which follows
|
|
2886
|
+
* them) because a symlinked segment can aim a canonical-looking path at a shared
|
|
2887
|
+
* checkout.
|
|
2888
|
+
*/
|
|
2889
|
+
function groundedWorktreeRootReason(root: string, segments: string[]): string | null {
|
|
2890
|
+
let probe = resolve(root);
|
|
2891
|
+
for (const segment of segments) {
|
|
2892
|
+
probe = join(probe, segment);
|
|
2893
|
+
let metadata;
|
|
2894
|
+
try {
|
|
2895
|
+
metadata = lstatSync(probe);
|
|
2896
|
+
} catch {
|
|
2897
|
+
return `no worktree exists at ${probe}`;
|
|
2898
|
+
}
|
|
2899
|
+
if (metadata.isSymbolicLink()) return `worktree path traverses a symlink at ${probe}`;
|
|
2900
|
+
if (!metadata.isDirectory()) return `worktree path is not a directory at ${probe}`;
|
|
2901
|
+
}
|
|
2902
|
+
return worktreeProvenanceReason(probe);
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* Classify a path against the canonical managed-worktree shape (rule 8).
|
|
2907
|
+
*
|
|
2908
|
+
* Accepted: a real git worktree root at `<worktrees-root>/<repo-name>/<worktree-name>`,
|
|
2909
|
+
* and any path inside it. Rejected, each with a reason: paths outside the worktrees
|
|
2910
|
+
* root, the root itself, flat single-segment worktrees, station-id/machine segments,
|
|
2911
|
+
* deeper nesting, and canonical-shaped paths that are not actually a worktree root
|
|
2912
|
+
* (invented, symlinked, or a subdirectory of a flat worktree).
|
|
2913
|
+
*/
|
|
2914
|
+
export function managedWorktreeInfo(cwd: string): ManagedWorktreeInfo {
|
|
1090
2915
|
const root = defaultWorktreesRoot();
|
|
2916
|
+
const canonical = canonicalWorktreeTemplate(root);
|
|
1091
2917
|
if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" };
|
|
1092
|
-
|
|
1093
|
-
const parts =
|
|
1094
|
-
if (parts.length
|
|
1095
|
-
|
|
1096
|
-
if (!machine || !repoSlugHash || !lease) return { managed: false, root, reason: "missing machine/repo/lease path segments" };
|
|
1097
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return { managed: false, root, reason: "machine segment is malformed" };
|
|
1098
|
-
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) {
|
|
1099
|
-
return { managed: false, root, reason: "repo segment must end in a hex hash suffix" };
|
|
2918
|
+
|
|
2919
|
+
const parts = relative(resolve(root), resolve(cwd)).split(sep).filter(Boolean);
|
|
2920
|
+
if (parts.length === 0) {
|
|
2921
|
+
return { managed: false, root, reason: `path is the worktrees root itself; canonical worktrees live at ${canonical}` };
|
|
1100
2922
|
}
|
|
1101
|
-
if (
|
|
1102
|
-
return {
|
|
2923
|
+
if (parts.length < CANONICAL_WORKTREE_SEGMENTS) {
|
|
2924
|
+
return {
|
|
2925
|
+
managed: false,
|
|
2926
|
+
root,
|
|
2927
|
+
reason: `worktree is flat under the worktrees root, which rule 8 forbids; canonical shape is ${canonical}`,
|
|
2928
|
+
};
|
|
1103
2929
|
}
|
|
1104
|
-
|
|
2930
|
+
|
|
2931
|
+
const [repo, worktree] = parts;
|
|
2932
|
+
for (const [label, segment] of [["repo-name", repo], ["worktree-name", worktree]] as const) {
|
|
2933
|
+
if (!segment || !WORKTREE_SEGMENT_PATTERN.test(segment)) {
|
|
2934
|
+
return { managed: false, root, reason: `${label} segment is malformed; canonical shape is ${canonical}` };
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// A canonical classification must be grounded in a real worktree root at depth 2,
|
|
2939
|
+
// never in path shape alone: at depth 2 the shape is ambiguous with a subdirectory
|
|
2940
|
+
// of a forbidden flat worktree, and at any depth it is ambiguous with an invented
|
|
2941
|
+
// or symlinked path.
|
|
2942
|
+
const worktreeRoot = resolve(root, repo!, worktree!);
|
|
2943
|
+
const rootReason = groundedWorktreeRootReason(root, [repo!, worktree!]);
|
|
2944
|
+
if (!rootReason) {
|
|
2945
|
+
return { managed: true, root, layout: "canonical", repo, worktree, worktreeRoot };
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
if (parts.length === CANONICAL_WORKTREE_SEGMENTS) {
|
|
2949
|
+
return { managed: false, root, reason: `${rootReason}; canonical shape is ${canonical}` };
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
// Recognised at or inside a legacy lease root, mirroring how a canonical worktree
|
|
2953
|
+
// covers its own subdirectories — an agent cwd'd into `src/` of a legacy worktree
|
|
2954
|
+
// is in the same non-compliant worktree, and must get the same migration message.
|
|
2955
|
+
//
|
|
2956
|
+
// The migration tolerance grants a weaker verdict than "blocked", so it has to clear
|
|
2957
|
+
// the same grounding as the canonical branch. Otherwise the lease name pattern is a
|
|
2958
|
+
// forgery kit: two directories named to match would launder a symlinked or grafted
|
|
2959
|
+
// path into a warn-and-allow.
|
|
2960
|
+
if (legacyWorktreeToleranceEnabled()
|
|
2961
|
+
&& parts.length >= LEGACY_LEASE_WORKTREE_SEGMENTS
|
|
2962
|
+
&& LEGACY_LEASE_REPO_PATTERN.test(parts[1]!)
|
|
2963
|
+
&& LEGACY_LEASE_ID_PATTERN.test(parts[2]!)) {
|
|
2964
|
+
// The layout has two historical variants: the checkout sits at the lease dir, or
|
|
2965
|
+
// one level below it in a `repo/` child. Try both, nothing deeper.
|
|
2966
|
+
for (const depth of [LEGACY_LEASE_WORKTREE_SEGMENTS, LEGACY_LEASE_WORKTREE_SEGMENTS + 1]) {
|
|
2967
|
+
if (parts.length < depth) break;
|
|
2968
|
+
const segments = parts.slice(0, depth);
|
|
2969
|
+
if (groundedWorktreeRootReason(root, segments)) continue;
|
|
2970
|
+
return {
|
|
2971
|
+
managed: false,
|
|
2972
|
+
root,
|
|
2973
|
+
layout: "legacy-station-lease",
|
|
2974
|
+
deprecated: true,
|
|
2975
|
+
worktreeRoot: resolve(root, ...segments),
|
|
2976
|
+
reason: `deprecated station-id lease layout <station-id>/<repo-slug>-<hex>/wt_<hex>; rule 8 forbids a station-id or machine segment — re-home to ${canonical}`,
|
|
2977
|
+
};
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
return {
|
|
2982
|
+
managed: false,
|
|
2983
|
+
root,
|
|
2984
|
+
reason: `worktree root is ${parts.length} segments under the worktrees root (station-id/machine segment or extra nesting); rule 8 requires the worktree to be created at exactly ${canonical}`,
|
|
2985
|
+
};
|
|
1105
2986
|
}
|
|
1106
2987
|
|
|
1107
2988
|
export async function gitRepoRoot(cwd: string): Promise<string | null> {
|
|
@@ -1121,8 +3002,105 @@ export async function gitRemoteSlug(cwd: string): Promise<string | null> {
|
|
|
1121
3002
|
return match?.[1] || null;
|
|
1122
3003
|
}
|
|
1123
3004
|
|
|
1124
|
-
|
|
1125
|
-
|
|
3005
|
+
/** `origin` normalised to the `host/org/name` form the repos CLI resolves exactly. */
|
|
3006
|
+
export async function gitRemoteHostSlug(cwd: string): Promise<string | null> {
|
|
3007
|
+
if (!commandExists("git")) return null;
|
|
3008
|
+
const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 });
|
|
3009
|
+
if (result.exitCode !== 0) return null;
|
|
3010
|
+
const remote = result.stdout.trim().replace(/\.git$/, "");
|
|
3011
|
+
if (!remote) return null;
|
|
3012
|
+
const match = remote.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:\s]+)[:/](.+)$/i);
|
|
3013
|
+
const host = match?.[1];
|
|
3014
|
+
const path = match?.[2]?.replace(/^\/+/, "");
|
|
3015
|
+
if (!host || !path || !/^[^/\s]+\/[^/\s]+$/.test(path)) return null;
|
|
3016
|
+
return `${host}/${path}`;
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
export interface CanonicalRepoIdentity {
|
|
3020
|
+
/** The repo name that forms the `<repo-name>` segment of the canonical path. */
|
|
3021
|
+
name: string | null;
|
|
3022
|
+
defaultBranch: string | null;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
/**
|
|
3026
|
+
* Resolve the canonical repo name via the repos CLI, as rule 8 requires:
|
|
3027
|
+
* "Locate repos with the repos CLI (`repos repo <name> --json` for the exact
|
|
3028
|
+
* lookup; never fuzzy `repos cd` or 'did you mean' output for targeting)".
|
|
3029
|
+
*
|
|
3030
|
+
* This matters because the repos-CLI name is frequently NOT the git remote
|
|
3031
|
+
* basename — on this fleet 46 of 50 indexed repos differ (`open-hooks` is
|
|
3032
|
+
* `github.com/hasna/hooks`, `open-mailery` is `.../emails`). Deriving the
|
|
3033
|
+
* canonical path segment from the remote would send every agent to the wrong
|
|
3034
|
+
* directory, so the remote is only ever used as the exact lookup key.
|
|
3035
|
+
*
|
|
3036
|
+
* `--remote host/org/name` is the exact-match form, so no fuzzy "did you mean"
|
|
3037
|
+
* output can be mistaken for a hit. OSS-safe: a missing or failing repos CLI
|
|
3038
|
+
* yields nulls and the caller falls back to local information.
|
|
3039
|
+
*/
|
|
3040
|
+
export async function canonicalRepoIdentity(cwd: string): Promise<CanonicalRepoIdentity> {
|
|
3041
|
+
const empty: CanonicalRepoIdentity = { name: null, defaultBranch: null };
|
|
3042
|
+
if (!commandExists("repos")) return empty;
|
|
3043
|
+
const remote = await gitRemoteHostSlug(cwd);
|
|
3044
|
+
if (!remote) return empty;
|
|
3045
|
+
// Hard ceiling on the lookup. runCommand's timeout kills the direct child but still
|
|
3046
|
+
// awaits its pipes, which a forking CLI can hold open indefinitely; this hook sits on
|
|
3047
|
+
// the PreToolUse path, so it must degrade to local information rather than stall.
|
|
3048
|
+
const result = await Promise.race([
|
|
3049
|
+
runCommand(["repos", "repo", "--remote", remote, "--json"], { cwd, timeoutMs: 1000 }),
|
|
3050
|
+
new Promise<null>((done) => setTimeout(() => done(null), 1500).unref?.()),
|
|
3051
|
+
]);
|
|
3052
|
+
if (!result || result.exitCode !== 0) return empty;
|
|
3053
|
+
try {
|
|
3054
|
+
const parsed = JSON.parse(result.stdout) as { name?: unknown; default_branch?: unknown; path?: unknown };
|
|
3055
|
+
const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null;
|
|
3056
|
+
const defaultBranch = typeof parsed.default_branch === "string" && parsed.default_branch
|
|
3057
|
+
? parsed.default_branch
|
|
3058
|
+
: null;
|
|
3059
|
+
|
|
3060
|
+
// The index holds worktree directories as first-class rows, so an exact remote
|
|
3061
|
+
// match can resolve to a worktree rather than the repo. Such a row's name is a
|
|
3062
|
+
// worktree name and its default_branch is that worktree's branch — both wrong for
|
|
3063
|
+
// the canonical path. When the row lives under the worktrees root, the real repo
|
|
3064
|
+
// name is its first segment there; the branch is not recoverable, so drop it.
|
|
3065
|
+
const worktreesRoot = resolve(defaultWorktreesRoot());
|
|
3066
|
+
const rowPath = typeof parsed.path === "string" && parsed.path ? resolve(parsed.path) : null;
|
|
3067
|
+
if (rowPath && isInsidePath(rowPath, worktreesRoot) && rowPath !== worktreesRoot) {
|
|
3068
|
+
const segment = relative(worktreesRoot, rowPath).split(sep).filter(Boolean)[0];
|
|
3069
|
+
return { name: segment || null, defaultBranch: null };
|
|
3070
|
+
}
|
|
3071
|
+
return { name, defaultBranch };
|
|
3072
|
+
} catch {
|
|
3073
|
+
return empty;
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
/**
|
|
3078
|
+
* Remediation command for work happening outside a canonical worktree.
|
|
3079
|
+
*
|
|
3080
|
+
* Rule 8: create the worktree at `<worktrees-root>/<repo-name>/<worktree-name>`,
|
|
3081
|
+
* named after the todos task where one exists, then `repos scan`. The repos CLI
|
|
3082
|
+
* has no worktree verb, so `git worktree` is the creation path.
|
|
3083
|
+
*
|
|
3084
|
+
* `repo` must be a canonical repo name (see canonicalRepoIdentity) — never a
|
|
3085
|
+
* remote slug, which names a different directory for most repos.
|
|
3086
|
+
*
|
|
3087
|
+
* This is the boundary where names become a command an operator may paste, so every
|
|
3088
|
+
* interpolated value is validated here rather than trusted from its source: a repo
|
|
3089
|
+
* name is attacker-influenced via the remote, and a task id is unvalidated hook input.
|
|
3090
|
+
* Anything unsafe degrades to the explicit placeholder instead of being emitted.
|
|
3091
|
+
*/
|
|
3092
|
+
const SAFE_COMMAND_VALUE = /^[a-zA-Z0-9_][a-zA-Z0-9_.\/-]{0,120}$/;
|
|
3093
|
+
|
|
3094
|
+
export function claimCommand(repo: string | null, taskId: string | null, defaultBranch: string | null = null): string {
|
|
3095
|
+
// A repo name is one path segment: a slug would silently add a third segment.
|
|
3096
|
+
const safeRepo = repo && SAFE_COMMAND_VALUE.test(repo) && !repo.includes("/") ? repo : null;
|
|
3097
|
+
const safeTask = taskId && SAFE_COMMAND_VALUE.test(taskId) ? taskId : null;
|
|
3098
|
+
const safeBase = defaultBranch && SAFE_COMMAND_VALUE.test(defaultBranch) ? defaultBranch : null;
|
|
3099
|
+
|
|
3100
|
+
const repoName = safeRepo || "<repo-name>";
|
|
3101
|
+
const worktreeName = safeTask || "<worktree-name>";
|
|
3102
|
+
const path = join(defaultWorktreesRoot(), repoName, worktreeName);
|
|
3103
|
+
return `git worktree add -b ${worktreeName} ${path} origin/${safeBase || "<default-branch>"} && repos scan`;
|
|
1126
3104
|
}
|
|
1127
3105
|
|
|
1128
3106
|
export function taskIdFrom(input: CodewithHookInput): string | null {
|