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