From a87a393d79b50e5ab06cff56116aa656a55ca72b Mon Sep 17 00:00:00 2001 From: notsapinho Date: Mon, 14 Sep 2026 11:50:13 -0300 Subject: [PATCH 1/6] Fix Windows shell command presentation --- src/lib/harness/shellIntent.test.ts | 34 +++++++++++- src/lib/harness/shellIntent.ts | 85 +++++++++++++++++++++++------ 2 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index 52168743..75c2f98a 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -183,10 +183,42 @@ describe("formatShellIntent", () => { }); describe("unwrapShellCommand", () => { - it("removes the shell transport wrapper without changing ordinary commands", () => { + it("unwraps POSIX shells without including trailing shell arguments", () => { expect(unwrapShellCommand(`/bin/zsh -lc "npm test -- --run app.test.ts"`)).toBe( "npm test -- --run app.test.ts", ); + expect(unwrapShellCommand(`/bin/zsh -lc "rg -n \\"foo\\" src" ignored`)).toBe( + 'rg -n "foo" src', + ); + }); + + it("unwraps PowerShell command remainders", () => { + expect( + unwrapShellCommand( + `"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoLogo -NoProfile -Command 'rg -n foo src'`, + ), + ).toBe("rg -n foo src"); + expect( + unwrapShellCommand( + "powershell.exe -ExecutionPolicy Bypass -Command Get-Content package.json", + ), + ).toBe("Get-Content package.json"); + }); + + it("unwraps cmd command remainders", () => { + expect(unwrapShellCommand(`cmd.exe /d /s /c "npm test"`)).toBe("npm test"); + }); + + it("leaves ordinary and incomplete commands unchanged", () => { expect(unwrapShellCommand("git status --short")).toBe("git status --short"); + expect(unwrapShellCommand(`pwsh -File '-Command' script.ps1`)).toBe( + `pwsh -File '-Command' script.ps1`, + ); + expect(unwrapShellCommand(`pwsh -Command 'npm test`)).toBe( + `pwsh -Command 'npm test`, + ); + expect(unwrapShellCommand(`cmd.exe /c "npm test`)).toBe( + `cmd.exe /c "npm test`, + ); }); }); diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index 35fd65a1..6e8b377d 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -30,8 +30,9 @@ export function inferShellIntent(command: string): ShellIntent | undefined { const stages = splitTopLevel(chain, pipeSep); let pipeIntent: ShellIntent | undefined; for (const stage of stages) { - const argv = tokenize(stage); - if (!argv || argv.length === 0) return undefined; + const tokens = tokenize(stage); + if (!tokens || tokens.length === 0) return undefined; + const argv = tokens.map(({ value }) => value); const classified = classifyArgv(argv); if (classified === "opaque") return undefined; if (classified === "noise") continue; @@ -54,30 +55,66 @@ export function inferShellIntent(command: string): ShellIntent | undefined { /** * Codex may expose a command through the argv used to launch the user's shell, - * for example `/bin/zsh -lc "cat package.json"`. The launcher is transport + * for example `/bin/zsh -lc "cat package.json"` or + * `pwsh.exe -Command "Get-Content package.json"`. The launcher is transport * noise for this visual-only classifier; inspect the script it was given. */ export function unwrapShellCommand(command: string): string { let current = command.trim(); for (let depth = 0; depth < 2; depth += 1) { - const argv = tokenize(current); - if (!argv || argv.length < 3 || !SHELL_LAUNCHERS.has(binName(argv[0]))) { - break; - } - const scriptIndex = argv.findIndex( - (arg, index) => index > 0 && isCommandFlag(arg), + const tokens = tokenize(current); + if (!tokens || tokens.length < 3) break; + const executable = trimMatchingOuterQuotes( + current.slice(tokens[0].start, tokens[0].end), + ); + const wrapper = SHELL_WRAPPERS.find(({ executables }) => + executables.has(binName(executable)), ); - const script = scriptIndex >= 0 ? argv[scriptIndex + 1]?.trim() : undefined; + if (!wrapper) break; + const flagIndex = tokens.findIndex( + (token, index) => + index > 0 && !token.quoted && wrapper.commandFlag.test(token.value), + ); + if (flagIndex < 0) break; + const commandToken = tokens[flagIndex + 1]; + if (!commandToken) break; + const remainder = current.slice(commandToken.start).trim(); + const script = wrapper.consumeRemainder + ? trimMatchingOuterQuotes(remainder) + : commandToken.value.trim(); if (!script || script === current) break; current = script; } return current; } -const SHELL_LAUNCHERS = new Set(["sh", "bash", "zsh", "dash", "ksh"]); - -function isCommandFlag(arg: string): boolean { - return arg === "--command" || /^-[A-Za-z]*c[A-Za-z]*$/.test(arg); +const SHELL_WRAPPERS = [ + { + executables: new Set(["sh", "bash", "zsh", "dash", "ksh"]), + commandFlag: /^(?:--command|-[a-z]*c[a-z]*)$/i, + consumeRemainder: false, + }, + { + executables: new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), + commandFlag: /^-command$/i, + consumeRemainder: true, + }, + { + executables: new Set(["cmd", "cmd.exe"]), + commandFlag: /^\/c$/i, + consumeRemainder: true, + }, +] as const; + +function trimMatchingOuterQuotes(value: string): string { + if ( + value.length >= 2 && + ((value[0] === '"' && value[value.length - 1] === '"') || + (value[0] === "'" && value[value.length - 1] === "'")) + ) { + return value.slice(1, -1).trim(); + } + return value; } export function formatShellIntent( @@ -555,8 +592,15 @@ function splitTopLevel( return parts; } -function tokenize(stage: string): string[] | null { - const tokens: string[] = []; +type ShellToken = { + value: string; + start: number; + end: number; + quoted: boolean; +}; + +function tokenize(stage: string): ShellToken[] | null { + const tokens: ShellToken[] = []; let i = 0; while (i < stage.length) { // Treat redirects (`2>&1`) as separators so `&` cannot stall the scan. @@ -597,7 +641,14 @@ function tokenize(stage: string): string[] | null { token += c; i += 1; } - if (token) tokens.push(token); + if (token) { + tokens.push({ + value: token, + start, + end: i, + quoted: stage[start] === "'" || stage[start] === '"', + }); + } if (i <= start) i += 1; } return tokens; From e6f33f681b0e97191abaaeb6f3e25e92cac587b9 Mon Sep 17 00:00:00 2001 From: notsapinho Date: Mon, 14 Sep 2026 12:15:33 -0300 Subject: [PATCH 2/6] Stop PowerShell flag scan at file scripts --- src/lib/harness/shellIntent.test.ts | 6 ++++++ src/lib/harness/shellIntent.ts | 20 ++++++++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index 75c2f98a..e0e1c0b6 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -209,6 +209,12 @@ describe("unwrapShellCommand", () => { expect(unwrapShellCommand(`cmd.exe /d /s /c "npm test"`)).toBe("npm test"); }); + it("stops scanning PowerShell launcher options at -File", () => { + expect(unwrapShellCommand(`pwsh -File script.ps1 -Mode -Command build`)).toBe( + `pwsh -File script.ps1 -Mode -Command build`, + ); + }); + it("leaves ordinary and incomplete commands unchanged", () => { expect(unwrapShellCommand("git status --short")).toBe("git status --short"); expect(unwrapShellCommand(`pwsh -File '-Command' script.ps1`)).toBe( diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index 6e8b377d..32a96c7d 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -71,10 +71,21 @@ export function unwrapShellCommand(command: string): string { executables.has(binName(executable)), ); if (!wrapper) break; - const flagIndex = tokens.findIndex( - (token, index) => - index > 0 && !token.quoted && wrapper.commandFlag.test(token.value), - ); + let flagIndex = -1; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (token.quoted) continue; + if ( + "optionBoundary" in wrapper && + wrapper.optionBoundary.test(token.value) + ) { + break; + } + if (wrapper.commandFlag.test(token.value)) { + flagIndex = index; + break; + } + } if (flagIndex < 0) break; const commandToken = tokens[flagIndex + 1]; if (!commandToken) break; @@ -97,6 +108,7 @@ const SHELL_WRAPPERS = [ { executables: new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), commandFlag: /^-command$/i, + optionBoundary: /^-file$/i, consumeRemainder: true, }, { From cd75b084ec929fb1298e9f389828a36512368135 Mon Sep 17 00:00:00 2001 From: notsapinho Date: Mon, 14 Sep 2026 12:27:07 -0300 Subject: [PATCH 3/6] Support PowerShell command option aliases --- src/lib/harness/shellIntent.test.ts | 6 ++++++ src/lib/harness/shellIntent.ts | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index e0e1c0b6..6425ca8a 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -203,6 +203,9 @@ describe("unwrapShellCommand", () => { "powershell.exe -ExecutionPolicy Bypass -Command Get-Content package.json", ), ).toBe("Get-Content package.json"); + expect(unwrapShellCommand("pwsh -c Get-Content package.json")).toBe( + "Get-Content package.json", + ); }); it("unwraps cmd command remainders", () => { @@ -213,6 +216,9 @@ describe("unwrapShellCommand", () => { expect(unwrapShellCommand(`pwsh -File script.ps1 -Mode -Command build`)).toBe( `pwsh -File script.ps1 -Mode -Command build`, ); + expect(unwrapShellCommand(`pwsh -f script.ps1 -Mode -c build`)).toBe( + `pwsh -f script.ps1 -Mode -c build`, + ); }); it("leaves ordinary and incomplete commands unchanged", () => { diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index 32a96c7d..a46de737 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -107,8 +107,8 @@ const SHELL_WRAPPERS = [ }, { executables: new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]), - commandFlag: /^-command$/i, - optionBoundary: /^-file$/i, + commandFlag: /^-(?:command|c)$/i, + optionBoundary: /^-(?:file|f)$/i, consumeRemainder: true, }, { From 652dbf078e1ede67861d58e7f142050b4d55ed0b Mon Sep 17 00:00:00 2001 From: notsapinho Date: Tue, 15 Sep 2026 13:43:02 -0300 Subject: [PATCH 4/6] Preserve quoted PowerShell command arguments --- src/lib/harness/shellIntent.test.ts | 9 ++++++++ src/lib/harness/shellIntent.ts | 32 ++++++++++++++--------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index 6425ca8a..654334f3 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -206,6 +206,12 @@ describe("unwrapShellCommand", () => { expect(unwrapShellCommand("pwsh -c Get-Content package.json")).toBe( "Get-Content package.json", ); + expect( + unwrapShellCommand(`pwsh "-Command" "Get-Content package.json"`), + ).toBe("Get-Content package.json"); + expect( + unwrapShellCommand(`pwsh -Command 'Get-Date' '-Format' 'yyyy-MM-dd'`), + ).toBe(`'Get-Date' '-Format' 'yyyy-MM-dd'`); }); it("unwraps cmd command remainders", () => { @@ -219,6 +225,9 @@ describe("unwrapShellCommand", () => { expect(unwrapShellCommand(`pwsh -f script.ps1 -Mode -c build`)).toBe( `pwsh -f script.ps1 -Mode -c build`, ); + expect( + unwrapShellCommand(`pwsh "-File" script.ps1 "-Command" build`), + ).toBe(`pwsh "-File" script.ps1 "-Command" build`); }); it("leaves ordinary and incomplete commands unchanged", () => { diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index a46de737..277c7adf 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -64,9 +64,16 @@ export function unwrapShellCommand(command: string): string { for (let depth = 0; depth < 2; depth += 1) { const tokens = tokenize(current); if (!tokens || tokens.length < 3) break; - const executable = trimMatchingOuterQuotes( - current.slice(tokens[0].start, tokens[0].end), + const executableToken = tokens[0]; + const rawExecutable = current.slice( + executableToken.start, + executableToken.end, ); + const executable = + executableToken.quoted && + rawExecutable[0] === rawExecutable[rawExecutable.length - 1] + ? rawExecutable.slice(1, -1) + : rawExecutable; const wrapper = SHELL_WRAPPERS.find(({ executables }) => executables.has(binName(executable)), ); @@ -74,7 +81,6 @@ export function unwrapShellCommand(command: string): string { let flagIndex = -1; for (let index = 1; index < tokens.length; index += 1) { const token = tokens[index]; - if (token.quoted) continue; if ( "optionBoundary" in wrapper && wrapper.optionBoundary.test(token.value) @@ -90,9 +96,12 @@ export function unwrapShellCommand(command: string): string { const commandToken = tokens[flagIndex + 1]; if (!commandToken) break; const remainder = current.slice(commandToken.start).trim(); - const script = wrapper.consumeRemainder - ? trimMatchingOuterQuotes(remainder) - : commandToken.value.trim(); + const singleQuotedCommand = + commandToken.quoted && tokens.length === flagIndex + 2; + const script = + wrapper.consumeRemainder && !singleQuotedCommand + ? remainder + : commandToken.value.trim(); if (!script || script === current) break; current = script; } @@ -118,17 +127,6 @@ const SHELL_WRAPPERS = [ }, ] as const; -function trimMatchingOuterQuotes(value: string): string { - if ( - value.length >= 2 && - ((value[0] === '"' && value[value.length - 1] === '"') || - (value[0] === "'" && value[value.length - 1] === "'")) - ) { - return value.slice(1, -1).trim(); - } - return value; -} - export function formatShellIntent( intent: ShellIntent, path?: string, From 24af57efddec885a28fb85077d4226765cbcbb1f Mon Sep 17 00:00:00 2001 From: notsapinho Date: Tue, 15 Sep 2026 13:49:15 -0300 Subject: [PATCH 5/6] Simplify shell wrapper quote tracking --- src/lib/harness/shellIntent.test.ts | 6 ------ src/lib/harness/shellIntent.ts | 13 +++++++------ 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index 654334f3..34689ded 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -232,14 +232,8 @@ describe("unwrapShellCommand", () => { it("leaves ordinary and incomplete commands unchanged", () => { expect(unwrapShellCommand("git status --short")).toBe("git status --short"); - expect(unwrapShellCommand(`pwsh -File '-Command' script.ps1`)).toBe( - `pwsh -File '-Command' script.ps1`, - ); expect(unwrapShellCommand(`pwsh -Command 'npm test`)).toBe( `pwsh -Command 'npm test`, ); - expect(unwrapShellCommand(`cmd.exe /c "npm test`)).toBe( - `cmd.exe /c "npm test`, - ); }); }); diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index 277c7adf..a29ecfb0 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -69,8 +69,9 @@ export function unwrapShellCommand(command: string): string { executableToken.start, executableToken.end, ); + const executableQuote = current[executableToken.start]; const executable = - executableToken.quoted && + (executableQuote === '"' || executableQuote === "'") && rawExecutable[0] === rawExecutable[rawExecutable.length - 1] ? rawExecutable.slice(1, -1) : rawExecutable; @@ -96,10 +97,12 @@ export function unwrapShellCommand(command: string): string { const commandToken = tokens[flagIndex + 1]; if (!commandToken) break; const remainder = current.slice(commandToken.start).trim(); - const singleQuotedCommand = - commandToken.quoted && tokens.length === flagIndex + 2; + const commandQuote = current[commandToken.start]; + const commandIsSoleQuotedToken = + (commandQuote === '"' || commandQuote === "'") && + tokens.length === flagIndex + 2; const script = - wrapper.consumeRemainder && !singleQuotedCommand + wrapper.consumeRemainder && !commandIsSoleQuotedToken ? remainder : commandToken.value.trim(); if (!script || script === current) break; @@ -606,7 +609,6 @@ type ShellToken = { value: string; start: number; end: number; - quoted: boolean; }; function tokenize(stage: string): ShellToken[] | null { @@ -656,7 +658,6 @@ function tokenize(stage: string): ShellToken[] | null { value: token, start, end: i, - quoted: stage[start] === "'" || stage[start] === '"', }); } if (i <= start) i += 1; From a1f4c297a5892845c7c4e80a3cc9e1a7a0e50d31 Mon Sep 17 00:00:00 2001 From: notsapinho Date: Tue, 15 Sep 2026 13:51:11 -0300 Subject: [PATCH 6/6] Preserve partially quoted shell arguments --- src/lib/harness/shellIntent.test.ts | 3 +++ src/lib/harness/shellIntent.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/lib/harness/shellIntent.test.ts b/src/lib/harness/shellIntent.test.ts index 34689ded..2af4e795 100644 --- a/src/lib/harness/shellIntent.test.ts +++ b/src/lib/harness/shellIntent.test.ts @@ -209,6 +209,9 @@ describe("unwrapShellCommand", () => { expect( unwrapShellCommand(`pwsh "-Command" "Get-Content package.json"`), ).toBe("Get-Content package.json"); + expect(unwrapShellCommand(`pwsh -Command "Get-Content".ps1`)).toBe( + `"Get-Content".ps1`, + ); expect( unwrapShellCommand(`pwsh -Command 'Get-Date' '-Format' 'yyyy-MM-dd'`), ).toBe(`'Get-Date' '-Format' 'yyyy-MM-dd'`); diff --git a/src/lib/harness/shellIntent.ts b/src/lib/harness/shellIntent.ts index a29ecfb0..8cf94da7 100644 --- a/src/lib/harness/shellIntent.ts +++ b/src/lib/harness/shellIntent.ts @@ -100,6 +100,7 @@ export function unwrapShellCommand(command: string): string { const commandQuote = current[commandToken.start]; const commandIsSoleQuotedToken = (commandQuote === '"' || commandQuote === "'") && + current[commandToken.end - 1] === commandQuote && tokens.length === flagIndex + 2; const script = wrapper.consumeRemainder && !commandIsSoleQuotedToken