1type CommandWrapper = "env" | "sudo";
2type CommandProgramContext = "exec" | "shell";
3
4const MAX_COMMAND_SEGMENTS = 64;
5
6const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]);
7const WINDOWS_SHELL_PROGRAMS = new Set([
8 "cmd",
9 "cmd.exe",
10 "powershell",
11 "powershell.exe",
12 "pwsh",
13 "pwsh.exe",
14]);
15const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]);
16const SHELL_COMMAND_WRAPPERS = new Set(["builtin", "command", "exec"]);
17const SHELL_PRECOMMAND_MODIFIERS = new Set(["nocorrect", "noglob", "time"]);
18const POWERSHELL_SETUP_PROGRAMS = new Set(["pop-location", "push-location", "set-location"]);
19const POWERSHELL_FLAGS = new Set(["-mta", "-nologo", "-noninteractive", "-noprofile", "-sta"]);
20const POWERSHELL_OPTIONS_WITH_VALUE = new Set([
21 "-configurationname",
22 "-executionpolicy",
23 "-inputformat",
24 "-outputformat",
25 "-version",
26 "-windowstyle",
27 "-workingdirectory",
28]);
29const START_PROCESS_FLAGS = new Set([
30 "-confirm",
31 "-debug",
32 "-loaduserprofile",
33 "-nonewwindow",
34 "-passthru",
35 "-usenewenvironment",
36 "-verbose",
37 "-wait",
38 "-whatif",
39]);
40const START_PROCESS_OPTIONS_WITH_VALUE = new Set([
41 "-argumentlist",
42 "-credential",
43 "-environment",
44 "-erroraction",
45 "-errorvariable",
46 "-informationaction",
47 "-informationvariable",
48 "-outbuffer",
49 "-outvariable",
50 "-pipelinevariable",
51 "-progressaction",
52 "-redirectstandarderror",
53 "-redirectstandardinput",
54 "-redirectstandardoutput",
55 "-verb",
56 "-warningaction",
57 "-warningvariable",
58 "-windowstyle",
59 "-workingdirectory",
60]);
61const SKIPPABLE_SUDO_PROBES = new Set(["[", "[[", "test", "true"]);
62const NON_PROGRAM_PREFIX_CHARACTERS = "<>(){}[];|&$`#!%@:";
63const NON_PROGRAM_SUFFIX_CHARACTERS = "){]}`";
64
65const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([
66 "!",
67 "#",
68 ".",
69 ":",
70 "[",
71 "[[",
72 "alias",
73 "and",
74 "autoload",
75 "begin",
76 "bg",
77 "bind",
78 "bindkey",
79 "break",
80 "builtin",
81 "caller",
82 "case",
83 "catch",
84 "cd",
85 "command",
86 "compgen",
87 "complete",
88 "compopt",
89 "continue",
90 "coproc",
91 "declare",
92 "dirs",
93 "disown",
94 "do",
95 "done",
96 "elif",
97 "else",
98 "enable",
99 "end",
100 "esac",
101 "eval",
102 "exec",
103 "exit",
104 "export",
105 "false",
106 "fc",
107 "fg",
108 "fi",
109 "finally",
110 "for",
111 "foreach",
112 "function",
113 "getopts",
114 "history",
115 "if",
116 "in",
117 "jobs",
118 "let",
119 "local",
120 "logout",
121 "mapfile",
122 "nocorrect",
123 "noglob",
124 "not",
125 "or",
126 "popd",
127 "pushd",
128 "read",
129 "readarray",
130 "readonly",
131 "repeat",
132 "return",
133 "select",
134 "set",
135 "setopt",
136 "shift",
137 "shopt",
138 "source",
139 "switch",
140 "suspend",
141 "test",
142 "then",
143 "time",
144 "times",
145 "trap",
146 "try",
147 "true",
148 "type",
149 "typeset",
150 "ulimit",
151 "umask",
152 "unalias",
153 "until",
154 "unset",
155 "unsetopt",
156 "wait",
157 "while",
158]);
159
160const TERMINAL_SHELL_PROGRAMS = new Set([
161 "and",
162 "begin",
163 "break",
164 "case",
165 "catch",
166 "continue",
167 "coproc",
168 "do",
169 "done",
170 "elif",
171 "else",
172 "end",
173 "esac",
174 "eval",
175 "exec",
176 "exit",
177 "false",
178 "fi",
179 "finally",
180 "for",
181 "foreach",
182 "function",
183 "if",
184 "in",
185 "not",
186 "or",
187 "repeat",
188 "return",
189 "select",
190 "switch",
191 "then",
192 "try",
193 "until",
194 "while",
195]);
196
197function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null {
198 for (let index = start; index < tokens.length; index += 1) {
199 const option = tokens[index]!;
200 if (option === "--" || !option.startsWith("-")) return null;
201 if (SHELL_OPTIONS_WITH_VALUE.has(option)) {
202 index += 1;
203 continue;
204 }
205 if (option === "--command" || /^-[a-zA-Z]*c[a-zA-Z]*$/.test(option)) return index + 1;
206 }
207 return null;
208}
209
210const COMMAND_WRAPPER_OPTIONS_WITH_VALUE: Record<CommandWrapper, ReadonlySet<string>> = {
211 env: new Set(["-C", "--chdir", "-S", "--split-string", "-u", "--unset"]),
212 sudo: new Set(["-C", "--close-from", "-D", "--chdir", "-g", "--group", "-u", "--user"]),
213};
214
215const COMMAND_WRAPPER_FLAGS: Record<CommandWrapper, ReadonlySet<string>> = {
216 env: new Set(["-0", "--null", "-i", "--ignore-environment", "--debug", "-v"]),
217 sudo: new Set(["-A", "--askpass", "-b", "--background", "-E", "-H", "-i", "-n", "-S"]),
218};
219
220function tokenizeShellCommand(command: string): string[] | null {
221 const input = command.trim();
222 const tokens: string[] = [];
223 let current = "";
224 let quote: '"' | "'" | null = null;
225 let escaping = false;
226 let inBackticks = false;
227 let substitutionDepth = 0;
228 let parameterExpansionDepth = 0;
229 let tokenStarted = false;
230
231 for (let index = 0; index < input.length; index += 1) {
232 const character = input[index]!;
233 if (escaping) {
234 current += character;
235 escaping = false;
236 tokenStarted = true;
237 continue;
238 }
239 if (character === "\\" && quote !== "'") {
240 const nextCharacter = input[index + 1];
241 const isWindowsPath = quote === null && /^(?:[A-Za-z]:|\.{1,2})(?:\\[^\s]*)?$/u.test(current);
242 if (
243 (quote === '"' || isWindowsPath) &&
244 nextCharacter !== undefined &&
245 nextCharacter !== '"' &&
246 nextCharacter !== "\\" &&
247 nextCharacter !== "$" &&
248 nextCharacter !== "`" &&
249 nextCharacter !== "\n"
250 ) {
251 current += character;
252 tokenStarted = true;
253 continue;
254 }
255 escaping = true;
256 tokenStarted = true;
257 continue;
258 }
259 if (inBackticks) {
260 current += character;
261 if (character === "`") inBackticks = false;
262 tokenStarted = true;
263 continue;
264 }
265 if (quote !== null) {
266 if (character === quote) {
267 quote = null;
268 } else {
269 current += character;
270 }
271 tokenStarted = true;
272 continue;
273 }
274 if (character === "`") {
275 current += character;
276 inBackticks = true;
277 tokenStarted = true;
278 continue;
279 }
280 if (character === "$" && input[index + 1] === "{") {
281 current += "${";
282 parameterExpansionDepth += 1;
283 tokenStarted = true;
284 index += 1;
285 continue;
286 }
287 if (character === "{" && parameterExpansionDepth > 0) {
288 current += character;
289 parameterExpansionDepth += 1;
290 tokenStarted = true;
291 continue;
292 }
293 if (character === "}" && parameterExpansionDepth > 0) {
294 current += character;
295 parameterExpansionDepth -= 1;
296 tokenStarted = true;
297 continue;
298 }
299 if (character === "$" && input[index + 1] === "(") {
300 current += "$(";
301 substitutionDepth += 1;
302 tokenStarted = true;
303 index += 1;
304 continue;
305 }
306 if (character === "(") {
307 current += character;
308 substitutionDepth += 1;
309 tokenStarted = true;
310 continue;
311 }
312 if (character === ")" && substitutionDepth > 0) {
313 current += character;
314 substitutionDepth -= 1;
315 tokenStarted = true;
316 continue;
317 }
318 if (character === '"' || character === "'") {
319 quote = character;
320 tokenStarted = true;
321 continue;
322 }
323 if (/\s/u.test(character)) {
324 if (substitutionDepth > 0 || parameterExpansionDepth > 0) {
325 current += character;
326 tokenStarted = true;
327 continue;
328 }
329 if (tokenStarted) {
330 tokens.push(current);
331 current = "";
332 tokenStarted = false;
333 }
334 continue;
335 }
336 current += character;
337 tokenStarted = true;
338 }
339
340 if (
341 quote !== null ||
342 escaping ||
343 inBackticks ||
344 substitutionDepth > 0 ||
345 parameterExpansionDepth > 0
346 ) {
347 return null;
348 }
349 if (tokenStarted) tokens.push(current);
350 return tokens;
351}
352
353type ShellCommandSplit = {
354 readonly firstCommand: string;
355 readonly remainingCommand: string | null;
356 readonly separator: string | null;
357};
358
359type Heredoc = {
360 readonly delimiter: string;
361 readonly stripTabs: boolean;
362};
363
364type ShellSeparator = {
365 readonly index: number;
366 readonly length: number;
367};
368
369type ShellCommentRange = {
370 readonly start: number;
371 readonly end: number;
372};
373
374function commandWithoutShellComments(
375 command: string,
376 end: number,
377 comments: ReadonlyArray<ShellCommentRange>,
378): string {
379 let result = "";
380 let cursor = 0;
381 for (const comment of comments) {
382 if (comment.start >= end) break;
383 result += command.slice(cursor, comment.start);
384 cursor = Math.min(comment.end, end);
385 }
386 return result + command.slice(cursor, end);
387}
388
389function readHeredocDelimiter(
390 command: string,
391 start: number,
392 stripTabs: boolean,
393): { readonly heredoc: Heredoc; readonly end: number } | null {
394 let index = start;
395 while (command[index] === " " || command[index] === "\t") index += 1;
396
397 let delimiter = "";
398 let quote: '"' | "'" | null = null;
399 let escaping = false;
400 for (; index < command.length; index += 1) {
401 const character = command[index]!;
402 if (escaping) {
403 delimiter += character;
404 escaping = false;
405 continue;
406 }
407 if (character === "\\" && quote !== "'") {
408 escaping = true;
409 continue;
410 }
411 if (quote !== null) {
412 if (character === quote) quote = null;
413 else delimiter += character;
414 continue;
415 }
416 if (character === '"' || character === "'") {
417 quote = character;
418 continue;
419 }
420 if (/\s/u.test(character) || ";&|<>()".includes(character)) break;
421 delimiter += character;
422 }
423
424 if (!delimiter || quote !== null || escaping) return null;
425 return { heredoc: { delimiter, stripTabs }, end: index };
426}
427
428function commandAfterHeredocs(
429 command: string,
430 start: number,
431 heredocs: ReadonlyArray<Heredoc>,
432): string | null {
433 let cursor = start;
434 for (const heredoc of heredocs) {
435 let foundDelimiter = false;
436 while (cursor <= command.length) {
437 const newlineIndex = command.indexOf("\n", cursor);
438 const lineEnd = newlineIndex === -1 ? command.length : newlineIndex;
439 const line = command.slice(cursor, lineEnd).replace(/\r$/u, "");
440 const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/u, "") : line;
441 cursor = newlineIndex === -1 ? command.length : newlineIndex + 1;
442 if (comparableLine === heredoc.delimiter) {
443 foundDelimiter = true;
444 break;
445 }
446 if (newlineIndex === -1) break;
447 }
448 if (!foundDelimiter) return null;
449 }
450
451 return command.slice(cursor).trim() || null;
452}
453
454function splitFirstShellCommand(command: string): ShellCommandSplit {
455 let quote: '"' | "'" | null = null;
456 let powerShellHereStringQuote: '"' | "'" | null = null;
457 let escaping = false;
458 let inBackticks = false;
459 let inComment = false;
460 let substitutionDepth = 0;
461 let parameterExpansionDepth = 0;
462 const heredocs: Heredoc[] = [];
463 const comments: ShellCommentRange[] = [];
464 let commentStart = 0;
465 let separatorBeforeHeredocs: ShellSeparator | null = null;
466
467 for (let index = 0; index < command.length; index += 1) {
468 const character = command[index]!;
469 if (powerShellHereStringQuote !== null) {
470 if (
471 character === powerShellHereStringQuote &&
472 command[index + 1] === "@" &&
473 (index === 0 || command[index - 1] === "\n")
474 ) {
475 powerShellHereStringQuote = null;
476 index += 1;
477 }
478 continue;
479 }
480 if (inComment) {
481 if (character !== "\n") continue;
482 inComment = false;
483 comments.push({ start: commentStart, end: index });
484 }
485 if (escaping) {
486 escaping = false;
487 continue;
488 }
489 if (character === "\\" && quote !== "'") {
490 escaping = true;
491 continue;
492 }
493 if (inBackticks) {
494 if (character === "`") inBackticks = false;
495 continue;
496 }
497 if (quote !== null) {
498 if (character === quote) quote = null;
499 continue;
500 }
501 if (
502 character === "@" &&
503 (command[index + 1] === '"' || command[index + 1] === "'") &&
504 (command[index + 2] === "\n" || (command[index + 2] === "\r" && command[index + 3] === "\n"))
505 ) {
506 powerShellHereStringQuote = command[index + 1] as '"' | "'";
507 index += 1;
508 continue;
509 }
510 if (character === '"' || character === "'") {
511 quote = character;
512 continue;
513 }
514 if (character === "`") {
515 inBackticks = true;
516 continue;
517 }
518 if (
519 character === "#" &&
520 (index === 0 || /\s/u.test(command[index - 1]!) || ";&|(".includes(command[index - 1]!))
521 ) {
522 inComment = true;
523 commentStart = index;
524 continue;
525 }
526 if (character === "$" && command[index + 1] === "{") {
527 parameterExpansionDepth += 1;
528 index += 1;
529 continue;
530 }
531 if (character === "{" && parameterExpansionDepth > 0) {
532 parameterExpansionDepth += 1;
533 continue;
534 }
535 if (character === "}" && parameterExpansionDepth > 0) {
536 parameterExpansionDepth -= 1;
537 continue;
538 }
539 if (character === "(") {
540 substitutionDepth += 1;
541 continue;
542 }
543 if (character === ")" && substitutionDepth > 0) {
544 substitutionDepth -= 1;
545 continue;
546 }
547 if (substitutionDepth > 0 || parameterExpansionDepth > 0) continue;
548
549 if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") {
550 const stripTabs = command[index + 2] === "-";
551 const delimiter = readHeredocDelimiter(command, index + (stripTabs ? 3 : 2), stripTabs);
552 if (delimiter === null) {
553 return { firstCommand: command.trim(), remainingCommand: null, separator: null };
554 }
555 heredocs.push(delimiter.heredoc);
556 index = delimiter.end - 1;
557 continue;
558 }
559
560 const isDoubleOperator =
561 (character === "&" && command[index + 1] === "&") ||
562 (character === "|" && (command[index + 1] === "|" || command[index + 1] === "&"));
563 const isRedirectionAmpersand =
564 character === "&" &&
565 (command[index - 1] === ">" || command[index - 1] === "<" || command[index + 1] === ">");
566 if ((!isDoubleOperator && !";&|\n".includes(character)) || isRedirectionAmpersand) continue;
567
568 if (character === "\n" && heredocs.length > 0) {
569 const separator = separatorBeforeHeredocs;
570 const firstCommand = commandWithoutShellComments(
571 command,
572 separator?.index ?? index,
573 comments,
574 ).trimStart();
575 const commandBeforeHeredocs = separator
576 ? command.slice(separator.index + separator.length, index).trim()
577 : "";
578 const commandFollowingHeredocs = commandAfterHeredocs(command, index + 1, heredocs);
579 const remainingCommand = [commandBeforeHeredocs, commandFollowingHeredocs]
580 .filter((part): part is string => Boolean(part))
581 .join("\n");
582 return {
583 firstCommand,
584 remainingCommand: remainingCommand || null,
585 separator: separator
586 ? command.slice(separator.index, separator.index + separator.length)
587 : "\n",
588 };
589 }
590 if (heredocs.length > 0) {
591 separatorBeforeHeredocs ??= {
592 index,
593 length: isDoubleOperator ? 2 : 1,
594 };
595 if (isDoubleOperator) index += 1;
596 continue;
597 }
598
599 const firstCommand = commandWithoutShellComments(command, index, comments).trimStart();
600 let nextCommandIndex = index + (isDoubleOperator ? 2 : 1);
601 while (/\s/u.test(command[nextCommandIndex] ?? "")) nextCommandIndex += 1;
602 const nextCommand = command.slice(nextCommandIndex).trim();
603 return {
604 firstCommand,
605 remainingCommand: nextCommand || null,
606 separator: isDoubleOperator ? command.slice(index, index + 2) : character,
607 };
608 }
609
610 if (inComment) comments.push({ start: commentStart, end: command.length });
611 return {
612 firstCommand: commandWithoutShellComments(command, command.length, comments).trim(),
613 remainingCommand: null,
614 separator: null,
615 };
616}
617
618function commandWithoutLeadingShellComments(command: string): string | null {
619 let remainingCommand = command.trimStart();
620 while (remainingCommand.startsWith("#")) {
621 const newlineIndex = remainingCommand.indexOf("\n");
622 if (newlineIndex === -1) return null;
623 remainingCommand = remainingCommand.slice(newlineIndex + 1).trimStart();
624 }
625 return remainingCommand || null;
626}
627
628function withoutShellLineContinuations(command: string): string {
629 let normalizedCommand = "";
630 let quote: '"' | "'" | null = null;
631 let escaping = false;
632
633 for (let index = 0; index < command.length; index += 1) {
634 const character = command[index]!;
635 if (escaping) {
636 normalizedCommand += character;
637 escaping = false;
638 continue;
639 }
640 if (character === "\\" && quote !== "'") {
641 if (command[index + 1] === "\n") {
642 index += 1;
643 continue;
644 }
645 if (command[index + 1] === "\r" && command[index + 2] === "\n") {
646 index += 2;
647 continue;
648 }
649 normalizedCommand += character;
650 escaping = true;
651 continue;
652 }
653 if (quote !== null) {
654 if (character === quote) quote = null;
655 } else if (character === '"' || character === "'") {
656 quote = character;
657 }
658 normalizedCommand += character;
659 }
660
661 return normalizedCommand;
662}
663
664function indexAfterShellRedirection(tokens: ReadonlyArray<string>, index: number): number | null {
665 const token = tokens[index];
666 if (!token || /^[<>]\(/u.test(token)) return null;
667 const match = token.match(
668 /^(?:(?:(?:\d+|\*|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|<>|>>|>\||<&|>&|<|>))|&>>|&>)(.*)$/u,
669 );
670 if (!match) return null;
671 if (match[1]) return index + 1;
672 return tokens[index + 1] === undefined ? tokens.length + 1 : index + 2;
673}
674
675function serializeShellTokens(tokens: ReadonlyArray<string>): string {
676 return tokens.map((token) => `'${token.replaceAll("'", "'\\''")}'`).join(" ");
677}
678
679function transparentWrapperCommandIndex(
680 wrapper: string,
681 tokens: ReadonlyArray<string>,
682 index: number,
683): number | null {
684 if (wrapper === "bundle") {
685 return tokens[index + 1] === "exec" && tokens[index + 2] !== undefined ? index + 2 : null;
686 }
687
688 if (wrapper === "nohup") {
689 let targetIndex = index + 1;
690 if (tokens[targetIndex] === "--") targetIndex += 1;
691 const target = tokens[targetIndex];
692 return target && !target.startsWith("-") ? targetIndex : null;
693 }
694
695 if (wrapper === "script") {
696 // BSD `script` takes an output file before the optional command. Requiring
697 // an option and both operands avoids guessing about a plain `script file`.
698 return /^-[adkpqr]+$/u.test(tokens[index + 1] ?? "") && tokens[index + 3] !== undefined
699 ? index + 3
700 : null;
701 }
702
703 if (wrapper === "arch") {
704 if (/^-(?:arm64|arm64e|i386|x86_64)$/u.test(tokens[index + 1] ?? "")) {
705 return tokens[index + 2] !== undefined ? index + 2 : null;
706 }
707 return tokens[index + 1] === "-arch" && tokens[index + 3] !== undefined ? index + 3 : null;
708 }
709
710 if (wrapper === "timeout" || wrapper === "gtimeout") {
711 return /^(?:\d+(?:\.\d*)?|\.\d+)[smhd]?$/u.test(tokens[index + 1] ?? "") &&
712 tokens[index + 2] !== undefined
713 ? index + 2
714 : null;
715 }
716
717 return null;
718}
719
720function staticProgramName(value: string): string | null {
721 const trimmedValue = value.trim();
722 if (!trimmedValue || /^[A-Za-z][A-Za-z0-9+.-]*:(?![\\/])/u.test(trimmedValue)) return null;
723 const program = trimmedValue.split(/[\\/]/u).at(-1);
724 if (
725 !program ||
726 (/\s/u.test(program) && !/[\\/]/u.test(trimmedValue)) ||
727 NON_PROGRAM_PREFIX_CHARACTERS.includes(program[0] ?? "") ||
728 NON_PROGRAM_SUFFIX_CHARACTERS.includes(program.at(-1) ?? "")
729 ) {
730 return null;
731 }
732 return program;
733}
734
735function leadingPowerShellLiteral(command: string): string | null {
736 const input = command.trimStart();
737 const quote = input[0];
738 if (quote !== '"' && quote !== "'") return staticProgramName(input.match(/^\S+/u)?.[0] ?? "");
739
740 let value = "";
741 for (let index = 1; index < input.length; index += 1) {
742 const character = input[index]!;
743 if (character === "`" && input[index + 1] !== undefined) {
744 value += input[index + 1];
745 index += 1;
746 continue;
747 }
748 if (character === quote) {
749 if (quote === "'" && input[index + 1] === "'") {
750 value += "'";
751 index += 1;
752 continue;
753 }
754 return staticProgramName(value);
755 }
756 value += character;
757 }
758 return null;
759}
760
761function powerShellCallOperatorProgramName(command: string): string | null | undefined {
762 const match = command.match(/^\s*&\s+([\s\S]*)$/u);
763 return match ? leadingPowerShellLiteral(match[1]!) : undefined;
764}
765
766type PowerShellAssignment = {
767 readonly matched: boolean;
768 readonly program: string | null;
769};
770
771function powerShellAssignmentProgramName(
772 command: string,
773 depth: number,
774 remainingCommand: string | null,
775 segmentsRemaining: number,
776): PowerShellAssignment {
777 const assignment = command.match(
778 /^\s*\$(?:(env|global|local|script):)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*([\s\S]*)$/iu,
779 );
780 if (!assignment) return { matched: false, program: null };
781
782 if (assignment[1]?.toLowerCase() === "env") {
783 return {
784 matched: true,
785 program: remainingCommand
786 ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1)
787 : null,
788 };
789 }
790
791 const value = assignment[2]!.trim();
792 // The POSIX-oriented segment splitter does not balance PowerShell arrays or
793 // hashtables. Do not mistake a key after an internal semicolon for a command.
794 if (/^(?:\[ordered\]\s*)?@\s*[{(]/iu.test(value)) {
795 return { matched: true, program: null };
796 }
797 const calledProgram = powerShellCallOperatorProgramName(value);
798 if (calledProgram !== undefined) return { matched: true, program: calledProgram };
799
800 const directCommand = value.match(/^(?:@?\(\s*)?([A-Za-z][A-Za-z0-9_.-]*)\b/u)?.[1];
801 if (directCommand && !NON_DESCRIPTIVE_SHELL_PROGRAMS.has(directCommand.toLowerCase())) {
802 const parsedCommand = commandProgramNameInternal(value, depth + 1, "shell", segmentsRemaining);
803 return { matched: true, program: parsedCommand ?? directCommand };
804 }
805
806 return {
807 matched: true,
808 program: remainingCommand
809 ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1)
810 : null,
811 };
812}
813
814type WindowsShellPayload = {
815 readonly matched: boolean;
816 readonly program: string | null;
817};
818
819function windowsShellPayloadProgramName(
820 shell: string,
821 tokens: ReadonlyArray<string>,
822 start: number,
823 depth: number,
824 remainingCommand: string | null,
825 separator: string | null,
826 segmentsRemaining: number,
827): WindowsShellPayload {
828 const parsePayload = (payload: string | undefined): string | null => {
829 if (!payload) return null;
830 const command =
831 remainingCommand && separator ? `${payload} ${separator} ${remainingCommand}` : payload;
832 return commandProgramNameInternal(command, depth + 1, "shell", segmentsRemaining);
833 };
834
835 if (shell === "cmd" || shell === "cmd.exe") {
836 for (let index = start; index < tokens.length; index += 1) {
837 const option = tokens[index]!.toLowerCase();
838 if (option !== "/c" && option !== "/k") continue;
839 const payload = tokens[index + 1];
840 return {
841 matched: true,
842 program: parsePayload(payload),
843 };
844 }
845 return { matched: false, program: null };
846 }
847
848 for (let index = start; index < tokens.length; index += 1) {
849 const option = tokens[index]!.toLowerCase();
850 if (option === "-command" || option === "-c") {
851 const payload = tokens[index + 1];
852 return {
853 matched: true,
854 program: parsePayload(payload),
855 };
856 }
857 if (option === "-file" || option === "-f") {
858 return { matched: true, program: staticProgramName(tokens[index + 1] ?? "") };
859 }
860 if (option === "-encodedcommand" || option === "-enc" || option === "-e") {
861 return { matched: true, program: null };
862 }
863 if (POWERSHELL_OPTIONS_WITH_VALUE.has(option)) {
864 index += 1;
865 continue;
866 }
867 if (POWERSHELL_FLAGS.has(option)) continue;
868 if (!option.startsWith("-")) {
869 return { matched: true, program: staticProgramName(tokens[index]!) };
870 }
871 }
872 return { matched: false, program: null };
873}
874
875function startProcessProgramName(tokens: ReadonlyArray<string>, start: number): string | null {
876 for (let index = start; index < tokens.length; index += 1) {
877 const token = tokens[index]!;
878 const option = token.toLowerCase();
879 if (option === "-filepath") return staticProgramName(tokens[index + 1] ?? "");
880 if (START_PROCESS_FLAGS.has(option)) continue;
881 if (START_PROCESS_OPTIONS_WITH_VALUE.has(option)) {
882 if (tokens[index + 1] === undefined) return null;
883 index += 1;
884 continue;
885 }
886 if (token.startsWith("-")) return null;
887 return staticProgramName(token);
888 }
889 return null;
890}
891
892function literalAssignmentProgram(
893 token: string,
894): { readonly name: string; readonly program: string | null } | null {
895 const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su);
896 if (!assignment) return null;
897 const name = assignment[1]!;
898 let value = assignment[2]!.trim();
899 if (!value || /[$`]/u.test(value)) return { name, program: null };
900
901 if (value.startsWith("(") && value.endsWith(")")) {
902 const arrayTokens = tokenizeShellCommand(value.slice(1, -1));
903 value = arrayTokens?.[0] ?? "";
904 } else if (/\s/u.test(value) && !/[\\/]/u.test(value)) {
905 return { name, program: null };
906 }
907
908 return { name, program: staticProgramName(value) };
909}
910
911function referencedCommandAlias(token: string): string | null {
912 const reference = token.match(
913 /^\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)(?:\[@\])?\})$/u,
914 );
915 return reference?.[1] ?? reference?.[2] ?? null;
916}
917
918function literalCommandAliasProgramName(command: string): string | null {
919 const aliases = new Map<string, string>();
920 let remainingCommand: string | null = command;
921 let controlFlowDepth = 0;
922
923 for (let segmentCount = 0; remainingCommand && segmentCount < 64; segmentCount += 1) {
924 const commandWithoutComments = commandWithoutLeadingShellComments(remainingCommand);
925 if (commandWithoutComments === null) return null;
926 const commandSplit = splitFirstShellCommand(commandWithoutComments);
927 const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand));
928 if (tokens === null) return null;
929
930 let commandIndex = tokens[0] === "do" || tokens[0] === "then" ? 1 : 0;
931 while (commandIndex < tokens.length) {
932 const indexAfterRedirection = indexAfterShellRedirection(tokens, commandIndex);
933 if (indexAfterRedirection !== null && indexAfterRedirection <= tokens.length) {
934 commandIndex = indexAfterRedirection;
935 continue;
936 }
937 if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(tokens[commandIndex] ?? "")) {
938 commandIndex += 1;
939 continue;
940 }
941 break;
942 }
943
944 const aliasName = referencedCommandAlias(tokens[commandIndex] ?? "");
945 const aliasedProgram = aliasName ? aliases.get(aliasName) : undefined;
946 if (aliasedProgram) return aliasedProgram;
947
948 const leadingToken = tokens[0];
949 if (leadingToken === "fi" || leadingToken === "done" || leadingToken === "esac") {
950 controlFlowDepth = Math.max(0, controlFlowDepth - 1);
951 }
952 if (
953 leadingToken === "if" ||
954 leadingToken === "for" ||
955 leadingToken === "while" ||
956 leadingToken === "until" ||
957 leadingToken === "select" ||
958 leadingToken === "case"
959 ) {
960 controlFlowDepth += 1;
961 }
962
963 if (controlFlowDepth === 0 && leadingToken === "unset") {
964 for (const name of tokens.slice(1)) aliases.delete(name);
965 }
966
967 const assignmentStart = tokens[0] === "export" ? 1 : 0;
968 const assignments = tokens.slice(assignmentStart).map(literalAssignmentProgram);
969 if (
970 controlFlowDepth === 0 &&
971 assignments.length > 0 &&
972 assignments.every((assignment) => assignment !== null)
973 ) {
974 for (const assignment of assignments) {
975 if (assignment.program) aliases.set(assignment.name, assignment.program);
976 else aliases.delete(assignment.name);
977 }
978 }
979
980 remainingCommand = commandSplit.remainingCommand;
981 }
982
983 return null;
984}
985
986function wrappedShellCommandProgramName(
987 wrapper: string,
988 tokens: ReadonlyArray<string>,
989 start: number,
990 depth: number,
991 remainingCommand: string | null,
992 segmentsRemaining: number,
993): string | null {
994 let index = start;
995
996 if (wrapper === "command") {
997 while (index < tokens.length) {
998 const option = tokens[index]!;
999 if (option === "--") {
1000 index += 1;
1001 break;
1002 }
1003 if (!option.startsWith("-") || option === "-") break;
1004 if (option !== "-p") return null;
1005 index += 1;
1006 }
1007 } else if (wrapper === "builtin") {
1008 if (tokens[index] === "--") index += 1;
1009 else if (tokens[index]?.startsWith("-")) return null;
1010 } else if (wrapper === "exec") {
1011 while (index < tokens.length) {
1012 const option = tokens[index]!;
1013 if (option === "--") {
1014 index += 1;
1015 break;
1016 }
1017 if (option === "-a") {
1018 if (tokens[index + 1] === undefined) return null;
1019 index += 2;
1020 continue;
1021 }
1022 if (/^-a.+/u.test(option) || /^-[cl]+$/u.test(option)) {
1023 index += 1;
1024 continue;
1025 }
1026 if (option.startsWith("-") && option !== "-") return null;
1027 break;
1028 }
1029 }
1030
1031 const wrappedTokens = tokens.slice(index);
1032 if (wrappedTokens.length === 0) return null;
1033 let targetIndex = 0;
1034 while (targetIndex < wrappedTokens.length) {
1035 const indexAfterRedirection = indexAfterShellRedirection(wrappedTokens, targetIndex);
1036 if (indexAfterRedirection === null || indexAfterRedirection > wrappedTokens.length) break;
1037 targetIndex = indexAfterRedirection;
1038 }
1039 const target = wrappedTokens[targetIndex];
1040 if (target && /^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(target)) return null;
1041
1042 const wrappedProgram = commandProgramNameInternal(
1043 serializeShellTokens(wrappedTokens),
1044 depth + 1,
1045 wrapper === "exec" ? "exec" : "shell",
1046 segmentsRemaining,
1047 );
1048 if (wrappedProgram !== null) return wrappedProgram;
1049
1050 if (
1051 wrapper !== "exec" &&
1052 target &&
1053 target === target.toLowerCase() &&
1054 NON_DESCRIPTIVE_SHELL_PROGRAMS.has(target) &&
1055 !TERMINAL_SHELL_PROGRAMS.has(target) &&
1056 remainingCommand
1057 ) {
1058 return commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1);
1059 }
1060 return null;
1061}
1062
1063function parseCommandProgramName(
1064 command: string,
1065 depth: number,
1066 context: CommandProgramContext,
1067 segmentsRemaining: number,
1068): string | null {
1069 if (depth >= 8 || segmentsRemaining <= 0) return null;
1070 const commandWithoutComments = commandWithoutLeadingShellComments(command);
1071 if (commandWithoutComments === null) return null;
1072 if (
1073 /^(?:catch|finally|for|foreach|function|if|param|switch|try|while)\s*[{(]/iu.test(
1074 commandWithoutComments,
1075 )
1076 ) {
1077 return null;
1078 }
1079 if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)\s*\{/u.test(commandWithoutComments)) return null;
1080 // `&&` and `||` inside a `[[ ... ]]` expression are not top-level command
1081 // separators. Keep the label conservative instead of scanning the test body.
1082 if (commandWithoutComments.startsWith("[[")) return null;
1083 const commandSplit = splitFirstShellCommand(commandWithoutComments);
1084 if (/^@["'](?:\r?\n)/u.test(commandSplit.firstCommand.trimStart())) {
1085 return commandSplit.remainingCommand
1086 ? commandProgramNameInternal(
1087 commandSplit.remainingCommand,
1088 depth,
1089 "shell",
1090 segmentsRemaining - 1,
1091 )
1092 : null;
1093 }
1094 const powerShellAssignment = powerShellAssignmentProgramName(
1095 commandSplit.firstCommand,
1096 depth,
1097 commandSplit.remainingCommand,
1098 segmentsRemaining,
1099 );
1100 if (powerShellAssignment.matched) return powerShellAssignment.program;
1101 const windowsPath = commandSplit.firstCommand.match(
1102 /^\s*((?:\.{1,2}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*)\\\S+)/iu,
1103 )?.[1];
1104 if (windowsPath) return staticProgramName(windowsPath);
1105 const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand));
1106 if (tokens === null) return null;
1107 const firstCharacter = commandSplit.firstCommand.trimStart()[0];
1108 if (
1109 tokens.length === 1 &&
1110 (firstCharacter === '"' || firstCharacter === "'") &&
1111 /[\s()=]/u.test(tokens[0] ?? "") &&
1112 !/[\\/]/u.test(tokens[0] ?? "")
1113 ) {
1114 return commandSplit.remainingCommand
1115 ? commandProgramNameInternal(
1116 commandSplit.remainingCommand,
1117 depth,
1118 context,
1119 segmentsRemaining - 1,
1120 )
1121 : null;
1122 }
1123 let index = 0;
1124 let wrapper: CommandWrapper | null = null;
1125 let executionContext = context;
1126 let sawAssignment = false;
1127 let sawRedirection = false;
1128
1129 while (index < tokens.length) {
1130 const token = tokens[index];
1131 if (!token) return null;
1132 const indexAfterRedirection = indexAfterShellRedirection(tokens, index);
1133 if (indexAfterRedirection !== null) {
1134 if (indexAfterRedirection > tokens.length) return null;
1135 sawRedirection = true;
1136 index = indexAfterRedirection;
1137 continue;
1138 }
1139 if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token)) {
1140 sawAssignment = true;
1141 index += 1;
1142 continue;
1143 }
1144 if (executionContext === "shell" && token === ":") {
1145 return commandSplit.remainingCommand
1146 ? commandProgramNameInternal(
1147 commandSplit.remainingCommand,
1148 depth,
1149 executionContext,
1150 segmentsRemaining - 1,
1151 )
1152 : null;
1153 }
1154 if (
1155 NON_PROGRAM_PREFIX_CHARACTERS.includes(token[0] ?? "") &&
1156 !(token.startsWith("$") && token.includes("/"))
1157 ) {
1158 return executionContext === "shell" && token.startsWith("[") && commandSplit.remainingCommand
1159 ? commandProgramNameInternal(
1160 commandSplit.remainingCommand,
1161 depth,
1162 executionContext,
1163 segmentsRemaining - 1,
1164 )
1165 : null;
1166 }
1167 const tokenProgram = token.split(/[\\/]/).at(-1);
1168 const isUnqualifiedToken = token === tokenProgram;
1169 if (tokenProgram === "env" || tokenProgram === "sudo") {
1170 wrapper = tokenProgram;
1171 executionContext = "exec";
1172 index += 1;
1173 continue;
1174 }
1175 if (wrapper !== null && token === "--") {
1176 wrapper = null;
1177 index += 1;
1178 continue;
1179 }
1180 if (wrapper !== null && token.startsWith("-")) {
1181 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
1182 const splitCommand = tokens[index + 1];
1183 return splitCommand
1184 ? commandProgramNameInternal(splitCommand, depth + 1, executionContext, segmentsRemaining)
1185 : null;
1186 }
1187 if (wrapper === "env" && token.startsWith("--split-string=")) {
1188 return commandProgramNameInternal(
1189 token.slice("--split-string=".length),
1190 depth + 1,
1191 executionContext,
1192 segmentsRemaining,
1193 );
1194 }
1195 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
1196 if (tokens[index + 1] === undefined) return null;
1197 index += 2;
1198 continue;
1199 }
1200 if (COMMAND_WRAPPER_FLAGS[wrapper].has(token)) {
1201 index += 1;
1202 continue;
1203 }
1204 const equalsIndex = token.indexOf("=");
1205 if (token.startsWith("--") && equalsIndex > 2) {
1206 if (!COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token.slice(0, equalsIndex))) {
1207 return null;
1208 }
1209 index += 1;
1210 continue;
1211 }
1212 if (/^-[A-Za-z].+/.test(token) && !token.startsWith("--")) {
1213 let consumesNextToken = false;
1214 for (const [optionIndex, option] of token.slice(1).split("").entries()) {
1215 const shortOption = `-${option}`;
1216 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(shortOption)) {
1217 consumesNextToken = optionIndex === token.length - 2;
1218 break;
1219 }
1220 if (!COMMAND_WRAPPER_FLAGS[wrapper].has(shortOption)) return null;
1221 }
1222 if (consumesNextToken && tokens[index + 1] === undefined) return null;
1223 index += consumesNextToken ? 2 : 1;
1224 continue;
1225 }
1226 return null;
1227 }
1228 if (tokenProgram && SHELL_PROGRAMS.has(tokenProgram.replace(/\.exe$/i, "").toLowerCase())) {
1229 const scriptIndex = shellCommandArgumentIndex(tokens, index + 1);
1230 if (scriptIndex !== null) {
1231 const script = tokens[scriptIndex];
1232 return script
1233 ? commandProgramNameInternal(script, depth + 1, "shell", segmentsRemaining)
1234 : null;
1235 }
1236 }
1237 const lowerTokenProgram = tokenProgram?.toLowerCase();
1238 if (lowerTokenProgram && WINDOWS_SHELL_PROGRAMS.has(lowerTokenProgram)) {
1239 const payload = windowsShellPayloadProgramName(
1240 lowerTokenProgram,
1241 tokens,
1242 index + 1,
1243 depth,
1244 commandSplit.remainingCommand,
1245 commandSplit.separator,
1246 segmentsRemaining,
1247 );
1248 if (payload.matched) return payload.program;
1249 }
1250 if (lowerTokenProgram === "start-process") {
1251 const startedProgram = startProcessProgramName(tokens, index + 1);
1252 if (startedProgram !== null) return startedProgram;
1253 }
1254 if (
1255 executionContext === "shell" &&
1256 isUnqualifiedToken &&
1257 tokenProgram &&
1258 SHELL_PRECOMMAND_MODIFIERS.has(tokenProgram)
1259 ) {
1260 index += 1;
1261 if (tokenProgram === "time" && tokens[index] === "-p") index += 1;
1262 if (tokens[index]?.startsWith("-")) return null;
1263 continue;
1264 }
1265 if (isUnqualifiedToken && tokenProgram) {
1266 const targetIndex = transparentWrapperCommandIndex(tokenProgram, tokens, index);
1267 if (targetIndex !== null) {
1268 const wrappedProgram = commandProgramNameInternal(
1269 serializeShellTokens(tokens.slice(targetIndex)),
1270 depth + 1,
1271 "exec",
1272 segmentsRemaining,
1273 );
1274 if (wrappedProgram !== null) return wrappedProgram;
1275 }
1276 }
1277 if (isUnqualifiedToken && tokenProgram && SHELL_COMMAND_WRAPPERS.has(tokenProgram)) {
1278 return wrappedShellCommandProgramName(
1279 tokenProgram,
1280 tokens,
1281 index + 1,
1282 depth,
1283 commandSplit.remainingCommand,
1284 segmentsRemaining,
1285 );
1286 }
1287 if (
1288 (executionContext === "shell" ||
1289 (wrapper === "sudo" && tokenProgram && SKIPPABLE_SUDO_PROBES.has(tokenProgram))) &&
1290 isUnqualifiedToken &&
1291 tokenProgram &&
1292 NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram) &&
1293 (!TERMINAL_SHELL_PROGRAMS.has(tokenProgram) ||
1294 (tokenProgram === "false" && commandSplit.separator !== "&&")) &&
1295 commandSplit.remainingCommand
1296 ) {
1297 return commandProgramNameInternal(
1298 commandSplit.remainingCommand,
1299 depth,
1300 "shell",
1301 segmentsRemaining - 1,
1302 );
1303 }
1304 if (
1305 executionContext === "shell" &&
1306 isUnqualifiedToken &&
1307 lowerTokenProgram &&
1308 POWERSHELL_SETUP_PROGRAMS.has(lowerTokenProgram) &&
1309 commandSplit.remainingCommand
1310 ) {
1311 return commandProgramNameInternal(
1312 commandSplit.remainingCommand,
1313 depth,
1314 "shell",
1315 segmentsRemaining - 1,
1316 );
1317 }
1318 if (
1319 !tokenProgram ||
1320 (isUnqualifiedToken && NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram)) ||
1321 NON_PROGRAM_PREFIX_CHARACTERS.includes(tokenProgram[0] ?? "") ||
1322 NON_PROGRAM_SUFFIX_CHARACTERS.includes(tokenProgram.at(-1) ?? "") ||
1323 tokenProgram.endsWith("()") ||
1324 /^[A-Za-z_][A-Za-z0-9_]*\(\)\{$/u.test(tokenProgram)
1325 ) {
1326 return null;
1327 }
1328 return tokenProgram || null;
1329 }
1330
1331 if ((sawAssignment || sawRedirection) && wrapper === null && commandSplit.remainingCommand) {
1332 return commandProgramNameInternal(
1333 commandSplit.remainingCommand,
1334 depth,
1335 executionContext,
1336 segmentsRemaining - 1,
1337 );
1338 }
1339 return null;
1340}
1341
1342function commandProgramNameInternal(
1343 command: string,
1344 depth: number,
1345 context: CommandProgramContext,
1346 segmentsRemaining = MAX_COMMAND_SEGMENTS,
1347): string | null {
1348 if (segmentsRemaining <= 0) return null;
1349 const calledProgram = powerShellCallOperatorProgramName(command);
1350 if (calledProgram !== undefined) return calledProgram;
1351 return (
1352 parseCommandProgramName(command, depth, context, segmentsRemaining) ??
1353 (context === "shell" ? literalCommandAliasProgramName(command) : null)
1354 );
1355}
1356
1357export function commandProgramName(command: string, depth = 0): string | null {
1358 return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS);
1359}
1360
1361// Escape bytes and other control characters render as invisible gaps; display
1362// shows them as Unicode control pictures (ESC becomes ␛) instead. Tab, newline
1363// and carriage return lay out as whitespace, so CRLF scripts stay unmarked.
1364const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu;
1365
1366export function commandDisplayText(command: string): string {
1367 return withVisibleControlCharacters(withoutShellWrapper(command));
1368}
1369
1370export function withVisibleControlCharacters(text: string): string {
1371 return text.replace(CONTROL_CHARACTERS, (character) =>
1372 character === "\u007f" ? "␡" : String.fromCharCode(0x2400 + character.charCodeAt(0)),
1373 );
1374}
1375
1376// A wrapper whose quoting does not balance is kept: the script's end cannot be
1377// known, which happens when Codex's secret redaction removes the backslash of
1378// an escaped quote inside `-lc "…"`.
1379function withoutShellWrapper(command: string): string {
1380 const trimmed = command.trim();
1381 const split = splitFirstShellCommand(trimmed);
1382 if (split.remainingCommand !== null) return trimmed;
1383 const tokens = tokenizeShellCommand(trimmed);
1384 const program = tokens?.[0]
1385 ?.split(/[\\/]/u)
1386 .at(-1)
1387 ?.replace(/\.exe$/iu, "");
1388 if (!tokens || !program || !SHELL_PROGRAMS.has(program)) return trimmed;
1389 const scriptIndex = shellCommandArgumentIndex(tokens, 1);
1390 // Positional arguments can affect the script; keep those invocations intact.
1391 if (scriptIndex === null || scriptIndex !== tokens.length - 1) return trimmed;
1392 return tokens[scriptIndex]?.trim() || trimmed;
1393}
1394
1395const POWERSHELL_PROGRAMS = new Set(["powershell", "pwsh"]);
1396
1397const POWERSHELL_VERBS = new Set(
1398 `Add Approve Assert Backup Block Build Checkpoint Clear Close Compare Complete Compress Confirm Connect Convert ConvertFrom ConvertTo Copy Debug Deny Deploy Disable Disconnect Dismount Edit Enable Enter Exit Expand Export Find ForEach Format Get Grant Group Hide Import Initialize Install Invoke Join Limit Lock Measure Merge Mount Move New Open Optimize Out Ping Pop Protect Publish Push Read Receive Redo Register Remove Rename Repair Request Reset Resize Resolve Restart Restore Resume Revoke Save Search Select Send Set Show Skip Sort Split Start Step Stop Submit Suspend Switch Sync Tee Test Trace Unblock Undo Uninstall Unlock Unprotect Unpublish Unregister Update Use Wait Watch Where Write`.split(
1399 " ",
1400 ),
1401);
1402
1403function isPowerShellCmdlet(word: string): boolean {
1404 const cmdlet = /^([A-Z][a-z]+(?:[A-Z][a-z]+)?)-[A-Z][A-Za-z]*$/u.exec(word);
1405 return cmdlet !== null && POWERSHELL_VERBS.has(cmdlet[1]!);
1406}
1407
1408export function commandHighlightLanguage(command: string): "powershell" | "shellscript" {
1409 const program = /^(?:&\s*)?(?:"([^"]*)"|'([^']*)'|(\S+))/u.exec(command.trim());
1410 const name = (program?.[1] ?? program?.[2] ?? program?.[3] ?? "")
1411 .split(/[\\/]/u)
1412 .at(-1)
1413 ?.toLowerCase()
1414 .replace(/\.exe$/u, "");
1415 if (name && POWERSHELL_PROGRAMS.has(name)) return "powershell";
1416 return isPowerShellScript(command) ? "powershell" : "shellscript";
1417}
1418
1419function isPowerShellScript(command: string): boolean {
1420 let rest: string | null = command.trim();
1421 if (/^&\s*\S/u.test(rest)) return true;
1422 for (let segment = 0; rest && segment < MAX_COMMAND_SEGMENTS; segment += 1) {
1423 const { firstCommand, remainingCommand } = splitFirstShellCommand(rest);
1424 const statement = firstCommand.trim();
1425 if (
1426 /^\$(?:env|global|local|script):/iu.test(statement) ||
1427 /^\$[A-Za-z_]\w*\s+=/u.test(statement) ||
1428 isPowerShellCmdlet(statement.match(/^\S+/u)?.[0] ?? "")
1429 ) {
1430 return true;
1431 }
1432 rest = remainingCommand;
1433 }
1434 return false;
1435}