diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0862ac31fe7..a256da5112c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -703,6 +703,7 @@ jobs: src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts src/shared/child-process/windows-command-line.win32.test.ts + src/main/agent-hooks/windows-hook-payload-delivery.test.ts src/main/windows/windows-pty-job.win32.test.ts src/main/windows/windows-host-job.win32.test.ts src/main/wsl/wsl-runner.test.ts diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 88c73550da2..3cf2525ba1e 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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ function decodeWindowsHookCommand(command: string): string { const encodedCommand = command.match(/ -EncodedCommand (\S+)$/)?.[1] @@ -762,7 +762,7 @@ describe('wrapRuntimeHomeHookCommand', () => { // applies to the exact same string (#16003). const command = wrapRuntimeHomeHookCommand('claude-hook') - expect(command).toContain('powershell.exe" -NoProfile -WindowStyle Hidden -EncodedCommand ') + expect(command).toContain('powershell.exe" -NoProfile -EncodedCommand ') expect(command).not.toMatch(/-ExecutionPolicy/i) }) diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts index e58fd25c960..e1aade2d8c2 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.test.ts @@ -12,38 +12,41 @@ function decodePayload(command: string): string { } /* - * Two reproduced Windows failures constrain this one string, in opposite - * directions: + * #16003 — endpoint security (Kaspersky, Windows 11) denies process creation + * for `-WindowStyle Hidden` + `-EncodedCommand` whatever the payload decodes + * to, and no exclusion re-enabled it. Measured on the reporting host: that pair + * exits 126 with or without `-ExecutionPolicy Bypass` and with or without + * `-NoProfile`, while `-NoProfile -EncodedCommand` alone runs 5/5. So the + * command line spells neither the policy bypass (moved into the payload by + * #16576) nor the window style. * - * #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. + * The counter-pressure is real and is NOT resolved by these assertions: + * #14815 (+ #14828, #15117, #15447, #15767) reported consoles taking + * foreground. `-WindowStyle Hidden` was that fix, but its suppression was never + * measured — see the launcher's comment. These tests pin the shape so it cannot + * be restored silently; whether a console appears is a question for a live + * window measurement, which no unit test here can answer. */ describe('windows PowerShell hook launcher', () => { - it('never spells the denied flag triple on the command line', () => { + it('never spells a denied flag on the command line', () => { const command = wrapWindowsPowerShellEncodedCommand('exit 0') + const switches = command.replace(/ -EncodedCommand \S+$/, '') expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-ExecutionPolicy/i) - expect(command.replace(/ -EncodedCommand \S+$/, '')).not.toMatch(/-ExecutionPolicy/i) + expect(switches).not.toMatch(/-ExecutionPolicy/i) + // Why: measured exit 126 paired with -EncodedCommand on the #16003 host. + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).not.toMatch(/-WindowStyle/i) + expect(switches).not.toMatch(/-WindowStyle/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). + it('pins the exact launcher shape so a flag cannot come back unnoticed', () => { + // Why this is worth a test: #16576 round 3 dropped -WindowStyle Hidden by + // accident and updated the tests to match, so nothing caught it. Restoring + // either flag re-breaks every hook on an AV host (#16003). 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+/=]+$/) + expect(WINDOWS_POWERSHELL_HOOK_SWITCHES).toBe('-NoProfile') + expect(command).toMatch(/ -NoProfile -EncodedCommand [A-Za-z0-9+/=]+$/) }) it('keeps the execution-policy bypass, in the payload where AV cannot read it', () => { diff --git a/src/main/agent-hooks/windows-powershell-hook-launcher.ts b/src/main/agent-hooks/windows-powershell-hook-launcher.ts index aeec04380db..b9a9f6dd208 100644 --- a/src/main/agent-hooks/windows-powershell-hook-launcher.ts +++ b/src/main/agent-hooks/windows-powershell-hook-launcher.ts @@ -14,26 +14,33 @@ export function getWindowsPowerShellExecutablePath(): string { * 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. + * The command line spells no flag beyond `-NoProfile`, because AV denies the + * combinations. #16003 measured, on the reporting Kaspersky host: * - * `-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. + * -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -WindowStyle Hidden -EncodedCommand 126 + * -WindowStyle Hidden -EncodedCommand 126 + * -NoProfile -EncodedCommand 0 (5/5) + * -NoProfile -ExecutionPolicy Bypass -Command 0 (5/5) * - * 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. + * The denial is at CreateProcess and is independent of the payload: `exit 0` is + * denied too, and bash reports it as `Permission denied`. So `-WindowStyle + * Hidden` + `-EncodedCommand` is the pair that has to stop being spelled. + * #16576 removed `-ExecutionPolicy Bypass` (kept in-payload below), which was + * the one flag of the three NOT in the signature — hooks kept failing. + * + * `-WindowStyle Hidden` was the shipped fix for #14815 (+#14828, #15117, + * #15447, #15767). Removing it is a real tradeoff and is recorded as such: its + * suppression was never measured — #14825 confirmed it *visually*, #16576's + * author stated it "remains unverified on a real box", and #15506's author + * argued it cannot help a `.cmd` child that has no console to inherit. The + * console is allocated by the parent chain, not by this command line. + * + * Do not restore the flag to fix a console report. That trades every hook on an + * AV host for a flicker. The answer is to shorten the interpreter chain — the + * shipped doctrine of #15520 and #15595 — or a launcher that owns no console. */ -export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile -WindowStyle Hidden' +export const WINDOWS_POWERSHELL_HOOK_SWITCHES = '-NoProfile' // Why: redirected PowerShell progress becomes CLIXML that can corrupt merged JSON // output. It must be the FIRST statement: Set-ExecutionPolicy autoloads diff --git a/src/main/claude/hook-service.test.ts b/src/main/claude/hook-service.test.ts index 315ee6dd228..531c092e3eb 100644 --- a/src/main/claude/hook-service.test.ts +++ b/src/main/claude/hook-service.test.ts @@ -40,9 +40,7 @@ describe('getWindowsManagedLifecycleHook', () => { const hook = getWindowsManagedLifecycleHook(scriptPath) expect(hook.args).toBeUndefined() - expect(hook.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook.command).not.toContain(scriptPath) // Why: Git Bash/MSYS mangles backslash paths and slash-prefixed switches. expect(hook.command.replace(/-EncodedCommand \S+$/, '')).not.toMatch(/\\| \/[a-zA-Z]+( |$)/) @@ -384,9 +382,7 @@ describe('ClaudeHookService.install', () => { for (const eventName of ['UserPromptSubmit', 'Stop', 'StopFailure']) { const hook = settings.hooks[eventName]?.[0]?.hooks?.[0] expect(hook?.args).toBeUndefined() - expect(hook?.command).toMatch( - /\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand / - ) + expect(hook?.command).toMatch(/\/powershell\.exe -NoProfile -EncodedCommand /) expect(hook?.command).not.toContain(scriptPath) const encoded = hook?.command.match(/-EncodedCommand (\S+)$/)?.[1] diff --git a/src/main/codex/hook-service-managed-install.test.ts b/src/main/codex/hook-service-managed-install.test.ts index 5d0021f9499..3e242dc2cf4 100644 --- a/src/main/codex/hook-service-managed-install.test.ts +++ b/src/main/codex/hook-service-managed-install.test.ts @@ -31,7 +31,7 @@ import { CodexHookService } from './hook-service' import { runExclusivelyForCodexTrustConfig } from './codex-trust-config-mutation-queue' const WINDOWS_POWERSHELL_LAUNCHER = - /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -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 f5b0f83af62..9765ef5b2f4 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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -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 36f6aa94b48..c6aef9aefc5 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -23,7 +23,7 @@ import { CURSOR_EVENTS, type CursorEvent } from './hook-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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -EncodedCommand \S+$/ type InstalledCursorHooks = { hooks: Record diff --git a/src/main/droid/hook-service.test.ts b/src/main/droid/hook-service.test.ts index bd145fdc1de..7486a12b71b 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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -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 a835a6278f8..551c40bb0b1 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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -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 4b1625614ac..251ae64fa33 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 -WindowStyle Hidden -EncodedCommand \S+$/ + /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -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