diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index be7cb71..d0f9b4f 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -30,6 +30,7 @@ import { writeClientConfig, } from '../utils/mcp-install.js'; import { colors } from '../utils/styling.js'; +import { toolSpawn } from '../utils/tool-spawn.js'; import { type Field, ui } from '../utils/ui.js'; const RELOAD_HINT: Record = { @@ -41,7 +42,12 @@ const RELOAD_HINT: Record = { /** Run another tool's CLI without a shell. Undefined when it isn't installed. */ function runTool(command: string, args: string[]): { ok: boolean; output: string } | undefined { - const result = spawnSync(command, args, { encoding: 'utf8' }); + const spawn = toolSpawn(command, args); + if (!spawn) return undefined; + const result = spawnSync(spawn.command, spawn.args, { + encoding: 'utf8', + windowsVerbatimArguments: spawn.windowsVerbatimArguments, + }); if ((result.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') return undefined; return { ok: result.status === 0, output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim() }; } diff --git a/src/utils/tool-spawn.ts b/src/utils/tool-spawn.ts new file mode 100644 index 0000000..2beed46 --- /dev/null +++ b/src/utils/tool-spawn.ts @@ -0,0 +1,86 @@ +/** + * How to start another tool's CLI (`code`, `claude`) without a shell. + * + * On Windows these are often batch files (`code.cmd`, and npm's `claude.cmd` + * shim). Node's spawn searches PATH only for .com and .exe, and refuses to + * start a .cmd or .bat at all without a shell (CVE-2024-27980). So we find the + * file ourselves and, for a batch file, run it through cmd.exe with every + * argument escaped for cmd. This doesn't lean on how Node or Bun resolve + * commands, so the npm install and the standalone binary behave the same. + */ +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +import { CliError } from './cli.js'; + +export interface ToolSpawn { + command: string; + args: string[]; + /** Pass the arguments to cmd.exe as written, already quoted. */ + windowsVerbatimArguments?: boolean; +} + +/** Characters cmd.exe treats specially, each escaped with a caret. */ +const CMD_META = /([ !"%&()*,;<>?[\]^`|])/g; + +/** + * What to spawn for `command args`, or undefined when Windows has no such + * command on PATH. Elsewhere the command is spawned as given, and a missing + * one shows up as ENOENT. + */ +export function toolSpawn( + command: string, + args: string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, + exists: (file: string) => boolean = existsSync, +): ToolSpawn | undefined { + if (platform !== 'win32') return { command, args }; + + const file = findOnWindowsPath(command, env, exists); + if (!file) return undefined; + if (!/\.(cmd|bat)$/i.test(file)) return { command: file, args }; + // A line break ends cmd's line, and no escaping can stop that. + if (args.some((arg) => /[\0\n\r]/.test(arg))) { + throw new CliError(`Can't pass a line break to ${path.win32.basename(file)} through cmd.exe.`); + } + + // /s strips the outer quotes and runs the rest as one line. + const line = [file.replace(CMD_META, '^$1'), ...args.map((arg) => cmdArgument(arg))].join(' '); + return { + command: env.ComSpec ?? env.COMSPEC ?? 'cmd.exe', + args: ['/d', '/s', '/c', `"${line}"`], + windowsVerbatimArguments: true, + }; +} + +/** The file Windows would run for `command`: each PATH folder, each PATHEXT extension. */ +export function findOnWindowsPath( + command: string, + env: NodeJS.ProcessEnv, + exists: (file: string) => boolean, +): string | undefined { + const exts = (env.PATHEXT ?? env.Pathext ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean); + const dirs = (env.PATH ?? env.Path ?? '').split(';').filter(Boolean); + const hasExt = exts.some((ext) => command.toLowerCase().endsWith(ext.toLowerCase())); + const names = hasExt ? [command] : exts.map((ext) => command + ext.toLowerCase()); + for (const dir of dirs) { + for (const name of names) { + const candidate = path.win32.join(dir.replaceAll('"', ''), name); + if (exists(candidate)) return candidate; + } + } + + return undefined; +} + +/** + * One argument for a batch file started by cmd.exe. First quote it the way + * the program's own parser expects (CommandLineToArgvW), then caret-escape + * every cmd character twice: once for the `cmd /c` line, and once more + * because the batch file passes it on with %*, which cmd parses again. + */ +export function cmdArgument(arg: string): string { + const quoted = `"${arg.replaceAll(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, '$1$1')}"`; + return quoted.replace(CMD_META, '^$1').replace(CMD_META, '^$1'); +} diff --git a/test/unit/tool-spawn.test.ts b/test/unit/tool-spawn.test.ts new file mode 100644 index 0000000..c31fb59 --- /dev/null +++ b/test/unit/tool-spawn.test.ts @@ -0,0 +1,128 @@ +import { expect } from 'chai'; + +import { vscodeAddArgs } from '../../src/utils/mcp-install.js'; +import { cmdArgument, findOnWindowsPath, toolSpawn } from '../../src/utils/tool-spawn.js'; + +/** One cmd.exe parse pass: carets escape outside quotes, quotes toggle, & | < > outside quotes are operators. */ +function cmdPass(line: string): string { + let out = ''; + let quoted = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (!quoted && ch === '^') { + out += line[++i] ?? ''; + continue; + } + + if (!quoted && '&|<>'.includes(ch)) throw new Error(`cmd would treat ${ch} as an operator in: ${line}`); + if (ch === '"') quoted = !quoted; + out += ch; + } + + return out; +} + +/** CommandLineToArgvW's rules for the arguments after the program name. */ +function argv(line: string): string[] { + const args: string[] = []; + let i = 0; + while (i < line.length) { + while (line[i] === ' ' || line[i] === '\t') i++; + if (i >= line.length) break; + let arg = ''; + let quoted = false; + while (i < line.length && (quoted || (line[i] !== ' ' && line[i] !== '\t'))) { + let slashes = 0; + while (line[i] === '\\') { + slashes++; + i++; + } + + if (line[i] === '"') { + arg += '\\'.repeat(Math.floor(slashes / 2)); + if (slashes % 2) arg += '"'; + else quoted = !quoted; + i++; + } else { + arg += '\\'.repeat(slashes); + if (i < line.length) arg += line[i++]; + } + } + + args.push(arg); + } + + return args; +} + +/** What a program started by `code.cmd ... %*` sees for the given arguments. */ +function throughBatch(args: string[]): string[] { + // `cmd /s /c ""` parses the line once; the batch file's %* line is parsed again. + return argv(cmdPass(cmdPass(args.map((arg) => cmdArgument(arg)).join(' ')))); +} + +describe('toolSpawn', () => { + const files = new Set([ + String.raw`C:\Program Files\Microsoft VS Code\bin\code.cmd`, + String.raw`C:\Users\a\AppData\Roaming\npm\claude.cmd`, + String.raw`C:\tools\claude.exe`, + ]); + const exists = (file: string) => files.has(file); + const env = { + ComSpec: String.raw`C:\Windows\system32\cmd.exe`, + PATH: String.raw`C:\Windows;"C:\Program Files\Microsoft VS Code\bin";C:\Users\a\AppData\Roaming\npm`, + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }; + + it('spawns the command as given off Windows', () => { + expect(toolSpawn('code', ['--add-mcp', '{}'], 'darwin', env, exists)).to.deep.equal({ + args: ['--add-mcp', '{}'], + command: 'code', + }); + }); + + it('finds a .cmd on PATH, and an .exe before a .cmd in a later folder', () => { + expect(findOnWindowsPath('code', env, exists)).to.equal(String.raw`C:\Program Files\Microsoft VS Code\bin\code.cmd`); + expect(findOnWindowsPath('claude', { ...env, PATH: `C:\\tools;${env.PATH}` }, exists)).to.equal( + String.raw`C:\tools\claude.exe`, + ); + expect(findOnWindowsPath('cursor', env, exists)).to.equal(undefined); + }); + + it('is undefined on Windows when the command is not on PATH', () => { + expect(toolSpawn('cursor', [], 'win32', env, exists)).to.equal(undefined); + }); + + it('spawns an .exe directly', () => { + expect(toolSpawn('claude', ['mcp'], 'win32', { ...env, PATH: 'C:\\tools' }, exists)).to.deep.equal({ + args: ['mcp'], + command: String.raw`C:\tools\claude.exe`, + }); + }); + + it('runs a .cmd through cmd.exe with verbatim arguments', () => { + const spawn = toolSpawn('code', ['--add-mcp', '{}'], 'win32', env, exists)!; + expect(spawn.command).to.equal(env.ComSpec); + expect(spawn.windowsVerbatimArguments).to.equal(true); + expect(spawn.args.slice(0, 3)).to.deep.equal(['/d', '/s', '/c']); + expect(cmdPass(spawn.args[3].slice(1, -1))).to.match(/^C:\\Program Files\\Microsoft VS Code\\bin\\code\.cmd /); + }); + + it('refuses a line break in an argument to a .cmd', () => { + expect(() => toolSpawn('code', ['a\nb'], 'win32', env, exists)).to.throw(/line break/); + expect(toolSpawn('claude', ['a\nb'], 'win32', { ...env, PATH: 'C:\\tools' }, exists)?.args).to.deep.equal(['a\nb']); + }); + + it('gets the --add-mcp JSON to VS Code unchanged', () => { + const add = vscodeAddArgs({ + args: [String.raw`C:\Users\a b\AppData\Roaming\npm\node_modules\@devicecloud.dev\dcd\dist\index.js`, 'mcp', '--read-only'], + command: String.raw`C:\Program Files (x86)\nodejs\node.exe`, + }); + expect(throughBatch(add)).to.deep.equal(add); + }); + + it('keeps cmd characters, quotes and trailing backslashes intact', () => { + const args = ['a&b|c', '', '100%PATH%', 'say "hi"', String.raw`C:\dir\ `, String.raw`end\\`, '^caret!', '"&"', 'x" | "y', '']; + expect(throughBatch(args)).to.deep.equal(args); + }); +});