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