authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-03 22:20:10-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 22:20:10-05:00
log0cb02abf5b3af2985d9dd23a637a63388e98fd49
tree0252ae1b9e532d6c4a269a93d13683078627274c
parent42bdea1c9c1d4b7c5c2e77cd23cf53fae68a6fd4
signature Signed by PGP key B5690EEEBB952194

fix: better shell syntax handling for labels (#9371)


6 files changed, 1683 insertions(+), 11 deletions(-)

apps/mobile/src/lib/threadActivity.test.ts+43
......@@ -1990,6 +1990,49 @@ describe("buildThreadFeed", () => {
19901990 },
19911991 );
19921992
1993 it("preserves serialized shell wrappers with non-matching boundary quotes", () => {
1994 const turnId = TurnId.make("turn-serialized-shell-wrapper");
1995 const command =
1996 "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"";
1997 const thread = makeThread({
1998 id: ThreadId.make("thread-serialized-shell-wrapper"),
1999 projectId: ProjectId.make("project-1"),
2000 title: "Serialized shell wrapper",
2001 latestTurn: {
2002 turnId,
2003 state: "running",
2004 requestedAt: "2026-04-01T00:00:00.000Z",
2005 startedAt: "2026-04-01T00:00:00.000Z",
2006 completedAt: null,
2007 assistantMessageId: null,
2008 },
2009 activities: [
2010 makeActivity({
2011 id: EventId.make("serialized-shell-wrapper"),
2012 kind: "tool.updated",
2013 tone: "tool",
2014 summary: "Ran command",
2015 createdAt: "2026-04-01T00:00:01.000Z",
2016 turnId,
2017 payload: {
2018 itemType: "command_execution",
2019 status: "inProgress",
2020 data: { item: { command } },
2021 },
2022 }),
2023 ],
2024 });
2025
2026 const feed = buildThreadFeed(thread);
2027 expect(feed[0]).toMatchObject({
2028 type: "activity-group",
2029 activities: [{ workEntry: { command } }],
2030 });
2031 if (feed[0]?.type === "activity-group") {
2032 expect(feed[0].activities[0]?.workEntry.rawCommand).toBeUndefined();
2033 }
2034 });
2035
19932036 it.each([
19942037 ["inProgress", true],
19952038 ["completed", false],
apps/mobile/src/lib/threadActivity.ts+5
......@@ -1001,6 +1001,11 @@ function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): stri
10011001 return null;
10021002 }
10031003
1004 const openingQuote = command[0];
1005 if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) {
1006 return null;
1007 }
1008
10041009 const unwrapped = trimMatchingOuterQuotes(command);
10051010 return unwrapped.length > 0 ? unwrapped : null;
10061011}
apps/web/src/session-logic.test.ts+20
......@@ -1578,6 +1578,26 @@ describe("deriveWorkLogEntries", () => {
15781578 expect(entry?.rawCommand).toBeUndefined();
15791579 });
15801580
1581 it("preserves serialized shell wrappers with non-matching boundary quotes", () => {
1582 const command =
1583 "/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"";
1584 const activities: OrchestrationThreadActivity[] = [
1585 makeActivity({
1586 id: "command-tool-serialized-wrapper",
1587 kind: "tool.completed",
1588 summary: "Ran command",
1589 payload: {
1590 itemType: "command_execution",
1591 data: { item: { command } },
1592 },
1593 }),
1594 ];
1595
1596 const [entry] = deriveWorkLogEntries(activities);
1597 expect(entry?.command).toBe(command);
1598 expect(entry?.rawCommand).toBeUndefined();
1599 });
1600
15811601 it("keeps compact Codex tool metadata used for icons and labels", () => {
15821602 const activities: OrchestrationThreadActivity[] = [
15831603 makeActivity({
apps/web/src/session-logic.ts+5
......@@ -1381,6 +1381,11 @@ function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): stri
13811381 return null;
13821382 }
13831383
1384 const openingQuote = command[0];
1385 if ((openingQuote === "'" || openingQuote === '"') && !command.endsWith(openingQuote)) {
1386 return null;
1387 }
1388
13841389 const unwrapped = trimMatchingOuterQuotes(command);
13851390 return unwrapped.length > 0 ? unwrapped : null;
13861391}
packages/client-runtime/src/work-log/commandLabel.test.ts+422
......@@ -17,6 +17,7 @@ describe("commandProgramName", () => {
1717 ["env CI=1 /bin/zsh -lc '\"/Applications/My Tools/bin/check\" --verbose'", "check"],
1818 ["bash -lc \"zsh -c 'git status'\"", "git"],
1919 ['"C:\\Program Files\\Git\\bin\\bash.exe" -lc "git status"', "git"],
20 ["/bin/zsh -lc 'git status\nsed -n '\"'1,20p' apps/web/src/components/DiffPanel.tsx\"", "git"],
2021 ])("unwraps shell scripts without executing them: %s", (command, program) => {
2122 expect(commandProgramName(command)).toBe(program);
2223 });
......@@ -32,6 +33,9 @@ describe("commandProgramName", () => {
3233 ["bash -- -c 'git status'", "bash"],
3334 ["bash --rcfile config.sh", "bash"],
3435 ["my-shell -c 'git status'", "my-shell"],
36 ["$HOME/.bun/bin/bun test", "bun"],
37 ['"$ANDROID_HOME/emulator/emulator" -list-avds', "emulator"],
38 ["${ROOT}/bin/tool --version", "tool"],
3539 ])("preserves ordinary programs and actual shell launches: %s", (command, program) => {
3640 expect(commandProgramName(command)).toBe(program);
3741 });
......@@ -47,6 +51,402 @@ describe("commandProgramName", () => {
4751 expect(commandProgramName(command)).toBeNull();
4852 });
4953
54 it.each([
55 "if test -f package.json; then vp test; fi",
56 "[ -f package.json ]",
57 "[[ -f package.json ]]",
58 "test -f package.json",
59 'for file in *; do echo "$file"; done',
60 "while true; do sleep 1; done",
61 "until false; do sleep 1; done",
62 "case $name in test) vp test;; esac",
63 'select item in one two; do echo "$item"; done',
64 "function check() { vp test; }",
65 "check() { vp test; }",
66 "k(){ echo ok; }; k",
67 "{ vp test; }",
68 "(vp test)",
69 "(( count += 1 ))",
70 "! vp test",
71 ":",
72 ". ./script.sh",
73 "source ./script.sh",
74 "eval 'vp test'",
75 "cd packages/client-runtime",
76 "export NODE_ENV=test",
77 "local name=value",
78 "set -e",
79 "alias ll='ls -la'",
80 "repeat 3 echo ok",
81 "and vp test",
82 "return 1",
83 "break",
84 "continue",
85 "true",
86 "false",
87 ])("falls back for shell syntax and internal control commands: %s", (command) => {
88 expect(commandProgramName(command)).toBeNull();
89 });
90
91 it.each([
92 ['rg -n "if|for|while" src', "rg"],
93 ["printf '%s\\n' 'a;b|c'", "printf"],
94 ["node -e \"if (true) console.log('ok')\"", "node"],
95 ["echo '$(git status)'", "echo"],
96 ["vp test && git status", "vp"],
97 ["vp test || git status", "vp"],
98 ["rg needle src | head", "rg"],
99 ["vp test; git status", "vp"],
100 ["vp test &", "vp"],
101 ["vp test\ngit status", "vp"],
102 ['echo "$(git status)"', "echo"],
103 ["echo `git status`", "echo"],
104 ["cat <(rg needle src)", "cat"],
105 ])("uses the first executable-looking program: %s", (command, program) => {
106 expect(commandProgramName(command)).toBe(program);
107 });
108
109 it.each([
110 ["cd packages/client-runtime && vp test run", "vp"],
111 ['cd "a path with spaces"; git status', "git"],
112 ["cd apps/web\npnpm test", "pnpm"],
113 ["cd first && cd second && bun test", "bun"],
114 ["CI=1 cd apps/web && npm test", "npm"],
115 ["PATH+=:/tools npm test", "npm"],
116 ["PATH+=:/tools && npm test", "npm"],
117 ['TMP=$(mktemp -d); cd "$TMP"; npm pack ./package', "npm"],
118 ["cd $(find . -type d | head -1) && git status", "git"],
119 ["cd `find . -type d | head -1` && node script.js", "node"],
120 ["cd /tmp 2>&1 && npm test", "npm"],
121 ["cd /tmp 2<&0 && pnpm test", "pnpm"],
122 ["cd /tmp &>/dev/null && bun test", "bun"],
123 ["cd work |& npm test", "npm"],
124 ["cd /tmp && # use the selected workspace\nnpm test", "npm"],
125 ["export CI=1; # first note\n# second note\npnpm test", "pnpm"],
126 ["cd&&npm test", "npm"],
127 ["export CI=1;pnpm test", "pnpm"],
128 ["cd ${ROOT:-path;with;semicolons} && bun test", "bun"],
129 ["cd ${ROOT:-path&&fallback} && node app.js", "node"],
130 ["cd @(first|second) && npm test", "npm"],
131 ["cd /tmp \\\n&& npm test", "npm"],
132 ["/bin/zsh -lc 'cd apps/web && vp test run'", "vp"],
133 ])("skips leading cd commands and uses the next useful program: %s", (command, program) => {
134 expect(commandProgramName(command)).toBe(program);
135 });
136
137 it.each([
138 ["source ~/.nvm/nvm.sh && nvm use", "nvm"],
139 [". ./.env && pnpm test", "pnpm"],
140 ["export CI=1 && vp test run", "vp"],
141 ["unset DEBUG; node app.js", "node"],
142 ["export CI=1 && cd apps/web && pnpm test", "pnpm"],
143 ["/bin/zsh -lc 'source ~/.nvm/nvm.sh && nvm use'", "nvm"],
144 ])("skips shell setup commands and uses the next useful program: %s", (command, program) => {
145 expect(commandProgramName(command)).toBe(program);
146 });
147
148 it.each([
149 ["set -eu; npm test", "npm"],
150 [": && npm test", "npm"],
151 ["true && npm test", "npm"],
152 ["false || npm test", "npm"],
153 ["false; npm test", "npm"],
154 ["sudo -n true && npm test", "npm"],
155 ["sudo -n true; echo checked", "echo"],
156 ["test -d node_modules || vp i", "vp"],
157 ["[ -d node_modules ] || vp i", "vp"],
158 ])("skips non-descriptive shell commands before a useful program: %s", (command, program) => {
159 expect(commandProgramName(command)).toBe(program);
160 });
161
162 it.each(
163 ["cd /tmp", "export CI=1", "unset DEBUG", "source env.sh", ". env.sh"].flatMap((setup) =>
164 ["&&", " || ", ";", "\n", "|", " |& ", " & "].map(
165 (operator) => [`${setup}${operator}npm test`, "npm"] as const,
166 ),
167 ),
168 )("handles shell setup followed by every command separator: %s", (command, program) => {
169 expect(commandProgramName(command)).toBe(program);
170 });
171
172 it.each([
173 ["command git status", "git"],
174 ["command -p git status", "git"],
175 ["command -- git status", "git"],
176 ["builtin printf ok", "printf"],
177 ["builtin -- echo ok", "echo"],
178 ["command cd /tmp && npm test", "npm"],
179 ["builtin cd /tmp && pnpm test", "pnpm"],
180 ["exec node app.js", "node"],
181 ["exec -cl -a worker node app.js", "node"],
182 ["exec env CI=1 /opt/tools/check --verbose", "check"],
183 ['exec "C:\\Program Files\\nodejs\\node.exe" app.js', "node.exe"],
184 ["exec sh -c 'cd /tmp && npm test'", "npm"],
185 ["exec sh -c 'cd /tmp\nnpm test'", "npm"],
186 ["exec bash -c 'set -e\nnpm test'", "npm"],
187 ])("unwraps shell command wrappers: %s", (command, program) => {
188 expect(commandProgramName(command)).toBe(program);
189 });
190
191 it.each([
192 ["timeout 10 pnpm test", "pnpm"],
193 ["timeout 10s python3 script.py", "python3"],
194 ["gtimeout 1.5 node app.js", "node"],
195 ["nohup npx expo start >/tmp/metro.log 2>&1 &", "npx"],
196 ["nohup -- env CI=1 bun test", "bun"],
197 ["arch -x86_64 ./build/app-under-test", "app-under-test"],
198 ["arch -arch arm64 /opt/tools/check", "check"],
199 ["bundle exec pod install", "pod"],
200 ["timeout 30 nohup env CI=1 node app.js", "node"],
201 ["timeout 60 script -q /dev/null env CI=1 node app.js", "node"],
202 ])("unwraps process-launch wrappers: %s", (command, program) => {
203 expect(commandProgramName(command)).toBe(program);
204 });
205
206 it.each([
207 ["& 'C:\\Program Files\\nodejs\\node.exe' script.js", "node.exe"],
208 ['& "$env:WINDIR\\Microsoft.NET\\Framework64\\v4\\csc.exe" file.cs', "csc.exe"],
209 ])("resolves literal PowerShell call operators: %s", (command, program) => {
210 expect(commandProgramName(command)).toBe(program);
211 });
212
213 it.each([
214 ["$env:CI='1'; npm test", "npm"],
215 ["$value = 'configured'; node app.js", "node"],
216 ["$process = Get-Process node; $process.Id", "Get-Process"],
217 ["$tmp = Join-Path $env:TEMP repo; git clone example", "Join-Path"],
218 ["$html = (Invoke-WebRequest https://example.com).Content", "Invoke-WebRequest"],
219 ["$process = Start-Process -FilePath .\\app.exe -PassThru; $process.Id", "app.exe"],
220 ])("labels commands inside simple PowerShell assignments: %s", (command, program) => {
221 expect(commandProgramName(command)).toBe(program);
222 });
223
224 it.each([
225 ["cmd /c bcdedit /enum", "bcdedit"],
226 ["cmd /c cd C:\\work && npm test", "npm"],
227 ['cmd.exe /d /s /c "cd C:\\work && npm test"', "npm"],
228 [
229 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "C:\\work\\scripts\\doctor.ps1"',
230 "doctor.ps1",
231 ],
232 ['pwsh -NoProfile -Command "Set-Location C:\\work; pnpm test"', "pnpm"],
233 ["pwsh -Command Set-Location C:\\work; pnpm test", "pnpm"],
234 ])("unwraps Windows shell launchers: %s", (command, program) => {
235 expect(commandProgramName(command)).toBe(program);
236 });
237
238 it.each([
239 ["Set-Location C:\\work; npm test", "npm"],
240 ["Push-Location C:\\work; node app.js", "node"],
241 ["Start-Process -FilePath node -ArgumentList server.js", "node"],
242 ["Start-Process -ArgumentList '-FilePath helper' node", "node"],
243 ["Start-Process -ErrorAction Stop -FilePath node", "node"],
244 ["Start-Process -NoNewWindow -WorkingDirectory C:\\work node", "node"],
245 ['Start-Process "C:\\Program Files\\Example\\app.exe"', "app.exe"],
246 ['Start-Process -FilePath ".\\dist\\Example App.exe" -PassThru', "Example App.exe"],
247 [".\\.venv\\Scripts\\python.exe script.py", "python.exe"],
248 ["$env:LOCALAPPDATA\\Programs\\tool.exe --version", "tool.exe"],
249 ['"=== CHECK FILE ==="; Get-Content file.txt', "Get-Content"],
250 ["$x = @'\ndata'; Get-Fake\n'@\nGet-Process", "Get-Process"],
251 ["@'\nprint('ok; still data')\n'@ | python -", "python"],
252 ])("handles common PowerShell setup and launch commands: %s", (command, program) => {
253 expect(commandProgramName(command)).toBe(program);
254 });
255
256 it.each([
257 ["timeout --help", "timeout"],
258 ["nohup --version", "nohup"],
259 ["arch", "arch"],
260 ["bundle install", "bundle"],
261 ["script output.log", "script"],
262 ["/usr/bin/timeout 10 node app.js", "timeout"],
263 ])("keeps process-launch wrappers when no safe payload is present: %s", (command, program) => {
264 expect(commandProgramName(command)).toBe(program);
265 });
266
267 it.each([
268 "command -v git",
269 "command -V git",
270 "command -a git",
271 "command -pv git",
272 "builtin -p",
273 "exec",
274 "exec > output.log",
275 "exec --",
276 "exec cd /tmp && npm test",
277 "exec env CI=1 cd /tmp && npm test",
278 "exec CI=1 npm test",
279 "env CI=1 cd /tmp && npm test",
280 "env CI=1; npm test",
281 "sudo cd /tmp && npm test",
282 "command CI=1 npm test",
283 "command false && npm test",
284 "cmd /c false && npm test",
285 "cd /tmp <<EOF\nunterminated heredoc",
286 "cd /tmp && >/tmp/log",
287 "(xcrun simctl io booted recordVideo /tmp/video.mp4 &) ; wait",
288 "export CI=1 && (bundle exec pod install || pod install)",
289 "$PY scripts/check.py",
290 "${TOOL} --version",
291 "%TOOL% --version",
292 "!TOOL! --version",
293 "& $tool --version",
294 "& { Get-Process }",
295 "$value = 'configured'",
296 "$headers = @{ 'Accept' = 'application/json'; 'Content-Type' = 'application/json' }; Invoke-WebRequest https://example.com",
297 '"sha256(value)=$hash"',
298 "broken{",
299 "@echo off",
300 ":: comment",
301 "time -- npm test",
302 "time -v npm test",
303 "coproc npm test",
304 "coproc worker { npm test; }",
305 "cd [first|second] && pnpm test",
306 "npm) --version",
307 "try { Invoke-WebRequest https://example.com } catch { Write-Error $_ }",
308 "for($i=0; $i -lt 2; $i++){ Start-Sleep 1 }",
309 "# comment only",
310 ])("does not treat shell lookup and commandless wrapper forms as executions: %s", (command) => {
311 expect(commandProgramName(command)).toBeNull();
312 });
313
314 it.each([
315 ["parallel -j4", "parallel"],
316 ["hash --help", "hash"],
317 ["process --help", "process"],
318 ["rem comment", "rem"],
319 ["Exec node app.js", "Exec"],
320 ["CD /tmp && npm test", "CD"],
321 ])("does not hide legitimate or case-distinct program names: %s", (command, program) => {
322 expect(commandProgramName(command)).toBe(program);
323 });
324
325 it.each([
326 ["env sh -c 'cd /tmp && npm test'", "npm"],
327 ["sudo zsh -lc 'export CI=1 && pnpm test'", "pnpm"],
328 ])("parses shell setup inside an explicitly launched shell: %s", (command, program) => {
329 expect(commandProgramName(command)).toBe(program);
330 });
331
332 it.each([
333 ["nocorrect pnpm test", "pnpm"],
334 ["noglob bun test", "bun"],
335 ["time node app.js", "node"],
336 ["time -p deno test", "deno"],
337 ["time nocorrect npm test", "npm"],
338 ])("skips shell precommand modifiers: %s", (command, program) => {
339 expect(commandProgramName(command)).toBe(program);
340 });
341
342 it("uses the command after leading shell comments", () => {
343 expect(commandProgramName("# first comment\n # second comment\ngit status")).toBe("git");
344 });
345
346 it.each([
347 ["CI=1 # note\nnpm test", "npm"],
348 ["CI=1 # it's configured\nnpm test", "npm"],
349 ['CI=1 # "unterminated quote\nbun test', "bun"],
350 [">/tmp/log # note\npnpm test", "pnpm"],
351 [">/tmp/log # it's configured\ndeno test", "deno"],
352 ])("skips comments after commandless shell setup: %s", (command, program) => {
353 expect(commandProgramName(command)).toBe(program);
354 });
355
356 it.each([
357 ["./cd /tmp && npm test", "cd"],
358 ["/opt/exec node app.js", "exec"],
359 ["/usr/bin/time npm test", "time"],
360 ["/usr/bin/test -f package.json", "test"],
361 ])("does not treat qualified paths as shell syntax: %s", (command, program) => {
362 expect(commandProgramName(command)).toBe(program);
363 });
364
365 it("skips a shell array assignment before the command", () => {
366 expect(commandProgramName("items=(one two); npm test")).toBe("npm");
367 });
368
369 it.each([
370 ['EMU="/opt/android/emulator"; "$EMU" -list-avds', "emulator"],
371 ["AAPT=/opt/android/aapt2\n$AAPT dump badging app.apk", "aapt2"],
372 [
373 'SSH=(ssh -i /tmp/key -o IdentitiesOnly=yes); HOST=user@example; "${SSH[@]}" "$HOST" uptime',
374 "ssh",
375 ],
376 ["SCP=(/usr/bin/scp -i /tmp/key); if true; then ${SCP[@]} file user@example:/tmp; fi", "scp"],
377 ['TOOL="/Applications/My Tool/bin/check"; "$TOOL" --verbose', "check"],
378 ])("resolves literal command aliases from earlier shell segments: %s", (command, program) => {
379 expect(commandProgramName(command)).toBe(program);
380 });
381
382 it.each([
383 'TOOL=$(pick-command); "$TOOL" --version',
384 'TOOL="git status"; "$TOOL"',
385 "# TOOL=git\n$TOOL status",
386 'TOOL=git true; "$TOOL" status',
387 'TOOL=git; TOOL=$(pick-command); "$TOOL" status',
388 'TOOL=git; unset TOOL; "$TOOL" status',
389 ])("does not evaluate dynamic or non-persistent command aliases: %s", (command) => {
390 expect(commandProgramName(command)).toBeNull();
391 });
392
393 it("does not retain aliases assigned inside control flow", () => {
394 expect(commandProgramName('TOOL=git; if false; then\nTOOL=npm\nfi\n"$TOOL" status')).toBe(
395 "git",
396 );
397 });
398
399 it.each([
400 ["ROOT=${BASE:-path with spaces}; npm test", "npm"],
401 ["ROOT=`printf 'path with spaces'`; pnpm test", "pnpm"],
402 ])("keeps expansions inside assignment words: %s", (command, program) => {
403 expect(commandProgramName(command)).toBe(program);
404 });
405
406 it.each([
407 [">/tmp/log && npm test", "npm"],
408 ["2>/tmp/error.log; pnpm test", "pnpm"],
409 ["cd /tmp && >/tmp/log npm test", "npm"],
410 ["cd /tmp && > /tmp/log pnpm test", "pnpm"],
411 ["cd /tmp && 2>&1 bun test", "bun"],
412 ["cd /tmp && 2>& 1 node app.js", "node"],
413 ["cd /tmp && &>/tmp/log git status", "git"],
414 ["cd /tmp && *>>/tmp/log vp test", "vp"],
415 ["cd /tmp && {output}>/tmp/log deno test", "deno"],
416 ["cd /tmp && <<<input ruby script.rb", "ruby"],
417 ])("skips redirections before the next command: %s", (command, program) => {
418 expect(commandProgramName(command)).toBe(program);
419 });
420
421 it.each([
422 ["cd /tmp <<EOF\nnot-a-command\nEOF\nnpm test", "npm"],
423 ["cd /tmp <<'EOF'\nnot-a-command\nEOF\npnpm test", "pnpm"],
424 ["cd /tmp <<'EOF'\nnot-a-command\\\nEOF\npnpm test", "pnpm"],
425 ["cd /tmp <<-EOF\n\tnot-a-command\n\tEOF\nbun test", "bun"],
426 ["cd /tmp <<A <<B\none\nA\ntwo\nB\ngit status", "git"],
427 ["cd /tmp <<EOF; # setup\nnot-a-command\nEOF\nnpm test", "npm"],
428 ["cd /tmp <<EOF && pnpm test\nnot-a-command\nEOF\nbun test", "pnpm"],
429 ])("skips heredoc bodies before finding the next command: %s", (command, program) => {
430 expect(commandProgramName(command)).toBe(program);
431 });
432
433 it.each(["cd apps/web && [ -f package.json ]", "cd apps/web || exit 1", "cd one && cd two"])(
434 "falls back when no useful program follows cd: %s",
435 (command) => {
436 expect(commandProgramName(command)).toBeNull();
437 },
438 );
439
440 it.each([
441 "if test -f package.json\nthen\n npm test\nfi",
442 "[[ -d first && -d second ]] && npm test",
443 'for file in *\ndo\n echo "$file"\ndone',
444 "while true\ndo\n sleep 1\ndone",
445 "cd /tmp; build () { npm test; }",
446 ])("does not label commands inside multiline shell control flow: %s", (command) => {
447 expect(commandProgramName(command)).toBeNull();
448 });
449
50450 it("bounds nested shell unwrapping", () => {
51451 let command = "git status";
52452 for (let depth = 0; depth < 9; depth += 1) {
......@@ -54,4 +454,26 @@ describe("commandProgramName", () => {
54454 }
55455 expect(commandProgramName(command)).toBeNull();
56456 });
457
458 it("does not spend the shell nesting budget on setup commands", () => {
459 const command = [
460 ...Array.from({ length: 10 }, (_, index) => `export VALUE_${index}=configured`),
461 "npm test",
462 ].join("\n");
463
464 expect(commandProgramName(command)).toBe("npm");
465 });
466
467 it("bounds the number of top-level setup segments", () => {
468 const command = [
469 ...Array.from({ length: 2_000 }, (_, index) => `export VALUE_${index}=configured`),
470 "npm test",
471 ].join(";");
472
473 expect(commandProgramName(command)).toBeNull();
474 });
475
476 it("bounds nested command wrappers", () => {
477 expect(commandProgramName(`${"command ".repeat(9)}git status`)).toBeNull();
478 });
57479});
packages/client-runtime/src/work-log/commandLabel.ts+1188-11
......@@ -1,7 +1,203 @@
11type CommandWrapper = "env" | "sudo";
2type CommandProgramContext = "exec" | "shell";
3
4const MAX_COMMAND_SEGMENTS = 64;
25
36const 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]);
415const 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]);
5201
6202function shellCommandArgumentIndex(tokens: ReadonlyArray<string>, start: number): number | null {
7203 for (let index = start; index < tokens.length; index += 1) {
......@@ -32,7 +228,9 @@ function tokenizeShellCommand(command: string): string[] | null {
32228 let current = "";
33229 let quote: '"' | "'" | null = null;
34230 let escaping = false;
231 let inBackticks = false;
35232 let substitutionDepth = 0;
233 let parameterExpansionDepth = 0;
36234 let tokenStarted = false;
37235
38236 for (let index = 0; index < input.length; index += 1) {
......@@ -45,9 +243,9 @@ function tokenizeShellCommand(command: string): string[] | null {
45243 }
46244 if (character === "\\" && quote !== "'") {
47245 const nextCharacter = input[index + 1];
48 const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current);
246 const isWindowsPath = quote === null && /^(?:[A-Za-z]:|\.{1,2})(?:\\[^\s]*)?$/u.test(current);
49247 if (
50 (quote === '"' || isWindowsDrivePath) &&
248 (quote === '"' || isWindowsPath) &&
51249 nextCharacter !== undefined &&
52250 nextCharacter !== '"' &&
53251 nextCharacter !== "\\" &&
......@@ -63,6 +261,12 @@ function tokenizeShellCommand(command: string): string[] | null {
63261 tokenStarted = true;
64262 continue;
65263 }
264 if (inBackticks) {
265 current += character;
266 if (character === "`") inBackticks = false;
267 tokenStarted = true;
268 continue;
269 }
66270 if (quote !== null) {
67271 if (character === quote) {
68272 quote = null;
......@@ -72,6 +276,31 @@ function tokenizeShellCommand(command: string): string[] | null {
72276 tokenStarted = true;
73277 continue;
74278 }
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 }
75304 if (character === "$" && input[index + 1] === "(") {
76305 current += "$(";
77306 substitutionDepth += 1;
......@@ -79,6 +308,12 @@ function tokenizeShellCommand(command: string): string[] | null {
79308 index += 1;
80309 continue;
81310 }
311 if (character === "(") {
312 current += character;
313 substitutionDepth += 1;
314 tokenStarted = true;
315 continue;
316 }
82317 if (character === ")" && substitutionDepth > 0) {
83318 current += character;
84319 substitutionDepth -= 1;
......@@ -91,7 +326,7 @@ function tokenizeShellCommand(command: string): string[] | null {
91326 continue;
92327 }
93328 if (/\s/u.test(character)) {
94 if (substitutionDepth > 0) {
329 if (substitutionDepth > 0 || parameterExpansionDepth > 0) {
95330 current += character;
96331 tokenStarted = true;
97332 continue;
......@@ -107,28 +342,843 @@ function tokenizeShellCommand(command: string): string[] | null {
107342 tokenStarted = true;
108343 }
109344
110 if (quote !== null || escaping || substitutionDepth > 0) return null;
345 if (
346 quote !== null ||
347 escaping ||
348 inBackticks ||
349 substitutionDepth > 0 ||
350 parameterExpansionDepth > 0
351 ) {
352 return null;
353 }
111354 if (tokenStarted) tokens.push(current);
112355 return tokens;
113356}
114357
115export function commandProgramName(command: string, depth = 0): string | null {
116 if (depth >= 8) return null;
117 const tokens = tokenizeShellCommand(command);
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));
1181116 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 }
1191133 let index = 0;
1201134 let wrapper: CommandWrapper | null = null;
1135 let executionContext = context;
1136 let sawAssignment = false;
1137 let sawRedirection = false;
1211138
1221139 while (index < tokens.length) {
1231140 const token = tokens[index];
1241141 if (!token) return null;
125 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) {
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;
1261151 index += 1;
1271152 continue;
1281153 }
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 }
1291177 const tokenProgram = token.split(/[\\/]/).at(-1);
1178 const isUnqualifiedToken = token === tokenProgram;
1301179 if (tokenProgram === "env" || tokenProgram === "sudo") {
1311180 wrapper = tokenProgram;
1181 executionContext = "exec";
1321182 index += 1;
1331183 continue;
1341184 }
......@@ -140,10 +1190,17 @@ export function commandProgramName(command: string, depth = 0): string | null {
1401190 if (wrapper !== null && token.startsWith("-")) {
1411191 if (wrapper === "env" && (token === "-S" || token === "--split-string")) {
1421192 const splitCommand = tokens[index + 1];
143 return splitCommand ? commandProgramName(splitCommand, depth + 1) : null;
1193 return splitCommand
1194 ? commandProgramNameInternal(splitCommand, depth + 1, executionContext, segmentsRemaining)
1195 : null;
1441196 }
1451197 if (wrapper === "env" && token.startsWith("--split-string=")) {
146 return commandProgramName(token.slice("--split-string=".length), depth + 1);
1198 return commandProgramNameInternal(
1199 token.slice("--split-string=".length),
1200 depth + 1,
1201 executionContext,
1202 segmentsRemaining,
1203 );
1471204 }
1481205 if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) {
1491206 if (tokens[index + 1] === undefined) return null;
......@@ -182,11 +1239,131 @@ export function commandProgramName(command: string, depth = 0): string | null {
1821239 const scriptIndex = shellCommandArgumentIndex(tokens, index + 1);
1831240 if (scriptIndex !== null) {
1841241 const script = tokens[scriptIndex];
185 return script ? commandProgramName(script, depth + 1) : null;
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;
1861285 }
1871286 }
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 }
1881338 return tokenProgram || null;
1891339 }
1901340
1341 if ((sawAssignment || sawRedirection) && wrapper === null && commandSplit.remainingCommand) {
1342 return commandProgramNameInternal(
1343 commandSplit.remainingCommand,
1344 depth,
1345 executionContext,
1346 segmentsRemaining - 1,
1347 );
1348 }
1911349 return null;
1921350}
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}