From 4300fa64bdd9f30aefa7ae107affc1701c5047e7 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Fri, 28 Aug 2026 19:11:24 +0900 Subject: [PATCH 01/10] fix(core): expand project directory in command hooks --- .../src/hooks/hook-runner.process.test.ts | 74 +++++++++++++++++++ packages/core/src/hooks/hookRunner.test.ts | 19 +++++ packages/core/src/hooks/hookRunner.ts | 3 + 3 files changed, 96 insertions(+) diff --git a/packages/core/src/hooks/hook-runner.process.test.ts b/packages/core/src/hooks/hook-runner.process.test.ts index 6c5161ee2dd..b1b42f8d008 100644 --- a/packages/core/src/hooks/hook-runner.process.test.ts +++ b/packages/core/src/hooks/hook-runner.process.test.ts @@ -67,6 +67,80 @@ const isRunning = (pid: number): boolean => { return !result.stdout.trim().startsWith('Z'); }; +describe.runIf(process.platform === 'win32')( + 'HookRunner Windows project directory expansion', + () => { + it.each(['QWEN_PROJECT_DIR', 'GEMINI_PROJECT_DIR', 'CLAUDE_PROJECT_DIR'])( + 'expands $%s for cmd.exe', + async (variable) => { + const tempDir = await mkdtemp( + join(tmpdir(), "qwen hook (project) & 'quoted' "), + ); + + try { + const runner = new HookRunner(); + const input: HookInput = { + session_id: 'project-dir-test', + transcript_path: join(tempDir, 'transcript.jsonl'), + cwd: tempDir, + hook_event_name: HookEventName.PreToolUse, + timestamp: new Date().toISOString(), + }; + + const result = await runner.executeHook( + { + type: HookType.Command, + command: `if exist $${variable} (echo FOUND) else (echo MISSING)`, + source: HooksConfigSource.Project, + }, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.stdout?.trim()).toBe('FOUND'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }, + ); + + it('expands QWEN_PROJECT_DIR for PowerShell', async () => { + const tempDir = await mkdtemp( + join(tmpdir(), "qwen hook (project) & 'quoted' "), + ); + + try { + const runner = new HookRunner(); + const input: HookInput = { + session_id: 'project-dir-test', + transcript_path: join(tempDir, 'transcript.jsonl'), + cwd: tempDir, + hook_event_name: HookEventName.PreToolUse, + timestamp: new Date().toISOString(), + }; + + const result = await runner.executeHook( + { + type: HookType.Command, + command: + "if (Test-Path $QWEN_PROJECT_DIR) { Write-Output 'FOUND' } else { Write-Output 'MISSING' }", + source: HooksConfigSource.Project, + shell: 'powershell', + }, + HookEventName.PreToolUse, + input, + ); + + expect(result.success).toBe(true); + expect(result.stdout?.trim()).toBe('FOUND'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + }, +); + describe.skipIf(process.platform === 'win32')( 'HookRunner process tree cancellation', () => { diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 6e8fbe5cdf5..d4c6f603394 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -924,6 +924,25 @@ describe('HookRunner', () => { }); describe('expandCommand', () => { + it('should expand QWEN_PROJECT_DIR placeholder', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $QWEN_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + const input = createMockInput({ cwd: '/test/project' }); + + await hookRunner.executeHook(hookConfig, HookEventName.PreToolUse, input); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; + expect(command).toContain('/test/project'); + expect(command).not.toContain('$QWEN_PROJECT_DIR'); + }); + it('should expand GEMINI_PROJECT_DIR placeholder', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index e3d2c96d8f6..ddbac637444 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -809,6 +809,8 @@ export class HookRunner { cwd: input.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false, + windowsVerbatimArguments: + process.platform === 'win32' && shellConfig.shell === 'cmd', // Own a process group so cancellation can signal the entire tree. detached: process.platform !== 'win32', }, @@ -1056,6 +1058,7 @@ export class HookRunner { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); const escapedCwd = escapeShellArg(input.cwd, shellType); return command + .replace(/\$QWEN_PROJECT_DIR/g, () => escapedCwd) .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility } From e69998fcdfc394b122e9bf6062b4dd3b03bc571a Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 00:19:26 +0900 Subject: [PATCH 02/10] fix(core): harden hook project directory expansion --- packages/core/src/hooks/hookRunner.test.ts | 49 ++++++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 14 +++++-- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index d4c6f603394..0688f49ffc4 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -932,6 +932,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo $QWEN_PROJECT_DIR', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); @@ -943,6 +944,54 @@ describe('HookRunner', () => { expect(command).not.toContain('$QWEN_PROJECT_DIR'); }); + it('leaves bash placeholders to the child environment', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: '# note $QWEN_PROJECT_DIR\necho $QWEN_PROJECT_DIRS', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/test/project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '# note $QWEN_PROJECT_DIR\necho $QWEN_PROJECT_DIRS', + ); + }); + + it('expands all supported placeholders without rescanning the path', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: + 'echo $QWEN_PROJECT_DIR $GEMINI_PROJECT_DIR $CLAUDE_PROJECT_DIR $QWEN_PROJECT_DIRS', + source: HooksConfigSource.Project, + shell: 'powershell', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:/test/$CLAUDE_PROJECT_DIR' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; + expect(command).toContain('C:/test/$CLAUDE_PROJECT_DIR'); + expect(command.match(/C:\/test\/\$CLAUDE_PROJECT_DIR/g)).toHaveLength(3); + expect(command).toContain('$QWEN_PROJECT_DIRS'); + }); + it('should expand GEMINI_PROJECT_DIR placeholder', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index ddbac637444..70f556568a4 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1056,11 +1056,17 @@ export class HookRunner { shellType: ShellType, ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); + // Bash already expands QWEN/GEMINI/CLAUDE_PROJECT_DIR from the child + // environment. Leave its command text untouched so placeholders in + // comments, quoted strings, and shell assignments retain native semantics. + if (shellType === 'bash') { + return command; + } const escapedCwd = escapeShellArg(input.cwd, shellType); - return command - .replace(/\$QWEN_PROJECT_DIR/g, () => escapedCwd) - .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) - .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility + return command.replace( + /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_])/g, + () => escapedCwd, + ); } /** From 7793f6f323035df06f4ebac3f3b7c776273b54d9 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 02:18:57 +0900 Subject: [PATCH 03/10] test(core): cover hook placeholder shell semantics --- packages/core/src/hooks/hookRunner.test.ts | 26 ++++++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 0688f49ffc4..b49787e9f10 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -212,6 +212,8 @@ describe('HookRunner', () => { // Benign inherited env and the hook's own vars are still present. expect(spawnOptions.env['PATH']).toBeDefined(); expect(spawnOptions.env['QWEN_PROJECT_DIR']).toBe('/test'); + expect(spawnOptions.env['GEMINI_PROJECT_DIR']).toBe('/test'); + expect(spawnOptions.env['CLAUDE_PROJECT_DIR']).toBe('/test'); } finally { if (originalServerToken === undefined) { delete process.env['QWEN_SERVER_TOKEN']; @@ -992,6 +994,28 @@ describe('HookRunner', () => { expect(command).toContain('$QWEN_PROJECT_DIRS'); }); + it('preserves PowerShell quoting and Unicode variable boundaries', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $QWEN_PROJECT_DIR $QWEN_PROJECT_DIRé', + source: HooksConfigSource.Project, + shell: 'powershell', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:/test/my project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + const command = spawnCall[1][spawnCall[1].length - 1]; + expect(command).toBe("echo 'C:/test/my project' $QWEN_PROJECT_DIRé"); + }); + it('should expand GEMINI_PROJECT_DIR placeholder', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); @@ -1000,6 +1024,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo $GEMINI_PROJECT_DIR', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); @@ -1019,6 +1044,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo $CLAUDE_PROJECT_DIR', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 70f556568a4..994edd73c61 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1064,7 +1064,7 @@ export class HookRunner { } const escapedCwd = escapeShellArg(input.cwd, shellType); return command.replace( - /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_])/g, + /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_\p{L}\p{N}])/gu, () => escapedCwd, ); } From a133fd572740701839aacaeaddc28e6200b25b9e Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 15:59:42 +0900 Subject: [PATCH 04/10] fix(core): preserve hook command shell behavior --- packages/core/src/hooks/hookRunner.test.ts | 7 ++++--- packages/core/src/hooks/hookRunner.ts | 16 +++++++--------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index b49787e9f10..b62943832e0 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -946,13 +946,13 @@ describe('HookRunner', () => { expect(command).not.toContain('$QWEN_PROJECT_DIR'); }); - it('leaves bash placeholders to the child environment', async () => { + it('escapes bash placeholders and preserves identifier boundaries', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); const hookConfig: HookConfig = { type: HookType.Command, - command: '# note $QWEN_PROJECT_DIR\necho $QWEN_PROJECT_DIRS', + command: 'echo $QWEN_PROJECT_DIR $QWEN_PROJECT_DIRS', source: HooksConfigSource.Project, shell: 'bash', }; @@ -965,7 +965,7 @@ describe('HookRunner', () => { const spawnCall = mockSpawn.mock.calls[0]; expect(spawnCall[1][spawnCall[1].length - 1]).toBe( - '# note $QWEN_PROJECT_DIR\necho $QWEN_PROJECT_DIRS', + 'echo /test/project $QWEN_PROJECT_DIRS', ); }); @@ -1063,6 +1063,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo hello', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 994edd73c61..11014b90481 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -801,16 +801,20 @@ export class HookRunner { ...hookConfig.env, }; + const useVerbatimArguments = + process.platform === 'win32' && shellConfig.shell === 'cmd'; const child = spawn( shellConfig.executable, - [...shellConfig.argsPrefix, command], + [ + ...shellConfig.argsPrefix, + useVerbatimArguments ? `"${command}"` : command, + ], { env, cwd: input.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false, - windowsVerbatimArguments: - process.platform === 'win32' && shellConfig.shell === 'cmd', + windowsVerbatimArguments: useVerbatimArguments, // Own a process group so cancellation can signal the entire tree. detached: process.platform !== 'win32', }, @@ -1056,12 +1060,6 @@ export class HookRunner { shellType: ShellType, ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); - // Bash already expands QWEN/GEMINI/CLAUDE_PROJECT_DIR from the child - // environment. Leave its command text untouched so placeholders in - // comments, quoted strings, and shell assignments retain native semantics. - if (shellType === 'bash') { - return command; - } const escapedCwd = escapeShellArg(input.cwd, shellType); return command.replace( /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_\p{L}\p{N}])/gu, From 4645a24164d2c68e6c5a07e1946558ecc02a3e85 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 16:17:14 +0900 Subject: [PATCH 05/10] fix(core): handle placeholder-first cmd hooks --- .../src/hooks/hook-runner.process.test.ts | 39 ++++++++++++++++++- packages/core/src/hooks/hookRunner.ts | 6 ++- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/core/src/hooks/hook-runner.process.test.ts b/packages/core/src/hooks/hook-runner.process.test.ts index b1b42f8d008..7a9798a7775 100644 --- a/packages/core/src/hooks/hook-runner.process.test.ts +++ b/packages/core/src/hooks/hook-runner.process.test.ts @@ -5,7 +5,7 @@ */ import { spawn, spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -132,6 +132,43 @@ describe.runIf(process.platform === 'win32')( input, ); + if (!result.success) console.log('placeholder-first result', result); + + expect(result.success).toBe(true); + expect(result.stdout?.trim()).toBe('FOUND'); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('runs a documented placeholder-first command through cmd.exe', async () => { + const tempDir = await mkdtemp( + join(tmpdir(), "qwen hook (project) & 'quoted' "), + ); + const hookPath = join(tempDir, '.qwen', 'hooks', 'security-check.cmd'); + + try { + await mkdir(join(tempDir, '.qwen', 'hooks'), { recursive: true }); + await writeFile(hookPath, '@echo FOUND\r\n', { encoding: 'utf8' }); + const runner = new HookRunner(); + const input: HookInput = { + session_id: 'project-dir-test', + transcript_path: join(tempDir, 'transcript.jsonl'), + cwd: tempDir, + hook_event_name: HookEventName.PreToolUse, + timestamp: new Date().toISOString(), + }; + + const result = await runner.executeHook( + { + type: HookType.Command, + command: '$QWEN_PROJECT_DIR/.qwen/hooks/security-check.cmd', + source: HooksConfigSource.Project, + }, + HookEventName.PreToolUse, + input, + ); + expect(result.success).toBe(true); expect(result.stdout?.trim()).toBe('FOUND'); } finally { diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 11014b90481..571f86fb0d7 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -807,7 +807,11 @@ export class HookRunner { shellConfig.executable, [ ...shellConfig.argsPrefix, - useVerbatimArguments ? `"${command}"` : command, + useVerbatimArguments && command.startsWith('"') + ? `"${command}` + : useVerbatimArguments + ? `"${command}"` + : command, ], { env, From b48d0780478af299b2908c411051a3d97fd65e48 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 16:49:41 +0900 Subject: [PATCH 06/10] fix(core): apply cmd command wrapper --- packages/core/src/hooks/hookRunner.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 571f86fb0d7..11014b90481 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -807,11 +807,7 @@ export class HookRunner { shellConfig.executable, [ ...shellConfig.argsPrefix, - useVerbatimArguments && command.startsWith('"') - ? `"${command}` - : useVerbatimArguments - ? `"${command}"` - : command, + useVerbatimArguments ? `"${command}"` : command, ], { env, From c85f01c42cf38ac6d3964cc50b708c5315536dec Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 18:59:30 +0900 Subject: [PATCH 07/10] fix(core): make expandCommand quote-aware Track whether a $QWEN/GEMINI/CLAUDE_PROJECT_DIR placeholder sits inside single or double quotes, as written by the hook author, and substitute per-region instead of always emitting the escaped form. A placeholder already wrapped in quotes (e.g. "$QWEN_PROJECT_DIR") was getting a second, nested layer of quoting on top of the author's own, breaking the idiomatic form on bash, cmd, and PowerShell alike. Also absorb the literal path suffix after an unquoted cmd placeholder into the same quoted region (findCmdTokenEnd): unlike bash, real cmd.exe does not concatenate a quoted token with adjacent unquoted text into one argument, so the documented placeholder-first hook form ($QWEN_PROJECT_DIR/.qwen/hooks/check.cmd) was splitting into two argv entries and failing with "not recognized as an internal or external command" on genuine Windows. Drops the leftover debug console.log in hook-runner.process.test.ts. --- .../src/hooks/hook-runner.process.test.ts | 2 - packages/core/src/hooks/hookRunner.test.ts | 161 ++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 133 ++++++++++++++- 3 files changed, 289 insertions(+), 7 deletions(-) diff --git a/packages/core/src/hooks/hook-runner.process.test.ts b/packages/core/src/hooks/hook-runner.process.test.ts index 7a9798a7775..b04ecf2a5a5 100644 --- a/packages/core/src/hooks/hook-runner.process.test.ts +++ b/packages/core/src/hooks/hook-runner.process.test.ts @@ -132,8 +132,6 @@ describe.runIf(process.platform === 'win32')( input, ); - if (!result.success) console.log('placeholder-first result', result); - expect(result.success).toBe(true); expect(result.stdout?.trim()).toBe('FOUND'); } finally { diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index b62943832e0..e9531361b86 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -1073,6 +1073,167 @@ describe('HookRunner', () => { const command = spawnCall[1][spawnCall[1].length - 1]; // Last arg is the command expect(command).toBe('echo hello'); }); + + it('splices a bare path into a bash double-quoted placeholder instead of nesting quotes', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'ls -d "$QWEN_PROJECT_DIR"', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/qwen hook (project)' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + 'ls -d "/tmp/qwen hook (project)"', + ); + }); + + it('leaves a bash single-quoted placeholder untouched', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: "echo '[$QWEN_PROJECT_DIR]'", + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/qwen plain project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + "echo '[$QWEN_PROJECT_DIR]'", + ); + }); + + it('escapes double-quote-sensitive characters when splicing into a bash double-quoted placeholder', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'ls -d "$QWEN_PROJECT_DIR"', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/say "hi" $HOME `x`' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + 'ls -d "/tmp/say \\"hi\\" \\$HOME \\`x\\`"', + ); + }); + + it('splices a bare path into a cmd double-quoted placeholder instead of nesting quotes', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: + 'if exist "$QWEN_PROJECT_DIR" (echo FOUND) else (echo MISSING)', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\qwen hook & project' }), + ); + + // On win32 with cmd, the whole expanded command is additionally + // wrapped in an outer quote pair for windowsVerbatimArguments. + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"if exist "C:\\qwen hook & project" (echo FOUND) else (echo MISSING)"', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + + it('splices a bare path into a PowerShell double-quoted placeholder instead of nesting quotes', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: '"$QWEN_PROJECT_DIR"', + source: HooksConfigSource.Project, + shell: 'powershell', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: "C:\\users\\root\\qwen 'hook'" }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"C:\\users\\root\\qwen \'hook\'"', + ); + }); + + it('doubles an embedded single quote when splicing into a PowerShell single-quoted placeholder', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: "(Test-Path '$QWEN_PROJECT_DIR')", + source: HooksConfigSource.Project, + shell: 'powershell', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: "C:\\users\\root\\qwen 'hook'" }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + "(Test-Path 'C:\\users\\root\\qwen ''hook''')", + ); + }); }); describe('convertPlainTextToHookOutput', () => { diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 11014b90481..3a1e09121d9 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1060,11 +1060,134 @@ export class HookRunner { shellType: ShellType, ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); - const escapedCwd = escapeShellArg(input.cwd, shellType); - return command.replace( - /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_\p{L}\p{N}])/gu, - () => escapedCwd, - ); + return this.expandProjectDirPlaceholders(command, input.cwd, shellType); + } + + /** + * Replaces every `$QWEN_PROJECT_DIR` / `$GEMINI_PROJECT_DIR` / + * `$CLAUDE_PROJECT_DIR` placeholder with the hook's cwd. + * + * A placeholder written unquoted (`$QWEN_PROJECT_DIR/hooks/check.sh`) needs + * the substituted path itself quoted so spaces survive; a placeholder + * already written inside quotes (`"$QWEN_PROJECT_DIR/hooks/check.sh"`) must + * NOT be re-quoted, or the extra quote pair nests inside the author's own + * quotes and corrupts the command. This tracks the quote region the + * placeholder sits in — as written in the source command, not in the + * substituted output — and picks the substitution for that region. + */ + private expandProjectDirPlaceholders( + command: string, + cwd: string, + shellType: ShellType, + ): string { + const placeholderPattern = + /\$(?:QWEN|GEMINI|CLAUDE)_PROJECT_DIR(?![0-9A-Za-z_\p{L}\p{N}])/gu; + // cmd.exe has no single-quoted string syntax; a `'` there is a literal + // character, not a quote delimiter. + const tracksSingleQuotes = shellType !== 'cmd'; + + let inSingleQuote = false; + let inDoubleQuote = false; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = placeholderPattern.exec(command))) { + for (let i = lastIndex; i < match.index; i++) { + const ch = command[i]; + if (tracksSingleQuotes && ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } + } + result += command.slice(lastIndex, match.index); + const afterPlaceholder = placeholderPattern.lastIndex; + + if (shellType === 'cmd' && !inDoubleQuote) { + // Unlike bash, cmd.exe does NOT concatenate a quoted token directly + // followed by unquoted text into one argument — it starts a new + // token at the closing quote. So a documented placeholder-first + // path like `$QWEN_PROJECT_DIR/.qwen/hooks/check.cmd` would expand + // to `"C:\..."` + `/.qwen/hooks/check.cmd` as two separate tokens, + // and cmd tries to execute just the quoted directory. Pull the + // literal suffix up to the next cmd delimiter into the same quoted + // region as the expanded path. + const suffixEnd = this.findCmdTokenEnd(command, afterPlaceholder); + result += escapeShellArg( + cwd + command.slice(afterPlaceholder, suffixEnd), + 'cmd', + ); + lastIndex = suffixEnd; + placeholderPattern.lastIndex = suffixEnd; + continue; + } + + result += this.substituteProjectDirPlaceholder( + match[0], + cwd, + shellType, + inSingleQuote, + inDoubleQuote, + ); + lastIndex = afterPlaceholder; + } + result += command.slice(lastIndex); + return result; + } + + /** Index of the next cmd.exe argument delimiter at or after `start`. */ + private findCmdTokenEnd(command: string, start: number): number { + const delimiters = new Set([' ', '\t', '&', '|', '<', '>', '(', ')']); + let i = start; + while (i < command.length && !delimiters.has(command[i])) { + i++; + } + return i; + } + + private substituteProjectDirPlaceholder( + matchedText: string, + cwd: string, + shellType: ShellType, + inSingleQuote: boolean, + inDoubleQuote: boolean, + ): string { + switch (shellType) { + case 'bash': + if (inSingleQuote) { + // Nothing expands inside '...' in bash; leave the placeholder text + // as-is rather than splicing a path that could contain a `'` and + // break out of the author's quotes. + return matchedText; + } + if (inDoubleQuote) { + // Only \ $ ` " are special inside "..." in bash; escape those in + // the raw path so it can't terminate the surrounding quotes. + return cwd.replace(/([\\$`"])/g, '\\$1'); + } + return escapeShellArg(cwd, 'bash'); + case 'cmd': + // The unquoted case is handled by the caller (it also absorbs the + // literal path suffix into the quoted region), so this is only ever + // reached with inDoubleQuote true: cmd.exe has no $VAR semantics + // inside "...", so splice the raw path into the existing quoted + // region instead of nesting another pair. + return cwd.replace(/"/g, '""'); + case 'powershell': + if (inSingleQuote) { + // PowerShell literal strings escape an embedded `'` by doubling it. + return cwd.replace(/'/g, "''"); + } + if (inDoubleQuote) { + // Escape ` $ " with PowerShell's backtick escape so the raw path + // can't trigger interpolation or close the surrounding quotes. + return cwd.replace(/([`$"])/g, '`$1'); + } + return escapeShellArg(cwd, 'powershell'); + default: + return escapeShellArg(cwd, shellType); + } } /** From ae9bf7402a4f80b8a70f145c7cbe3c8a51f444d8 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 19:05:13 +0900 Subject: [PATCH 08/10] test(core): cover bare cmd placeholder path-suffix absorption Fills the last gap in the "unit tests for \"\$QWEN_PROJECT_DIR\", '\$QWEN_PROJECT_DIR' and the bare form on each shell" matrix requested in review: bash and PowerShell already had bare-form coverage, cmd's bare form was only exercised by the win32-gated real-shell integration test, which test_windows never runs on pull_request. --- packages/core/src/hooks/hookRunner.test.ts | 47 ++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index e9531361b86..53fcb0dc262 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -1189,6 +1189,53 @@ describe('HookRunner', () => { } }); + it('absorbs the literal path suffix after a bare cmd placeholder into the same quoted region', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: '$QWEN_PROJECT_DIR/.qwen/hooks/check.cmd', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\qwen hook & project' }), + ); + + // The literal suffix after the placeholder must land inside the + // same quote pair as the expanded path — real cmd.exe treats text + // right after a closing quote as a new argument, so leaving it + // outside would split the executable path into two tokens. + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '""C:\\qwen hook & project/.qwen/hooks/check.cmd""', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + it('splices a bare path into a PowerShell double-quoted placeholder instead of nesting quotes', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); From d677a856e09657d835578a058728cf961f438f8d Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sat, 29 Aug 2026 20:18:31 +0900 Subject: [PATCH 09/10] fix(core): handle bash comments/escapes and cmd delimiters in expandCommand The quote-region scanner toggled inSingleQuote/inDoubleQuote on every quote character, including ones inside a bash # comment or escaped with a backslash (` for PowerShell, ^ for cmd). An apostrophe in a comment like "# don't touch this" left the scanner believing the rest of the command was single-quoted, silently leaving later placeholders unexpanded. Skip the character after an escape char (when not already inside single quotes) and treat an unquoted # at a word boundary as a bash comment running to the next newline; a placeholder written inside a comment is left untouched. findCmdTokenEnd's delimiter set was missing `,` `;` `=` `"` `^`, so a second placeholder immediately after a `;` or `=` was pulled as literal text into the first placeholder's quoted region instead of being recognised and expanded on its own. --- packages/core/src/hooks/hookRunner.test.ts | 92 ++++++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 57 +++++++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index 53fcb0dc262..0b496b46916 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -1120,6 +1120,52 @@ describe('HookRunner', () => { ); }); + it('does not let an apostrophe inside a bash comment corrupt quote tracking for later placeholders', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: "# don't touch this\nls -d $GEMINI_PROJECT_DIR", + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + "# don't touch this\nls -d /tmp/project", + ); + }); + + it('does not let an escaped double quote corrupt bash quote tracking for later placeholders', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo \\" >/dev/null; ls -d $QWEN_PROJECT_DIR', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + 'echo \\" >/dev/null; ls -d /tmp/project', + ); + }); + it('escapes double-quote-sensitive characters when splicing into a bash double-quoted placeholder', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); @@ -1143,6 +1189,52 @@ describe('HookRunner', () => { ); }); + it('does not let findCmdTokenEnd swallow a second bare cmd placeholder', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $QWEN_PROJECT_DIR;$QWEN_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\proj' }), + ); + + // `;` must stop the literal-suffix absorption so the second + // placeholder is still recognised and expanded on its own, instead + // of being pulled as literal text into the first quoted region. + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"echo "C:\\proj";"C:\\proj""', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + it('splices a bare path into a cmd double-quoted placeholder instead of nesting quotes', async () => { // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only // reachable via the platform's global shell configuration. diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 3a1e09121d9..a5aa581ea82 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1088,13 +1088,42 @@ export class HookRunner { let inSingleQuote = false; let inDoubleQuote = false; + let inComment = false; let result = ''; let lastIndex = 0; let match: RegExpExecArray | null; + // The character that escapes the next one when not inside single quotes + // (bash and PowerShell); cmd's `^` escapes even inside double quotes. + const escapeChar = + shellType === 'bash' ? '\\' : shellType === 'powershell' ? '`' : '^'; while ((match = placeholderPattern.exec(command))) { for (let i = lastIndex; i < match.index; i++) { const ch = command[i]; + if (inComment) { + if (ch === '\n') { + inComment = false; + } + continue; + } + if (!inSingleQuote && ch === escapeChar) { + // The escaped character can't end a quote region or start a + // comment — bash's `\'`/`\"`, PowerShell's `` `" ``, cmd's `^&`. + i++; + continue; + } + if ( + shellType === 'bash' && + !inSingleQuote && + !inDoubleQuote && + ch === '#' && + (i === 0 || /\s/.test(command[i - 1])) + ) { + // An unquoted `#` at a word boundary starts a bash comment that + // runs to the next newline; nothing inside it is live shell text. + inComment = true; + continue; + } if (tracksSingleQuotes && ch === "'" && !inDoubleQuote) { inSingleQuote = !inSingleQuote; } else if (ch === '"' && !inSingleQuote) { @@ -1104,6 +1133,14 @@ export class HookRunner { result += command.slice(lastIndex, match.index); const afterPlaceholder = placeholderPattern.lastIndex; + if (inComment) { + // A placeholder written inside a `#` comment is documentation, not + // live code; leave it exactly as the author wrote it. + result += match[0]; + lastIndex = afterPlaceholder; + continue; + } + if (shellType === 'cmd' && !inDoubleQuote) { // Unlike bash, cmd.exe does NOT concatenate a quoted token directly // followed by unquoted text into one argument — it starts a new @@ -1138,7 +1175,25 @@ export class HookRunner { /** Index of the next cmd.exe argument delimiter at or after `start`. */ private findCmdTokenEnd(command: string, start: number): number { - const delimiters = new Set([' ', '\t', '&', '|', '<', '>', '(', ')']); + // `,` `;` `=` also separate cmd arguments; `"` starts a new quoted + // region that must not be absorbed into this one; `^` is cmd's escape + // character and needs its own (unhandled) escaping logic to consume + // safely, so stop before it rather than swallowing it blind. + const delimiters = new Set([ + ' ', + '\t', + '&', + '|', + '<', + '>', + '(', + ')', + ',', + ';', + '=', + '"', + '^', + ]); let i = start; while (i < command.length && !delimiters.has(command[i])) { i++; From c7a7804976c633886599f920a01661f220bc5f71 Mon Sep 17 00:00:00 2001 From: Chandler M Date: Sun, 30 Aug 2026 14:47:52 +0900 Subject: [PATCH 10/10] fix(core): close scanner gaps found in round-4 bot review - cmd's ^ no longer suppresses quote-tracking inside "..." (it's literal there) - cmd quote state now resets on newline instead of leaking across lines - a placeholder escaped by the author (\$QWEN_PROJECT_DIR) is left untouched - bash comment detection recognizes a word boundary after ; | & < > ( ), not just whitespace - PowerShell comment detection no longer requires a word boundary at all - expandCommand now logs the expanded command, not just the pre-expansion text - documents the placeholder expansion semantics in hooks.md Also strengthens the parent-exit-surviving cmd.exe test (branches were both exiting 0, so it couldn't actually tell FOUND from MISSING) and adds a cmd unit test for a literal single quote, verified to catch the tracksSingleQuotes mutation the bot's review demonstrated. --- docs/users/features/hooks.md | 8 + .../src/hooks/hook-runner.process.test.ts | 5 +- packages/core/src/hooks/hookRunner.test.ts | 201 ++++++++++++++++++ packages/core/src/hooks/hookRunner.ts | 51 ++++- 4 files changed, 255 insertions(+), 10 deletions(-) diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 091a549d98e..16972bed488 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -79,6 +79,14 @@ Command hooks execute commands via child processes. Input JSON is passed through } ``` +`$QWEN_PROJECT_DIR` (and the `$GEMINI_PROJECT_DIR` / `$CLAUDE_PROJECT_DIR` +compatibility forms) is expanded before the command reaches the shell, not by +the shell itself. Outside quotes the expanded path is auto-quoted for you +(including any literal path suffix immediately after it, e.g. on Windows +`cmd.exe`); inside `"..."` the raw path is spliced into your existing quotes. +Inside bash `'...'`, nothing expands — that's standard bash single-quote +semantics, so the placeholder is left exactly as written. + ### HTTP Hooks HTTP hooks send hook input as POST requests to specified URLs. They support URL whitelists, DNS-level SSRF protection, environment variable interpolation, and other security features. diff --git a/packages/core/src/hooks/hook-runner.process.test.ts b/packages/core/src/hooks/hook-runner.process.test.ts index 1d8861cf3d1..50e9a11a518 100644 --- a/packages/core/src/hooks/hook-runner.process.test.ts +++ b/packages/core/src/hooks/hook-runner.process.test.ts @@ -203,8 +203,11 @@ describe.runIf(process.platform === 'win32')( const result = await runner.executeHook( { type: HookType.Command, + // exit codes diverge on the two branches (0 vs 3) since the + // supervisor discards hook stdout, so `... else (echo MISSING)` + // would leave both outcomes indistinguishable at exit code 0. command: - 'if exist "$QWEN_PROJECT_DIR" (echo FOUND) else (echo MISSING)', + 'if exist "$QWEN_PROJECT_DIR" (echo FOUND) else (exit 3)', source: HooksConfigSource.Project, }, HookEventName.MessageDisplay, diff --git a/packages/core/src/hooks/hookRunner.test.ts b/packages/core/src/hooks/hookRunner.test.ts index d9208d3781d..55958e25604 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -1194,6 +1194,75 @@ describe('HookRunner', () => { ); }); + it('leaves an escaped placeholder untouched instead of substituting it', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo "export QWEN_PROJECT_DIR=\\$QWEN_PROJECT_DIR"', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + 'echo "export QWEN_PROJECT_DIR=\\$QWEN_PROJECT_DIR"', + ); + }); + + it('recognizes a bash comment starting right after a shell metacharacter, not just whitespace', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo start;# note "open\nls -d $QWEN_PROJECT_DIR', + source: HooksConfigSource.Project, + shell: 'bash', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: '/tmp/my dir' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + 'echo start;# note "open\nls -d \'/tmp/my dir\'', + ); + }); + + it('recognizes a PowerShell comment starting mid-token, not just at a word boundary', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: "echo foo#it's fine\n(Test-Path $GEMINI_PROJECT_DIR)", + source: HooksConfigSource.Project, + shell: 'powershell', + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:/proj' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + "echo foo#it's fine\n(Test-Path 'C:/proj')", + ); + }); + it('escapes double-quote-sensitive characters when splicing into a bash double-quoted placeholder', async () => { const mockProcess = createMockProcess(0, 'result'); mockSpawn.mockImplementation(() => mockProcess); @@ -1308,6 +1377,138 @@ describe('HookRunner', () => { } }); + it('does not track a literal single quote as a quote region for cmd', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo it\'s "$QWEN_PROJECT_DIR"', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\qwen hook' }), + ); + + // cmd has no single-quote string syntax; the `'` above is literal + // and must not desync tracking of the real `"..."` that follows. + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"echo it\'s "C:\\qwen hook""', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + + it('does not let cmd `^` protect a closing quote, since caret is literal inside "..."', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo "a^" & echo $QWEN_PROJECT_DIR', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\My Projects' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"echo "a^" & echo "C:\\My Projects""', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + + it('resets cmd quote tracking on a newline, since cmd lexes line-by-line', async () => { + // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only + // reachable via the platform's global shell configuration. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + const previousMsystem = process.env['MSYSTEM']; + const previousComSpec = process.env['ComSpec']; + delete process.env['MSYSTEM']; + process.env['ComSpec'] = 'C:\\Windows\\System32\\cmd.exe'; + + try { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: + 'REM uses 3" pipe\nif exist $QWEN_PROJECT_DIR\\lock (echo busy) else (echo free)', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\My Project' }), + ); + + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '"REM uses 3" pipe\nif exist "C:\\My Project\\lock" (echo busy) else (echo free)"', + ); + } finally { + if (previousMsystem === undefined) { + delete process.env['MSYSTEM']; + } else { + process.env['MSYSTEM'] = previousMsystem; + } + if (previousComSpec === undefined) { + delete process.env['ComSpec']; + } else { + process.env['ComSpec'] = previousComSpec; + } + } + }); + it('splices a bare path into a cmd double-quoted placeholder instead of nesting quotes', async () => { // hookConfig.shell only overrides to 'bash' | 'powershell'; cmd is only // reachable via the platform's global shell configuration. diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 63e6b9b0c21..9ad9dbd4deb 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -1445,7 +1445,13 @@ export class HookRunner { shellType: ShellType, ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); - return this.expandProjectDirPlaceholders(command, input.cwd, shellType); + const expanded = this.expandProjectDirPlaceholders( + command, + input.cwd, + shellType, + ); + debugLogger.debug(`Expanded hook command: ${expanded}`); + return expanded; } /** @@ -1477,12 +1483,14 @@ export class HookRunner { let result = ''; let lastIndex = 0; let match: RegExpExecArray | null; - // The character that escapes the next one when not inside single quotes - // (bash and PowerShell); cmd's `^` escapes even inside double quotes. + // The character that escapes the next one: not inside single quotes for + // bash/PowerShell; cmd's `^` additionally loses its escaping power + // inside "..." (a caret there is a literal character). const escapeChar = shellType === 'bash' ? '\\' : shellType === 'powershell' ? '`' : '^'; while ((match = placeholderPattern.exec(command))) { + let matchIsEscaped = false; for (let i = lastIndex; i < match.index; i++) { const ch = command[i]; if (inComment) { @@ -1491,9 +1499,22 @@ export class HookRunner { } continue; } - if (!inSingleQuote && ch === escapeChar) { + if (shellType === 'cmd' && (ch === '\n' || ch === '\r')) { + // cmd lexes line-by-line; a stray/odd quote on an earlier line + // must not leak its quote state into the next one. + inDoubleQuote = false; + } + if ( + !inSingleQuote && + (shellType !== 'cmd' || !inDoubleQuote) && + ch === escapeChar + ) { // The escaped character can't end a quote region or start a - // comment — bash's `\'`/`\"`, PowerShell's `` `" ``, cmd's `^&`. + // comment — bash's `\'`/`\"`, PowerShell's `` `" ``, cmd's `^&` + // outside quotes. cmd's `^` is a literal character inside "...". + if (i + 1 === match.index) { + matchIsEscaped = true; + } i++; continue; } @@ -1502,11 +1523,14 @@ export class HookRunner { !inSingleQuote && !inDoubleQuote && ch === '#' && - (i === 0 || /\s/.test(command[i - 1])) + (shellType === 'powershell' || + i === 0 || + /[\s;|&<>()]/.test(command[i - 1])) ) { - // An unquoted `#` at a word boundary starts a line comment (bash - // and PowerShell both use `#`) that runs to the next newline; - // nothing inside it is live shell text. + // An unquoted `#` starts a line comment that runs to the next + // newline; nothing inside it is live shell text. PowerShell + // treats any unquoted `#` as a comment start; bash only at a + // word boundary (a shell metacharacter or start-of-command). inComment = true; continue; } @@ -1519,6 +1543,15 @@ export class HookRunner { result += command.slice(lastIndex, match.index); const afterPlaceholder = placeholderPattern.lastIndex; + if (matchIsEscaped) { + // The placeholder's own `$` was escaped by the author + // (`\$QWEN_PROJECT_DIR`); leave it untouched so the shell's own + // escaping suppresses expansion as they intended. + result += match[0]; + lastIndex = afterPlaceholder; + continue; + } + if (inComment) { // A placeholder written inside a `#` comment is documentation, not // live code; leave it exactly as the author wrote it.