fix(agent-hooks): stop the Windows hook launcher spelling the AV-denied flag pair (STA-5237)

`-WindowStyle Hidden` + `-EncodedCommand` is denied at CreateProcess by
Kaspersky on Windows 11, whatever the payload decodes to. Bash reports it as
`Permission denied` and every managed hook event fails, so agent status never
arrives; the parent shell also briefly cannot spawn anything afterwards, so a
denied hook can take the user's next command down with it.

Measured on the reporting host (#16003), with a harmless `exit 0` payload:

  -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand  126
  -NoProfile -WindowStyle Hidden -EncodedCommand                          126
  -WindowStyle Hidden -EncodedCommand                                     126
  -NoProfile -EncodedCommand                                              0 (5/5)

#16576 removed `-ExecutionPolicy Bypass`, which is the one flag of the three
NOT in the signature, so hooks kept failing after that fix. The pair that has
to stop being spelled is `-WindowStyle Hidden` + `-EncodedCommand`.

Because the change is to the shared switch constant, it covers every site that
spells the denied pair in one edit: Claude via `wrapWindowsPowerShellEncodedCommand`,
gemini/cursor/droid/command-code/copilot via `wrapWindowsHookCommand`, the
`runtime-home-hook-command` unsafe-HOME fallback, and the spaced-path fallback
for codex/grok/devin/antigravity. Only a flag is removed, so parser and payload
compatibility is unchanged for every executor: the string is still a PowerShell
command line, still one self-contained token, still base64-shielded.

The tradeoff, recorded rather than hidden: `-WindowStyle Hidden` was the shipped
fix for #14815 (+#14828, #15117, #15447, #15767), and this removes it. 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 with no console to inherit. The console is allocated
by the parent chain, not by this command line. A live window measurement is
still outstanding and is called out in the PR.

Also adds `windows-hook-payload-delivery.test.ts` to the PR CI Windows leg,
which had never run it.
This commit is contained in:
Brennan Benson
2026-08-27 10:43:53 -07:00
parent 642607bfa7
commit c8271aa8e8
11 changed files with 60 additions and 53 deletions
+1
View File
@@ -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
+2 -2
View File
@@ -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)
})
@@ -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', () => {
@@ -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
+2 -6
View File
@@ -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]
@@ -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)
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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<string, { command?: string }[]>
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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