diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index c40678f0..a1f49e85 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -68,14 +68,21 @@ function needsShell(binaryPath: string): boolean { } /** - * Minimal quoting for command‑line arguments when building a shell - * invocation. We only escape spaces and double quotes since our - * callers only ever use simple strings (paths, flags, literals) and - * the shell itself will handle most quoting rules. + * Quote an argument for cmd.exe shell invocation on Windows. + * + * cmd.exe rules: + * - Double-quote args containing spaces or special characters + * - Inside double quotes, escape literal `"` as `""` + * - `%` is expanded as env var even inside double quotes — escape as `%%` + * - `^`, `&`, `|`, `<`, `>` are safe inside double quotes + * + * Our callers only pass controlled strings (binary paths, CLI flags), + * NOT arbitrary user input. */ function quoteArg(arg: string): string { if (/[^A-Za-z0-9_\-/.]/.test(arg)) { - return `"${arg.replace(/"/g, '\\"')}"`; + const escaped = arg.replace(/%/g, '%%').replace(/"/g, '""'); + return `"${escaped}"`; } return arg; } @@ -133,7 +140,7 @@ export function spawnCli( if (process.platform === 'win32' && needsShell(binaryPath)) { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { shell: true, ...options }); + return spawn(cmd, { ...options, shell: true }); } try { @@ -144,7 +151,7 @@ export function spawnCli( if (process.platform === 'win32' && code === 'EINVAL') { const cmd = [binaryPath, ...args].map(quoteArg).join(' '); // eslint-disable-next-line sonarjs/os-command -- cmd from known binaryPath+args, not user input (Windows EINVAL fallback) - return spawn(cmd, { shell: true, ...options }); + return spawn(cmd, { ...options, shell: true }); } throw err; } diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 1c705c61..cc03192f 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -134,6 +134,34 @@ describe('cli child process helpers', () => { expect(result.stdout).toBe('1.2.3'); }); + it('escapes percent signs and quotes for cmd.exe in shell fallback', async () => { + setPlatform('win32'); + const execMock = child.exec as unknown as vi.Mock; + execMock.mockImplementation((_cmd: string, _opts: unknown, cb: Function) => { + cb(null, 'ok', ''); + return {} as any; + }); + + await execCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--model', 'test%PATH%"arg']); + const shellCmd = execMock.mock.calls[0][0] as string; + // %PATH% must become %%PATH%% to prevent cmd.exe env var expansion + expect(shellCmd).toContain('%%PATH%%'); + // double quote inside arg must become "" (cmd.exe escaping) + expect(shellCmd).toContain('""arg'); + // should NOT contain \" (Unix-style escaping) + expect(shellCmd).not.toContain('\\"'); + }); + + it('shell: true cannot be overridden by caller options', () => { + setPlatform('win32'); + const spawnMock = child.spawn as unknown as vi.Mock; + spawnMock.mockReturnValue({} as any); + + spawnCli('C:\\Users\\Алексей\\bin\\claude.cmd', ['--version'], { shell: false } as any); + // shell: true must win over caller's shell: false + expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true }); + }); + it('falls back to shell when execFile throws EINVAL on windows', async () => { setPlatform('win32'); const execFileMock = child.execFile as unknown as vi.Mock;