diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 64b47046ec1..8515f415fd2 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -604,7 +604,7 @@ describe('wrapPosixHookCommand', () => { }) const qualifiedWindowsPowerShellCommand = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -614,8 +614,10 @@ function decodeWindowsHookCommand(command: string): string { function expectedDecodedWindowsHookCommand(scriptPath: string): string { const quoted = `'${scriptPath.replaceAll("'", "''")}'` + // Why: the execution-policy bypass rides in the payload, not on the command + // line, so the launcher cannot spell the AV-blocked flag triple (#16003). // Why: PowerShell progress CLIXML corrupts consumers that merge stderr into JSON stdout. - return `$ProgressPreference='SilentlyContinue'; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` + return `$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; if (Test-Path -LiteralPath ${quoted} -PathType Leaf) { & ${quoted}; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null; exit 0` } describe('wrapWindowsHookCommand', () => { @@ -742,6 +744,17 @@ describe('wrapRuntimeHomeHookCommand', () => { } ) + it('hides the console on the Git Bash branch too, and still avoids the denied triple', () => { + // Why: this branch launches PowerShell from bash, where the parent has no + // console to inherit — Windows allocates a fresh one per hook event unless + // the switch says otherwise (#14815), and the AV verdict on the flag triple + // applies to the exact same string (#16003). + const command = wrapRuntimeHomeHookCommand('claude-hook') + + expect(command).toContain('powershell.exe" -NoProfile -WindowStyle Hidden -EncodedCommand ') + expect(command).not.toMatch(/-ExecutionPolicy/i) + }) + it('rejects a script base name that could inject shell syntax', () => { expect(() => wrapRuntimeHomeHookCommand('claude-hook; echo injected')).toThrow( 'Invalid managed script base name' diff --git a/src/main/agent-hooks/runtime-home-hook-command.ts b/src/main/agent-hooks/runtime-home-hook-command.ts index 321c8d4adef..3a6f0d20725 100644 --- a/src/main/agent-hooks/runtime-home-hook-command.ts +++ b/src/main/agent-hooks/runtime-home-hook-command.ts @@ -24,7 +24,7 @@ export function wrapRuntimeHomeHookCommand( const powershellFallback = options.neutralJsonWhenMissing ? "; Write-Output '{}'" : '' const powershellCommand = `$homePath = $env:HOME -replace '^/([A-Za-z])/', '$1:/'; $scriptPath = Join-Path $homePath '.orca\\agent-hooks\\${scriptBaseName}.cmd'; if (Test-Path -LiteralPath $scriptPath -PathType Leaf) { & $scriptPath; exit $LASTEXITCODE }; [Console]::In.ReadToEnd() | Out-Null${powershellFallback}; exit 0` const encodedCommand = encodeWindowsPowerShellHookCommand(powershellCommand) - // Why: the Git Bash and native Windows launchers must suppress windows identically (#14815). + // Why: the Git Bash and native Windows launchers must spell the same switches — window suppression (#14815) and an AV verdict on the shape (#16003) both hit either path. const powershellInvocation = `${powershell} ${WINDOWS_POWERSHELL_HOOK_SWITCHES} -EncodedCommand ${encodedCommand}` const encodedWindowsBranch = `if [ -f ${powershell} ]; then ${powershellInvocation}; else ${missingScriptFallback}; fi` const windowsBranch = `if [ -f ${windowsScript} ]; then case "\${HOME-}" in ${WINDOWS_GIT_BASH_RUNTIME_HOME_UNSAFE}) ${encodedWindowsBranch} ;; *) ${windowsScript} ;; esac; else ${missingScriptFallback}; fi` diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts new file mode 100644 index 00000000000..e58fd25c960 --- /dev/null +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { + encodeWindowsPowerShellHookCommand, + WINDOWS_POWERSHELL_HOOK_SWITCHES, + wrapWindowsPowerShellEncodedCommand +} from './windows-powershell-hook-launcher' + +function decodePayload(command: string): string { + const encoded = command.match(/ -EncodedCommand (\S+)$/)?.[1] + expect(encoded).toBeTruthy() + return Buffer.from(encoded!, 'base64').toString('utf16le') +} + +/* + * Two reproduced Windows failures constrain this one string, in opposite + * directions: + * + * #16003 — endpoint security (Kaspersky Premium, Windows 11) denies process + * creation for `-ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand` + * whatever the payload decodes to, and no exclusion re-enabled it. The triple + * must stop being spelled. + * + * #14815 (+ #14828, #15117, #15447, #15767) — without `-WindowStyle Hidden` + * every hook event allocates a console that takes foreground and eats the + * user's keystrokes, and strands a visible window outright when the hook blocks + * on stdin. Window suppression must stay. + * + * Both hold only if the flag that leaves the command line is the policy bypass, + * which is the one with an exact in-payload equivalent. + */ +describe('windows PowerShell hook launcher', () => { + it('never spells the denied flag triple on the command line', () => { + const command = wrapWindowsPowerShellEncodedCommand('exit 0') + + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-ExecutionPolicy/i) + expect(command.replace(/ -EncodedCommand \S+$/, '')).not.toMatch(/-ExecutionPolicy/i) + }) + + it('keeps hiding the console window on every hook event (#14815)', () => { + // Dropping this flag is not a cosmetic flash: the console takes foreground + // and swallows what the user is typing into Orca (#14828), and never closes + // at all when the hook blocks reading stdin (#14815). + const command = wrapWindowsPowerShellEncodedCommand('exit 0') + + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile -WindowStyle Hidden') + expect(command).toMatch(/ -NoProfile -WindowStyle Hidden -EncodedCommand [A-Za-z0-9+/=]+$/) + }) + + it('keeps the execution-policy bypass, in the payload where AV cannot read it', () => { + // Why it must survive somewhere: Copilot's managed hook is a .ps1, which a + // Restricted or AllSigned machine policy refuses to run without a bypass. + // Process scope is exactly what the switch used to set. + expect(decodePayload(wrapWindowsPowerShellEncodedCommand('exit 0'))).toContain( + 'Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue' + ) + }) + + it('swallows a terminating execution-policy failure, not just a non-terminating one', () => { + // A GPO MachinePolicy/UserPolicy scope makes the cmdlet complain that the + // process scope did not take. -ErrorAction covers only the non-terminating + // half; the switch this replaced printed nothing either way, and an + // ErrorRecord on stderr corrupts consumers that merge our streams into JSON. + const decoded = decodePayload(wrapWindowsPowerShellEncodedCommand('exit 0')) + + expect(decoded).toMatch(/try \{[^}]*Set-ExecutionPolicy[^}]*\} catch \{\}/) + }) + + it('applies the bypass before the caller command and keeps progress silenced', () => { + const decoded = Buffer.from( + encodeWindowsPowerShellHookCommand('& $scriptPath'), + 'base64' + ).toString('utf16le') + + expect(decoded).toBe( + "$ProgressPreference='SilentlyContinue'; try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; & $scriptPath" + ) + }) + + it('silences progress before anything that can autoload a module', () => { + // Set-ExecutionPolicy pulls in Microsoft.PowerShell.Security, and its + // "Preparing modules for first use." progress record is written before a + // later assignment can suppress it. Measured on Windows 11: bypass-first put + // 616 bytes of on stderr and made "#< CLIXML" the + // first merged line -- the exact corruption HOOK_PROGRESS_SILENCER exists to + // stop. Silencer-first measured 0 bytes. + const decoded = Buffer.from( + encodeWindowsPowerShellHookCommand('& $scriptPath'), + 'base64' + ).toString('utf16le') + + expect(decoded.indexOf("$ProgressPreference='SilentlyContinue'")).toBeGreaterThanOrEqual(0) + expect(decoded.indexOf("$ProgressPreference='SilentlyContinue'")).toBeLessThan( + decoded.indexOf('Set-ExecutionPolicy') + ) + }) +}) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index e3974ef8ff1..aeec04380db 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -1,4 +1,4 @@ -// Why: centralizing the launcher keeps window suppression consistent across installers (#14815). +// Why: centralizing the launcher keeps every installer on one command shape; #14815 and #16003 both turned on which shape it is. // Why: an absolute forward-slash path avoids PATH hijacking and survives cmd.exe and Git Bash. export function getWindowsSystem32Path(relativePath: string): string { @@ -10,16 +10,64 @@ export function getWindowsPowerShellExecutablePath(): string { return getWindowsSystem32Path('WindowsPowerShell/v1.0/powershell.exe') } -// Why: unlike conhost, hidden PowerShell relays hook output and exit status (#14818). -export const WINDOWS_POWERSHELL_HOOK_SWITCHES = - '-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden' +/** + * Switches for the PowerShell that relays hook output and exit status + * (#14818 — conhost does neither). + * + * `-WindowStyle Hidden` stays. It is the shipped fix for #14815 and its four + * duplicates (#14828, #15117, #15447, #15767): without it Windows allocates a + * console per hook event, which steals foreground from whatever the user is + * typing into, and strands a permanently visible window whenever a hook blocks + * reading stdin (see hook-stdin-contract.ts). Those are reported, reproduced + * user-facing failures on every hook event of every managed agent. + * + * `-ExecutionPolicy Bypass` is the flag that leaves the command line, because + * it is the only one of the three with an exact in-payload equivalent (below): + * it costs nothing to move. #16003 measured `-NoProfile -ExecutionPolicy Bypass + * -WindowStyle Hidden -EncodedCommand` as denied at CreateProcess (exit 126, + * Kaspersky Premium on Windows 11) no matter what the payload decodes to, so + * the triple has to stop being spelled; dropping the policy flag breaks it. + * + * What is not established: that the remaining pair clears that AV signature — + * the reporter measured no two-flag encoded shape. If it turns out not to, the + * answer is another launcher shape that still suppresses the window, not + * trading a reproduced regression for an unmeasured hope. + */ +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile -WindowStyle Hidden' -// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON output. +// Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON +// output. It must be the FIRST statement: Set-ExecutionPolicy autoloads +// Microsoft.PowerShell.Security, whose "Preparing modules for first use." +// progress record is emitted before any later assignment can suppress it. +// Measured on Windows 11: bypass-first put 616 bytes of +// on stderr and made "#< CLIXML" the first merged line; silencer-first, 0 bytes. const HOOK_PROGRESS_SILENCER = "$ProgressPreference='SilentlyContinue'; " +/** + * Process-scope stand-in for the `-ExecutionPolicy Bypass` switch (#16003). + * + * Equivalent by construction: the switch sets the Process scope too, and both + * lose to a Group Policy scope. `-EncodedCommand` itself is never policy-gated, + * so this always gets to run; it is what lets the managed `.ps1` hooks (Copilot) + * execute under a Restricted or AllSigned machine policy. + * + * try/catch as well as `-ErrorAction SilentlyContinue`: under a MachinePolicy or + * UserPolicy GPO the cmdlet reports that the process scope did not take, and + * `-ErrorAction` only governs the non-terminating half of that. The switch this + * replaces printed nothing at all in the same situation, and an ErrorRecord on + * stderr is a live corruption risk for the consumers that merge our streams into + * JSON stdout (see the progress silencer above). A hook must still answer its + * agent when the policy is locked down. + */ +const HOOK_EXECUTION_POLICY_BYPASS = + 'try { Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force -ErrorAction SilentlyContinue } catch {}; ' + // Why: encoding shields paths and switches from cmd.exe and MSYS rewriting (#6078, #14815). export function encodeWindowsPowerShellHookCommand(command: string): string { - return Buffer.from(`${HOOK_PROGRESS_SILENCER}${command}`, 'utf16le').toString('base64') + return Buffer.from( + `${HOOK_PROGRESS_SILENCER}${HOOK_EXECUTION_POLICY_BYPASS}${command}`, + 'utf16le' + ).toString('base64') } export function wrapWindowsPowerShellEncodedCommand(command: string): string { diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 2137bcda780..315ee6dd228 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -41,7 +41,7 @@ describe('getWindowsManagedLifecycleHook', () => { expect(hook.args).toBeUndefined() expect(hook.command).toMatch( - /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / ) expect(hook.command).not.toContain(scriptPath) // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. @@ -385,7 +385,7 @@ describe('ClaudeHookService.install', () => { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args).toBeUndefined() expect(hook?.command).toMatch( - /\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden / + /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / ) expect(hook?.command).not.toContain(scriptPath) diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 22f6bf698c5..72852e45b55 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -30,7 +30,7 @@ vi.mock('os', async (importOriginal) => { import { CodexHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ const homes = setupCodexHookHomes(homedirMock, getPathMock) diff --git a/src/main/command-code/hook-service.test.ts b/src/main/command-code/hook-service.test.ts index ce59e20b056..f5b0f83af62 100644 --- a/src/main/command-code/hook-service.test.ts +++ b/src/main/command-code/hook-service.test.ts @@ -21,7 +21,7 @@ vi.mock('os', async () => { import { CommandCodeHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('CommandCodeHookService', () => { let homeDir: string diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index b723d947db3..a260b74938f 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -31,7 +31,7 @@ const CURSOR_EVENTS = [ const CURSOR_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'cursor-hook.cmd' : 'cursor-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('CursorHookService', () => { let homeDir: string diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index 0a65facab21..bd145fdc1de 100644 --- a/src/main/droid/hook-service.test.ts +++ b/src/main/droid/hook-service.test.ts @@ -25,7 +25,7 @@ vi.mock('os', async () => { import { DroidHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('DroidHookService', () => { let homeDir: string diff --git a/src/main/gemini/hook-service.test.ts b/src/main/gemini/hook-service.test.ts index fc5a302a7d1..a835a6278f8 100644 --- a/src/main/gemini/hook-service.test.ts +++ b/src/main/gemini/hook-service.test.ts @@ -26,7 +26,7 @@ vi.mock('os', async (importOriginal) => { import { GeminiHookService } from './hook-service' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ describe('GeminiHookService', () => { let homeDir: string diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 89bd53f22c0..4b1625614ac 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -33,7 +33,7 @@ import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ // Why (#14828): Windows registers the bare script path when it is cmd-safe and only falls back // to the encoded launcher for a profile path that is not (#6078). windows-hook-launcher-chain