@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.
@@ -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(`${JSON.stringify(output)}\n`);
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
- 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 } {
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
- if (current.trim()) segments.push(current.trim());
159
- current = "";
160
- 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;
161
244
  continue;
162
245
  }
163
246
  current += ch;
164
247
  }
165
248
 
166
- if (current.trim()) segments.push(current.trim());
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 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 } {
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
- current += ch;
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 homedir();
219
- if (path.startsWith("~/")) return join(homedir(), path.slice(2));
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
- function broadContentWipeBase(targetPath: string): string | null {
475
- const target = resolve(targetPath);
476
- const last = basename(target);
477
- if (!/[*?\[]/.test(last)) return null;
478
- 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 };
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
- function managedLeaseRoot(worktreesRoot: string, target: string): string | null {
520
- const relativeTarget = relative(worktreesRoot, target);
521
- const parts = relativeTarget.split(sep).filter(Boolean);
522
- if (parts.length < 3) return null;
523
- const leaseRoot = resolve(worktreesRoot, ...parts.slice(0, 3));
524
- return managedWorktreeInfo(leaseRoot).managed ? leaseRoot : null;
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 = managedLeaseRoot(worktreesRoot, target);
591
- if (!leaseRoot) return null;
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
- const contentBase = broadContentWipeBase(targetPath);
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) => ({ path, operation: force ? "rm -rf" : "rm -r" })));
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({ path: operands[operands.length - 1], operation: "rsync --delete" });
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
- operation: hasDelete ? "find -delete" : "find -exec rm",
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
- 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;
909
2193
  return [
910
- ...rmCommandTargets(command),
911
- ...rsyncDeleteTargets(command),
912
- ...findDestructiveTargets(command),
913
- ...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 },
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
- for (const target of destructiveShellTargets(getCommand(input), cwd)) {
976
- const targetPath = resolveFrom(cwd, target.path);
977
- const extraRule = workspaceRoots.map((root) => hasnaDivisionRuleFor(targetPath, root)).find((rule): rule is ProtectedPathRule => Boolean(rule));
978
- const allRules = extraRule ? [...rules, extraRule] : rules;
979
- 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)) {
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
- 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");
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
- export function managedWorktreeInfo(cwd: string): { managed: boolean; root: string; reason?: string } {
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
- const rel = relative(resolve(root), resolve(cwd));
1093
- const parts = rel.split(sep).filter(Boolean);
1094
- if (parts.length < 3) return { managed: false, root, reason: "path is inside worktrees root but not deep enough" };
1095
- const [machine, repoSlugHash, lease] = parts;
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 (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) {
1102
- return { managed: false, root, reason: "lease segment must look like wt_<hex hash>" };
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
- return { managed: true, root };
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
- export function claimCommand(repo: string | null, taskId: string | null, runId: string | null): string {
1125
- return `repos worktrees claim --repo ${repo || "<repo>"} --task-id ${taskId || "<task-id>"} --run-id ${runId || "<run-id>"} --base main --mode required --json`;
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 {