opencode-workflow-guard 1.19.0 → 1.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/shell.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { homedir, userInfo } from "node:os";
2
+ import { dirname, join, resolve } from "node:path";
3
+
1
4
  function asRecord(value: unknown): Record<string, unknown> | undefined {
2
5
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
3
6
  return undefined;
@@ -193,6 +196,290 @@ function envSplitWords(value: string): string[] {
193
196
  return words;
194
197
  }
195
198
 
199
+ export interface HeredocRange {
200
+ /** Offset of the `<<` that introduces the heredoc. */
201
+ start: number;
202
+ /** Offset of the body's first byte. */
203
+ bodyStart: number;
204
+ /** Offset of the terminator line, or end of input when unterminated. */
205
+ bodyEnd: number;
206
+ /** Offset of the terminator line's newline, or end of input. */
207
+ end: number;
208
+ }
209
+
210
+ interface HeredocHeader {
211
+ start: number;
212
+ /** Offset just past the delimiter word. */
213
+ end: number;
214
+ delimiter: string;
215
+ stripTabs: boolean;
216
+ }
217
+
218
+ function heredocHeaderAt(command: string, start: number): HeredocHeader | undefined {
219
+ // `<<<` is a herestring, whose operand is a single word on the same line.
220
+ if (command[start + 2] === "<") return undefined;
221
+ const header = /^<<(-?)[ \t]*(?:'([^'\n]+)'|"([^"\n]+)"|([^\s'";|&()<>\n]+))/.exec(command.slice(start));
222
+ if (!header) return undefined;
223
+ const delimiter = header[2] ?? header[3] ?? header[4];
224
+ if (!delimiter) return undefined;
225
+ return { start, end: start + header[0].length, delimiter, stripTabs: header[1] === "-" };
226
+ }
227
+
228
+ function heredocBodyAt(
229
+ command: string,
230
+ from: number,
231
+ delimiter: string,
232
+ stripTabs: boolean,
233
+ ): { bodyStart: number; bodyEnd: number; end: number } {
234
+ let cursor = from;
235
+ while (cursor <= command.length) {
236
+ const nextEnd = command.indexOf("\n", cursor);
237
+ const line = command.slice(cursor, nextEnd < 0 ? command.length : nextEnd);
238
+ const comparable = stripTabs ? line.replace(/^\t+/, "") : line;
239
+ if (comparable === delimiter) {
240
+ // Stop at the terminator's own newline and let the caller split on it,
241
+ // so the command that follows the heredoc starts its own segment.
242
+ return { bodyStart: from, bodyEnd: cursor, end: nextEnd < 0 ? command.length : nextEnd };
243
+ }
244
+ if (nextEnd < 0) break;
245
+ cursor = nextEnd + 1;
246
+ }
247
+ // A missing terminator runs to end of input, which is where the shell would
248
+ // stop reading too. Bash still runs the body it collected.
249
+ return { bodyStart: from, bodyEnd: command.length, end: command.length };
250
+ }
251
+
252
+ /**
253
+ * Every heredoc introduced on the line that starts at `start`.
254
+ *
255
+ * One header line can introduce several bodies (`cat <<A <<B`), and the shell
256
+ * reads them one after another. Ignoring the later ones read a second body as
257
+ * top-level commands.
258
+ */
259
+ export function heredocRunAt(command: string, start: number): HeredocRange[] | undefined {
260
+ const headers: HeredocHeader[] = [];
261
+ let cursor = start;
262
+ for (;;) {
263
+ const header = heredocHeaderAt(command, cursor);
264
+ if (!header) break;
265
+ headers.push(header);
266
+ let next = header.end;
267
+ while (next < command.length && /[ \t]/.test(command[next]!)) next += 1;
268
+ if (command[next] !== "<" || command[next + 1] !== "<") break;
269
+ cursor = next;
270
+ }
271
+ if (headers.length === 0) return undefined;
272
+ const lastHeader = headers[headers.length - 1]!;
273
+ const lineEnd = command.indexOf("\n", lastHeader.end);
274
+ if (lineEnd < 0) return undefined;
275
+ const ranges: HeredocRange[] = [];
276
+ let bodyStart = lineEnd + 1;
277
+ for (const header of headers) {
278
+ const body = heredocBodyAt(command, bodyStart, header.delimiter, header.stripTabs);
279
+ ranges.push({ start: header.start, ...body });
280
+ if (body.end >= command.length) break;
281
+ bodyStart = body.end + 1;
282
+ }
283
+ return ranges;
284
+ }
285
+
286
+ /**
287
+ * Every heredoc in `command`, found with the same quote tracking the segment
288
+ * splitter uses.
289
+ *
290
+ * A `<<` inside a quoted span opens no heredoc. Scanning for `<<` without that
291
+ * check read a quoted `"<<"` as a header and masked the code that followed it.
292
+ */
293
+ export function heredocRanges(command: string): HeredocRange[] {
294
+ const ranges: HeredocRange[] = [];
295
+ let quote: "'" | '"' | "ansi" | undefined;
296
+ let escaped = false;
297
+ for (let i = 0; i < command.length; i += 1) {
298
+ const char = command[i]!;
299
+ if (escaped) {
300
+ escaped = false;
301
+ continue;
302
+ }
303
+ if (!quote && char === "$" && command[i + 1] === "'") {
304
+ quote = "ansi";
305
+ i += 1;
306
+ continue;
307
+ }
308
+ if (char === "\\" && quote !== "'") {
309
+ escaped = true;
310
+ continue;
311
+ }
312
+ if (quote === "ansi" && char === "'") {
313
+ quote = undefined;
314
+ continue;
315
+ }
316
+ if (char === "'" || char === '"') {
317
+ if (!quote) quote = char;
318
+ else if (quote === char) quote = undefined;
319
+ continue;
320
+ }
321
+ if (quote || char !== "<" || command[i + 1] !== "<") continue;
322
+ const run = heredocRunAt(command, i);
323
+ if (!run) continue;
324
+ ranges.push(...run);
325
+ // Lines inside a body are data, so a `<<` there opens nothing.
326
+ i = run[run.length - 1]!.end - 1;
327
+ }
328
+ return ranges;
329
+ }
330
+
331
+ /**
332
+ * The shells and interpreters that run a heredoc body as code.
333
+ *
334
+ * `source` and the `.` builtin execute a body they read from stdin, so their
335
+ * bodies are code too.
336
+ */
337
+ const HEREDOC_EXECUTOR_RE = /\b(?:python3?|node|perl|ruby|osascript|bash|sh|zsh|dash|ksh|source)\b|^\s*\.\s/i;
338
+
339
+ /**
340
+ * Commands that read a heredoc body as data.
341
+ *
342
+ * The body of these is never executed, so its lines are prose. An unrecognized
343
+ * command is treated as an executor instead: guessing "data" there would let a
344
+ * body a shell reads as code escape the scan.
345
+ *
346
+ * `sed`, `awk`, and `sort` are deliberately absent. Each can run a command named
347
+ * inside its own arguments (`sed 'e'`, `awk '{system($0)}'`,
348
+ * `sort --compress-program`), so a body behind one of them is executed text.
349
+ */
350
+ const DATA_HEREDOC_CONSUMERS = new Set([
351
+ "cat",
352
+ "tee",
353
+ "echo",
354
+ "printf",
355
+ "grep",
356
+ "egrep",
357
+ "fgrep",
358
+ "uniq",
359
+ "wc",
360
+ "head",
361
+ "tail",
362
+ "tr",
363
+ "cut",
364
+ "patch",
365
+ "diff",
366
+ "xxd",
367
+ "od",
368
+ "base64",
369
+ "sha1sum",
370
+ "sha256sum",
371
+ "md5sum",
372
+ "jq",
373
+ "column",
374
+ "less",
375
+ "more",
376
+ ]);
377
+
378
+ /*
379
+ * A program that can run a command named in its own arguments is not a data
380
+ * consumer. Testing the whole header line matched a word inside a quoted
381
+ * argument, so `sed -n '1e echo hi'` read as data because of the `echo` in its
382
+ * script.
383
+ */
384
+ function consumerWords(before: string): string[] {
385
+ // Only the command the heredoc belongs to reads the body, so take the last
386
+ // segment. Earlier commands on the same line never see it.
387
+ const segments = splitShellSegments(before);
388
+ return unwrapShellWords(segments[segments.length - 1] ?? "");
389
+ }
390
+
391
+ /** The program that reads the body, or `undefined` when no word names one. */
392
+ function consumerProgram(before: string): { head: string; sub?: string } | undefined {
393
+ const words = consumerWords(before);
394
+ const head = words[0]?.toLowerCase();
395
+ if (!head) return undefined;
396
+ return { head, sub: words[1]?.toLowerCase() };
397
+ }
398
+
399
+ /** Whether a program reads a heredoc body as data rather than executing it. */
400
+ function readsBodyAsData(program: { head: string; sub?: string }): boolean {
401
+ if (program.head === "git") return program.sub === "commit" || program.sub === "tag";
402
+ return DATA_HEREDOC_CONSUMERS.has(program.head);
403
+ }
404
+
405
+ /**
406
+ * The command word of every stage a header line pipes the body into.
407
+ *
408
+ * A pipeline hands the body to every stage, so `| grep x | sh` still ends in a
409
+ * shell. Only the first stage used to be read, and that masked the body. A `||`
410
+ * is a logical separator and runs nothing on success, so it is skipped, and a
411
+ * quoted `|` is an argument character rather than a pipe. `|&` is a real pipe
412
+ * that also redirects stderr, so only the first of its two characters is a pipe
413
+ * separator.
414
+ */
415
+ function pipedStageHeads(text: string): string[] {
416
+ const heads: string[] = [];
417
+ let quote: "'" | '"' | undefined;
418
+ let escaped = false;
419
+ for (let i = 0; i < text.length; i += 1) {
420
+ const char = text[i]!;
421
+ if (escaped) {
422
+ escaped = false;
423
+ continue;
424
+ }
425
+ if (char === "\\" && quote !== "'") {
426
+ escaped = true;
427
+ continue;
428
+ }
429
+ if (char === "'" || char === '"') {
430
+ if (!quote) quote = char;
431
+ else if (quote === char) quote = undefined;
432
+ continue;
433
+ }
434
+ if (quote || char !== "|") continue;
435
+ // `||` runs the right side only on failure and hands it no body. `|&` is a
436
+ // real pipe that also redirects stderr, so it is read as one pipe stage.
437
+ if (text[i + 1] === "|") {
438
+ i += 1;
439
+ continue;
440
+ }
441
+ const stageStart = text[i + 1] === "&" ? i + 2 : i + 1;
442
+ heads.push(shellWords(text.slice(stageStart))[0] ?? "");
443
+ }
444
+ return heads;
445
+ }
446
+
447
+ /** Whether the command on a heredoc's header line executes its body. */
448
+ export function heredocBodyIsCode(command: string, start: number): boolean {
449
+ const lineStart = command.lastIndexOf("\n", start - 1) + 1;
450
+ const lineEnd = command.indexOf("\n", start);
451
+ const line = command.slice(lineStart, lineEnd < 0 ? command.length : lineEnd);
452
+ const before = command.slice(lineStart, start);
453
+ if (HEREDOC_EXECUTOR_RE.test(before)) return true;
454
+ // Every piped stage is a consumer. Treating an unknown one as a filter let
455
+ // `cat <<EOF | ash` and `cat <<EOF | grep x | sh` run unread.
456
+ for (const head of pipedStageHeads(line.slice(start - lineStart))) {
457
+ if (!DATA_HEREDOC_CONSUMERS.has(head.toLowerCase())) return true;
458
+ }
459
+ const program = consumerProgram(before);
460
+ return !program || !readsBodyAsData(program);
461
+ }
462
+
463
+ /**
464
+ * `command` with every heredoc body that no command executes replaced by
465
+ * spaces.
466
+ *
467
+ * A body feeding `cat` is prose, so a line in it that looks like a command must
468
+ * not be judged as one. Newlines are preserved so line-oriented callers still
469
+ * see the same line structure.
470
+ */
471
+ export function maskDataHeredocBodies(command: string): string {
472
+ let out = "";
473
+ let cursor = 0;
474
+ for (const range of heredocRanges(command)) {
475
+ if (heredocBodyIsCode(command, range.start)) continue;
476
+ out += command.slice(cursor, range.bodyStart);
477
+ out += command.slice(range.bodyStart, range.bodyEnd).replace(/[^\n]/g, " ");
478
+ cursor = range.bodyEnd;
479
+ }
480
+ return out + command.slice(cursor);
481
+ }
482
+
196
483
  export function splitShellSegments(command: string): string[] {
197
484
  const segments: string[] = [];
198
485
  let current = "";
@@ -227,6 +514,26 @@ export function splitShellSegments(command: string): string[] {
227
514
  current += char;
228
515
  continue;
229
516
  }
517
+ // A heredoc body belongs to the segment that introduces it. Splitting it
518
+ // on its newlines made data read as code, so documentation quoting a
519
+ // blocked command shape was refused, and a code body's own lines looked
520
+ // like top-level commands.
521
+ //
522
+ // One header line can introduce several heredocs (`cat <<A <<B`), and the
523
+ // shell reads their bodies one after another. Consume the whole run here,
524
+ // so a later body is not read as top-level commands.
525
+ if (!quote && char === "<" && command[i + 1] === "<") {
526
+ const run = heredocRunAt(command, i);
527
+ if (run) {
528
+ let cursor = i;
529
+ for (const range of run) {
530
+ current += command.slice(cursor, range.end);
531
+ cursor = range.end;
532
+ }
533
+ i = cursor - 1;
534
+ continue;
535
+ }
536
+ }
230
537
  if (!quote && /[\n|;&]/.test(char)) {
231
538
  if (current.trim()) segments.push(current.trim());
232
539
  current = "";
@@ -238,7 +545,7 @@ export function splitShellSegments(command: string): string[] {
238
545
  return segments;
239
546
  }
240
547
 
241
- function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean } {
548
+ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: boolean; cwdTarget?: string; assignments: string[] } {
242
549
  let cleanCmd = decodeShellEscapes(command.trim());
243
550
  // Strip outer grouping parentheses/braces if present e.g. ( git commit ) or { git commit; }
244
551
  while (
@@ -252,6 +559,16 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
252
559
  const words = shellWords(cleanCmd);
253
560
  let i = 0;
254
561
  let changesCwd = false;
562
+ // Directory a wrapper (`env -C`, `sudo -D`) runs its command in. Recorded
563
+ // so a caller can judge a nested command in the tree it actually runs in
564
+ // instead of the shell's initial workdir.
565
+ let cwdTarget: string | undefined;
566
+ // Environment assignments consumed while unwrapping wrappers. They apply to
567
+ // the command that follows, so callers must not read them out of the command
568
+ // arguments instead: `git commit -m GIT_DIR=/repo` is message text, not an
569
+ // assignment, and treating it as one would claim a repository that the
570
+ // invocation never named.
571
+ const assignments: string[] = [];
255
572
  while (i < words.length) {
256
573
  const rawWord = words[i]!;
257
574
  const word = rawWord.replace(/^[\({]+/, "").replace(/[\)};]+$/, "");
@@ -337,7 +654,12 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
337
654
  ]);
338
655
  while (i < words.length && words[i]!.startsWith("-")) {
339
656
  const option = words[i++]!;
340
- if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) changesCwd = true;
657
+ if (option === "-D" || (option.startsWith("-D") && option !== "-D") || option === "--chdir" || option.startsWith("--chdir=")) {
658
+ changesCwd = true;
659
+ // The target is the next word unless it was attached (-Ddir).
660
+ if (option === "-D" || option === "--chdir") cwdTarget = words[i];
661
+ else cwdTarget = option.replace(/^-D|^--chdir=/, "");
662
+ }
341
663
  if (valueOptions.has(option) && i < words.length) i++;
342
664
  }
343
665
  continue;
@@ -360,6 +682,7 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
360
682
  break;
361
683
  }
362
684
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)) {
685
+ assignments.push(w);
363
686
  i++;
364
687
  continue;
365
688
  }
@@ -369,7 +692,8 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
369
692
  if (clustered?.[1] === "C") {
370
693
  changesCwd = true;
371
694
  i++;
372
- if (!clustered[2] && i < words.length) i++;
695
+ if (clustered[2]) cwdTarget = clustered[2];
696
+ else if (i < words.length) cwdTarget = words[i++];
373
697
  continue;
374
698
  }
375
699
  if (clustered?.[1] === "S") {
@@ -397,17 +721,24 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
397
721
  }
398
722
  if (w.startsWith("-C") && w !== "-C") {
399
723
  changesCwd = true;
724
+ cwdTarget = w.slice(2);
400
725
  i++;
401
726
  continue;
402
727
  }
403
728
  if (!w.startsWith("-")) break;
404
729
  i++;
405
- if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) changesCwd = true;
730
+ if (w === "-C" || w === "--chdir" || w.startsWith("--chdir=")) {
731
+ changesCwd = true;
732
+ if (w === "--chdir") cwdTarget = words[i];
733
+ else if (w.startsWith("--chdir=")) cwdTarget = w.slice("--chdir=".length);
734
+ else cwdTarget = words[i];
735
+ }
406
736
  if (valueOptions.has(w) && i < words.length) i++;
407
737
  }
408
738
  continue;
409
739
  }
410
740
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
741
+ assignments.push(word);
411
742
  i++;
412
743
  continue;
413
744
  }
@@ -416,13 +747,379 @@ function unwrapShellWordsImpl(command: string): { words: string[]; changesCwd: b
416
747
  return { words: words
417
748
  .slice(i)
418
749
  .map((w) => w.replace(/^[\({]+/, "").replace(/[\)};]+$/, ""))
419
- .filter(Boolean), changesCwd };
750
+ .filter(Boolean), changesCwd, cwdTarget, assignments };
420
751
  }
421
752
 
422
753
  export function unwrapShellWords(command: string): string[] {
423
754
  return unwrapShellWordsImpl(command).words;
424
755
  }
425
756
 
757
+ /**
758
+ * The directory a wrapper (`env -C`, `sudo -D`) runs its command in, if it
759
+ * names one. The target is left unexpanded: the caller knows the directory it
760
+ * is relative to.
761
+ */
762
+ export function shellWrapperCwd(command: string): string | undefined {
763
+ return unwrapShellWordsImpl(command).cwdTarget;
764
+ }
765
+
766
+ /**
767
+ * One shell segment and the directory it runs in.
768
+ *
769
+ * `root` is `undefined` when the walk cannot determine it: an unresolvable
770
+ * `cd` target (`cd $DIR`, `cd -`), a `pushd` that swaps the directory stack,
771
+ * or a `popd` with no tracked push. Callers treat that as indeterminate
772
+ * instead of assuming the session's checkout, because that assumption is how a
773
+ * `cd` into another tree used to hide a mutation from the boundary policy.
774
+ */
775
+ export interface ShellSegmentPlan {
776
+ segment: string;
777
+ root: string | undefined;
778
+ }
779
+
780
+ const CWD_WORDS = new Set(["cd", "pushd", "popd"]);
781
+
782
+ /**
783
+ * The directory a git repository selector names.
784
+ *
785
+ * `git -C ~/repo` reaches a directory through the shell's own expansion, so a
786
+ * literal `~` must not be read as a workspace-relative path. Only the forms a
787
+ * shell expands deterministically are expanded here. Anything else keeps its
788
+ * literal text, so an unresolvable `$VARIABLE` still fails closed through
789
+ * `hasUnresolvableVariable`.
790
+ */
791
+ export function resolveGitRepoSelector(baseDir: string, value: string): string {
792
+ return resolve(baseDir, expandCwdTarget(value) ?? value);
793
+ }
794
+
795
+ /** Expands only the directory forms a shell expands deterministically. */
796
+ export function expandCwdTarget(target: string): string | undefined {
797
+ const home = process.env.HOME || homedir();
798
+ if (target === "~") return home;
799
+ if (target.startsWith("~/")) return join(home, target.slice(2));
800
+ // `cd ~user` resolves to that user's home, never to a literal `~user`
801
+ // directory. Reading it as a relative path put a `cd ~csh` walk inside the
802
+ // workspace while the shell reached the real home. The path after the name
803
+ // is part of the target, so dropping it judged the wrong directory.
804
+ const named = /^~([^/\s]+)(\/.*)?$/.exec(target);
805
+ if (named) {
806
+ const user = named[1]!;
807
+ const tail = named[2] ?? "";
808
+ let base: string;
809
+ try {
810
+ // The walk cannot read another user's home portably, so it joins the name
811
+ // onto this home's parent. That is the same heuristic `expandShellTargetPath`
812
+ // uses, which keeps both scanners agreeing on one directory. A guess that
813
+ // lands inside the workspace arrives here only when the workspace already
814
+ // spans the home parent, the same exposure the boundary scan has.
815
+ base = user === userInfo().username ? home : join(dirname(home), user);
816
+ } catch {
817
+ return undefined;
818
+ }
819
+ return tail === "" ? base : join(base, tail.slice(1));
820
+ }
821
+ if (/^\$(?:HOME|\{HOME\})(?:$|[/\\])/.test(target)) {
822
+ return target.replace(/^\$(?:HOME|\{HOME\})/, home);
823
+ }
824
+ if (target.includes("$")) return undefined;
825
+ return target;
826
+ }
827
+
828
+ /** Words that carry no directory of their own; a control-flow body follows them. */
829
+ const TRANSPARENT_PREFIX = new Set([
830
+ "builtin",
831
+ "command",
832
+ "then",
833
+ "else",
834
+ "elif",
835
+ "do",
836
+ "if",
837
+ "while",
838
+ "until",
839
+ "for",
840
+ "!",
841
+ "time",
842
+ ]);
843
+
844
+ /**
845
+ * A `pushd` operand that rotates or only pushes the stack.
846
+ *
847
+ * `pushd +N`/`-N` swaps entries, and `-n` pushes without changing directory.
848
+ * The walk tracks a single saved directory, so it cannot model either. Both are
849
+ * reported as an unchecked change, which fails closed for a mutation.
850
+ */
851
+ function pushdRotation(operand: string): boolean {
852
+ return /^[+-]\d+$/.test(operand) || operand === "-n" || /^-n./.test(operand);
853
+ }
854
+
855
+ function cwdChangeIn(segment: string, insideCase = false): { word: string; target?: string; unmodeled?: boolean } | undefined {
856
+ const words = unwrapShellWords(segment);
857
+ let i = 0;
858
+ if (insideCase) {
859
+ // `case x in a) cd /other;; esac` arrives as `a) cd /other`, and
860
+ // `unwrapShellWords` has already stripped the pattern's closing paren.
861
+ // The pattern word runs nothing, so the command after it is the body.
862
+ while (i < words.length && !CWD_WORDS.has(words[i]!) && !TRANSPARENT_PREFIX.has(words[i]!) && words[i] !== "case") i += 1;
863
+ }
864
+ while (i < words.length) {
865
+ const word = words[i]!;
866
+ if (TRANSPARENT_PREFIX.has(word)) {
867
+ i += 1;
868
+ continue;
869
+ }
870
+ // `case` opens an arm wherever it appears, including after `then`, `do`,
871
+ // or `else`: the pattern is one word after `in`, and the body follows the
872
+ // pattern. Treating only a leading `case` as an arm hid `then case x in x)
873
+ // cd <other>`, so the `cd` was never seen.
874
+ if (word === "case") {
875
+ const inIndex = words.indexOf("in", i);
876
+ i = inIndex >= 0 ? inIndex + 2 : words.length;
877
+ continue;
878
+ }
879
+ if (CWD_WORDS.has(word)) break;
880
+ // A case pattern closes with `)`, and the command after it runs here.
881
+ if (word.endsWith(")") || word === "}") {
882
+ i += 1;
883
+ continue;
884
+ }
885
+ return undefined;
886
+ }
887
+ const word = words[i];
888
+ if (!word || !CWD_WORDS.has(word)) return undefined;
889
+ if (word === "popd") {
890
+ // `popd +N`/`-N` rotate the stack instead of popping it, which this walk
891
+ // does not model. A plain `popd` consumes the top entry.
892
+ return words.slice(i + 1).some((operand) => pushdRotation(operand)) ? { word, unmodeled: true } : { word };
893
+ }
894
+ let target: string | undefined;
895
+ let unmodeled = false;
896
+ for (let j = i + 1; j < words.length; j += 1) {
897
+ const candidate = words[j]!;
898
+ if (candidate === "--") continue;
899
+ if (word === "pushd" && pushdRotation(candidate)) {
900
+ unmodeled = true;
901
+ continue;
902
+ }
903
+ // `cd -` moves to OLDPWD, which this walk does not track.
904
+ if (candidate === "-") return { word, target: undefined };
905
+ if (candidate.startsWith("-")) continue;
906
+ target = candidate;
907
+ break;
908
+ }
909
+ if (unmodeled) return { word, unmodeled: true };
910
+ if (target !== undefined) return { word, target };
911
+ // A bare `cd` moves to $HOME, a known directory. A bare `pushd` swaps the
912
+ // top two entries of the directory stack, which is not tracked here.
913
+ return { word, target: word === "cd" ? "~" : undefined };
914
+ }
915
+
916
+ /**
917
+ * Unquoted parenthesis counts in a segment.
918
+ *
919
+ * `splitShellSegments` splits on `&&`/`;`/`|` but not on grouping, so a
920
+ * subshell arrives as segments that still carry its parens. A `cd` inside one
921
+ * must not move the directory of the segments after the subshell closes.
922
+ *
923
+ * Inside a `case` body a pattern's own `)` is not a grouping close, so it is
924
+ * not counted while a case construct is open.
925
+ */
926
+ function groupParensIn(segment: string, insideCase = false): { opens: number; closes: number } {
927
+ // Which characters sit outside quotes, so a `)` inside a string is never a
928
+ // group or pattern terminator.
929
+ const unquoted: boolean[] = [];
930
+ let quote: "'" | '"' | undefined;
931
+ let escaped = false;
932
+ for (let i = 0; i < segment.length; i += 1) {
933
+ const char = segment[i]!;
934
+ let bare = false;
935
+ if (escaped) escaped = false;
936
+ else if (char === "\\" && quote !== "'") escaped = true;
937
+ else if (char === "'" || char === '"') {
938
+ if (!quote) quote = char;
939
+ else if (quote === char) quote = undefined;
940
+ } else if (!quote) bare = true;
941
+ unquoted.push(bare);
942
+ }
943
+ // A `case` arm pattern ends with `)`, which closes no group. An arm can open
944
+ // in this segment (`case x in x) …`) or arrive already open (`x) …`), so both
945
+ // forms are recognised. Missing the in-segment form let a pattern's paren pop
946
+ // a subshell frame, which then swallowed the real closing paren.
947
+ const patternCloses = new Set<number>();
948
+ const spelled = unquoted.map((bare, i) => (bare ? segment[i]! : " ")).join("");
949
+ if (/[^\s]/.test(spelled)) {
950
+ for (const head of spelled.matchAll(/\bcase\b[^;|&]*?\bin\b/g)) {
951
+ let from = (head.index ?? 0) + head[0].length;
952
+ while (from < spelled.length) {
953
+ const close = spelled.indexOf(")", from);
954
+ if (close < 0) break;
955
+ patternCloses.add(close);
956
+ const nextArm = spelled.indexOf(";;", close);
957
+ if (nextArm < 0) break;
958
+ from = nextArm + 2;
959
+ }
960
+ }
961
+ }
962
+ let opens = 0;
963
+ let closes = 0;
964
+ // At segment start, `esac )` carries a real group close, not an arm pattern.
965
+ // Reading it as a pattern left the surrounding subshell open, so the segment
966
+ // after it inherited the arm's directory instead of the outer one.
967
+ const startsAtClose = /^\s*(?:esac|fi|done)\b/.test(segment);
968
+ for (let i = 0; i < segment.length; i += 1) {
969
+ if (!unquoted[i]) continue;
970
+ const char = segment[i]!;
971
+ if (char === "(") opens += 1;
972
+ else if (char === ")") {
973
+ if (patternCloses.has(i)) continue;
974
+ // An arm pattern that was already open when this segment began ends
975
+ // here. It closes no group.
976
+ if (insideCase && !startsAtClose && closes + 1 > opens) continue;
977
+ closes += 1;
978
+ }
979
+ }
980
+ return { opens, closes };
981
+ }
982
+
983
+ /**
984
+ * Which construct boundary a segment carries, if any.
985
+ *
986
+ * Only a keyword in command position counts: `echo done` and
987
+ * `git commit -m "if"` carry no construct boundary.
988
+ */
989
+ function constructKeywordIn(segment: string): "open" | "close" | "case-open" | "case-close" | undefined {
990
+ const words = unwrapShellWords(segment);
991
+ let i = 0;
992
+ // Wrappers and negation can precede the keyword: `time if …`, `! case …`.
993
+ while (i < words.length && (words[i] === "builtin" || words[i] === "command" || words[i] === "time" || words[i] === "!")) i += 1;
994
+ // A body opener can precede a nested construct: `then case …`, `do case …`.
995
+ // Without this the nested `case` opened no frame, so its `esac` popped the
996
+ // enclosing `if`/`for` frame instead and the arm's `cd` was never recorded.
997
+ // `if`/`while`/`until`/`for` stay out of this list, because they are the
998
+ // openers this function reports.
999
+ while (i < words.length && (words[i] === "then" || words[i] === "else" || words[i] === "do" || words[i] === "elif")) i += 1;
1000
+ const word = words[i];
1001
+ if (word === "if" || word === "while" || word === "until" || word === "for") return "open";
1002
+ if (word === "case") return "case-open";
1003
+ if (word === "esac") return "case-close";
1004
+ if (word === "fi" || word === "done") return "close";
1005
+ return undefined;
1006
+ }
1007
+
1008
+ /**
1009
+ * Whether a segment runs `eval` as its command word.
1010
+ *
1011
+ * Only a leading `eval` runs its body in this shell. A prose mention
1012
+ * (`Use eval "…" to show the bypass`) names no command, and forwarding it made
1013
+ * a documentation heredoc look like a directory change. The wrappers that do not
1014
+ * fork (`builtin`, `command`, `time`) and a leading assignment (`A=1 eval …`)
1015
+ * both still reach `eval`, so they are skipped here.
1016
+ */
1017
+ export function isLeadingEvalSegment(segment: string): boolean {
1018
+ const words = shellWords(segment);
1019
+ let i = 0;
1020
+ while (i < words.length) {
1021
+ const word = words[i]!;
1022
+ if (word === "builtin" || word === "command" || word === "time") {
1023
+ i += 1;
1024
+ continue;
1025
+ }
1026
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
1027
+ i += 1;
1028
+ continue;
1029
+ }
1030
+ break;
1031
+ }
1032
+ return words[i] === "eval";
1033
+ }
1034
+
1035
+ /**
1036
+ * Walk a multi-segment command and resolve the directory each segment runs in.
1037
+ *
1038
+ * Later segments run in the directory an earlier `cd`/`pushd` selected, and a
1039
+ * wrapper `-C`/`-D` moves only its own command. Judging every segment against
1040
+ * the shell's initial `workdir` let `cd <other-tree> && git commit` be checked
1041
+ * as if it happened in the session's own checkout.
1042
+ *
1043
+ * A `cd` inside a grouping subshell is scoped to it, so the segments after the
1044
+ * closing paren keep the outer directory. Without that, `( cd /other && ls )
1045
+ * && rm -rf dist` would read the removal as happening in `/other`.
1046
+ *
1047
+ * A `cd` inside a conditional or loop body is NOT scoped: that body runs in the
1048
+ * current shell. Which body runs depends on a condition this walk cannot
1049
+ * evaluate, so a construct whose bodies change directory leaves the later
1050
+ * directory undetermined rather than letting the last body parsed win.
1051
+ */
1052
+ export function planShellSegments(command: string, baseDir: string | undefined, evalBody?: (segment: string) => string | undefined): ShellSegmentPlan[] {
1053
+ const plan: ShellSegmentPlan[] = [];
1054
+ let cwd = baseDir;
1055
+ const dirStack: Array<string | undefined> = [];
1056
+ // Each open grouping paren saves the outer directory and the outer stack.
1057
+ // A `pushd` inside `( … )` must not leave an entry a later outer `popd`
1058
+ // consumes: bash discards the subshell's stack with it.
1059
+ const groupStack: Array<{ cwd: string | undefined; dirStack: Array<string | undefined> }> = [];
1060
+ // One frame per open `if`/`while`/`until`/`for`/`case`, recording whether any
1061
+ // body changed directory.
1062
+ const constructStack: Array<{ changed: boolean }> = [];
1063
+ let caseDepth = 0;
1064
+ for (const segment of splitShellSegments(command)) {
1065
+ const { opens, closes } = groupParensIn(segment, caseDepth > 0);
1066
+ for (let i = 0; i < opens; i += 1) groupStack.push({ cwd, dirStack: [...dirStack] });
1067
+ const wrapperCwd = shellWrapperCwd(segment);
1068
+ let root = cwd;
1069
+ if (wrapperCwd !== undefined) {
1070
+ const expanded = expandCwdTarget(wrapperCwd);
1071
+ root = expanded === undefined || cwd === undefined ? undefined : resolve(cwd, expanded);
1072
+ }
1073
+ plan.push({ segment, root });
1074
+ const keyword = constructKeywordIn(segment);
1075
+ if (keyword === "open") constructStack.push({ changed: false });
1076
+ if (keyword === "case-open") {
1077
+ constructStack.push({ changed: false });
1078
+ caseDepth += 1;
1079
+ }
1080
+ // `eval` runs its body in the current shell, so a `cd` inside it moves the
1081
+ // directory of later segments. The body is supplied by the caller because
1082
+ // extracting it lives in the interpreter policy, not here.
1083
+ const evaluated = evalBody?.(segment);
1084
+ const change = cwdChangeIn(segment, caseDepth > 0) ?? (evaluated === undefined ? undefined : cwdChangeIn(evaluated, caseDepth > 0));
1085
+ if (change) {
1086
+ for (const frame of constructStack) frame.changed = true;
1087
+ if (change.unmodeled) {
1088
+ cwd = undefined;
1089
+ } else if (change.word === "popd") {
1090
+ // `popd` with nothing to pop fails in bash and leaves the
1091
+ // directory alone, so it must not clear a tracked directory.
1092
+ cwd = dirStack.length > 0 ? dirStack.pop() : cwd;
1093
+ } else {
1094
+ if (change.word === "pushd") dirStack.push(cwd);
1095
+ const expanded = change.target === undefined ? undefined : expandCwdTarget(change.target);
1096
+ cwd = cwd === undefined || expanded === undefined ? undefined : resolve(cwd, expanded);
1097
+ }
1098
+ }
1099
+ if (keyword === "case-close") caseDepth = Math.max(0, caseDepth - 1);
1100
+ if (keyword === "close" || keyword === "case-close") {
1101
+ const frame = constructStack.pop();
1102
+ // A body that changed directory only sometimes leaves the later
1103
+ // directory dependent on a condition, which fails closed.
1104
+ if (frame?.changed) cwd = undefined;
1105
+ }
1106
+ for (let i = 0; i < closes; i += 1) {
1107
+ const outer = groupStack.pop();
1108
+ if (outer) {
1109
+ cwd = outer.cwd;
1110
+ dirStack.length = 0;
1111
+ dirStack.push(...outer.dirStack);
1112
+ }
1113
+ }
1114
+ }
1115
+ return plan;
1116
+ }
1117
+
1118
+ /** Environment assignments that precede the command word, after unwrapping. */
1119
+ export function shellPrefixAssignments(command: string): string[] {
1120
+ return unwrapShellWordsImpl(command).assignments;
1121
+ }
1122
+
426
1123
  export function shellWrappersChangeCwd(command: string): boolean {
427
1124
  return unwrapShellWordsImpl(command).changesCwd;
428
1125
  }