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 ba22b6a50cb..50e9a11a518 100644 --- a/packages/core/src/hooks/hook-runner.process.test.ts +++ b/packages/core/src/hooks/hook-runner.process.test.ts @@ -5,7 +5,14 @@ */ import { spawn, spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -67,6 +74,155 @@ 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 }); + } + }); + + 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 { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('expands and verbatim-quotes $QWEN_PROJECT_DIR for a parent-exit-surviving cmd.exe hook', async () => { + // Surviving hooks (MessageDisplay/StopFailure/SessionDelete) run + // through the detached SURVIVING_HOOK_SUPERVISOR_SOURCE process + // instead of the regular spawn path, so this exercises the + // windowsVerbatimArguments wiring threaded into that separate path. + 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.MessageDisplay, + timestamp: new Date().toISOString(), + }; + + 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 (exit 3)', + source: HooksConfigSource.Project, + }, + HookEventName.MessageDisplay, + input, + ); + + expect(result.success).toBe(true); + expect(result.exitCode).toBe(0); + } 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 c6d40d9e739..55958e25604 100644 --- a/packages/core/src/hooks/hookRunner.test.ts +++ b/packages/core/src/hooks/hookRunner.test.ts @@ -217,6 +217,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']; @@ -929,6 +931,96 @@ 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, + shell: 'powershell', + }; + 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('escapes bash placeholders and preserves identifier boundaries', async () => { + const mockProcess = createMockProcess(0, 'result'); + mockSpawn.mockImplementation(() => mockProcess); + + const hookConfig: HookConfig = { + type: HookType.Command, + command: 'echo $QWEN_PROJECT_DIR $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( + 'echo /test/project $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('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); @@ -937,6 +1029,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo $GEMINI_PROJECT_DIR', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); @@ -956,6 +1049,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo $CLAUDE_PROJECT_DIR', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); @@ -974,6 +1068,7 @@ describe('HookRunner', () => { type: HookType.Command, command: 'echo hello', source: HooksConfigSource.Project, + shell: 'powershell', }; const input = createMockInput({ cwd: '/test/project' }); @@ -983,6 +1078,575 @@ 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('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 apostrophe inside a PowerShell 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\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( + "# don't touch this\n(Test-Path 'C:/proj')", + ); + }); + + 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('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); + + 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('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('does not let findCmdTokenEnd swallow a newline into the quoted path', 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\\hook.cmd\necho done', + source: HooksConfigSource.Project, + }; + + await hookRunner.executeHook( + hookConfig, + HookEventName.PreToolUse, + createMockInput({ cwd: 'C:\\proj' }), + ); + + // A newline ends the current command line; `echo done` on the next + // line must stay literal, not get pulled into the quoted path. + const spawnCall = mockSpawn.mock.calls[0]; + expect(spawnCall[1][spawnCall[1].length - 1]).toBe( + '""C:\\proj\\hook.cmd"\necho done"', + ); + } 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 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. + 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('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); + + 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 3ca32b17440..9ad9dbd4deb 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -74,12 +74,14 @@ const [ executable, argsValue, nodeOptionsValue, + verbatimArgumentsValue, ] = process.argv.slice(1); const timeout = Number(timeoutValue); const grace = Number(graceValue); const args = JSON.parse(argsValue); const originalNodeOptions = JSON.parse(nodeOptionsValue); +const windowsVerbatimArguments = verbatimArgumentsValue === 'true'; const pollInterval = ${HOOK_PROCESS_GROUP_POLL_MS}; const timeoutExitCode = ${SURVIVING_HOOK_TIMEOUT_EXIT_CODE}; const signalExitCode = 143; @@ -205,6 +207,7 @@ try { env: hookEnv, stdio: [inputFd, 'ignore', 'ignore'], shell: false, + windowsVerbatimArguments, detached: process.platform !== 'win32', }); sendStatus('pid:' + hook.pid); @@ -1089,6 +1092,15 @@ export class HookRunner { ...hookConfig.env, }; + // cmd.exe hooks need their argument marshalled verbatim so Node + // doesn't mangle quoted command text; the supervisor spawn below needs + // the same treatment, both for its own args array and internally in + // SURVIVING_HOOK_SUPERVISOR_SOURCE's own spawn() call. + const useVerbatimArguments = + process.platform === 'win32' && shellConfig.shell === 'cmd'; + const verbatimCommand = useVerbatimArguments + ? `"${command}"` + : command; const survivesParentExit = eventName === HookEventName.MessageDisplay || eventName === HookEventName.StopFailure || @@ -1110,8 +1122,9 @@ export class HookRunner { String(timeout), String(HOOK_TERMINATE_GRACE_MS), shellConfig.executable, - JSON.stringify([...shellConfig.argsPrefix, command]), + JSON.stringify([...shellConfig.argsPrefix, verbatimCommand]), JSON.stringify(env['NODE_OPTIONS'] ?? null), + String(useVerbatimArguments), ], { env: supervisorEnv, @@ -1141,12 +1154,13 @@ export class HookRunner { resolveSupervisorStarted(); child = spawn( shellConfig.executable, - [...shellConfig.argsPrefix, command], + [...shellConfig.argsPrefix, verbatimCommand], { env, cwd: input.cwd, stdio: ['pipe', 'pipe', 'pipe'], shell: false, + windowsVerbatimArguments: useVerbatimArguments, // Own a process group so cancellation can signal the entire tree. detached: process.platform !== 'win32', }, @@ -1431,10 +1445,226 @@ export class HookRunner { shellType: ShellType, ): string { debugLogger.debug(`Expanding hook command: ${command} (cwd: ${input.cwd})`); - const escapedCwd = escapeShellArg(input.cwd, shellType); - return command - .replace(/\$GEMINI_PROJECT_DIR/g, () => escapedCwd) - .replace(/\$CLAUDE_PROJECT_DIR/g, () => escapedCwd); // For compatibility + const expanded = this.expandProjectDirPlaceholders( + command, + input.cwd, + shellType, + ); + debugLogger.debug(`Expanded hook command: ${expanded}`); + return expanded; + } + + /** + * 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 inComment = false; + let result = ''; + let lastIndex = 0; + let match: RegExpExecArray | null; + // 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) { + if (ch === '\n') { + inComment = false; + } + continue; + } + 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 `^&` + // outside quotes. cmd's `^` is a literal character inside "...". + if (i + 1 === match.index) { + matchIsEscaped = true; + } + i++; + continue; + } + if ( + (shellType === 'bash' || shellType === 'powershell') && + !inSingleQuote && + !inDoubleQuote && + ch === '#' && + (shellType === 'powershell' || + i === 0 || + /[\s;|&<>()]/.test(command[i - 1])) + ) { + // 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; + } + if (tracksSingleQuotes && ch === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (ch === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } + } + 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. + 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 + // 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 { + // `,` `;` `=` 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; a newline + // ends the current command line outright. + const delimiters = new Set([ + ' ', + '\t', + '\n', + '\r', + '&', + '|', + '<', + '>', + '(', + ')', + ',', + ';', + '=', + '"', + '^', + ]); + 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); + } } /**