From 66909ddd27d1d8ea8fe543812cc8a59879360fd7 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 2 Nov 2026 09:42:02 +0000 Subject: [PATCH 1/2] fix(mcp): find code and claude .cmd shims on Windows On Windows, VS Code's command line is code.cmd and npm installs Claude Code as claude.cmd. Node's spawn without a shell skips .cmd files on PATH and refuses to start them, so `dcd mcp install vscode` and `dcd mcp install claude-code` said the command wasn't on PATH. Find the command on PATH with PATHEXT ourselves. Run an .exe directly, and a .cmd or .bat through cmd.exe with each argument escaped for both of cmd's parse passes, so the --add-mcp JSON arrives unchanged. Fixes #281 Co-Authored-By: Claude Opus 5.5 --- src/commands/mcp.ts | 8 ++- src/utils/tool-spawn.ts | 80 +++++++++++++++++++++++ test/unit/tool-spawn.test.ts | 123 +++++++++++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 src/utils/tool-spawn.ts create mode 100644 test/unit/tool-spawn.test.ts 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..bad84cf --- /dev/null +++ b/src/utils/tool-spawn.ts @@ -0,0 +1,80 @@ +/** + * 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'; + +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 }; + + // /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..3b142ae --- /dev/null +++ b/test/unit/tool-spawn.test.ts @@ -0,0 +1,123 @@ +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('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); + }); +}); From e1219ceac01da8c4c4ed768bc0e4c326f06a805b Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 2 Nov 2026 09:50:33 +0000 Subject: [PATCH 2/2] fix(mcp): refuse a line break in an argument passed through cmd.exe A line break ends cmd's command line, so caret escaping can't protect an argument that holds one. No argument we pass can contain one today, but refuse it rather than rely on that. Co-Authored-By: Claude Opus 5.5 --- src/utils/tool-spawn.ts | 6 ++++++ test/unit/tool-spawn.test.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/utils/tool-spawn.ts b/src/utils/tool-spawn.ts index bad84cf..2beed46 100644 --- a/src/utils/tool-spawn.ts +++ b/src/utils/tool-spawn.ts @@ -11,6 +11,8 @@ import { existsSync } from 'node:fs'; import * as path from 'node:path'; +import { CliError } from './cli.js'; + export interface ToolSpawn { command: string; args: string[]; @@ -38,6 +40,10 @@ export function toolSpawn( 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(' '); diff --git a/test/unit/tool-spawn.test.ts b/test/unit/tool-spawn.test.ts index 3b142ae..c31fb59 100644 --- a/test/unit/tool-spawn.test.ts +++ b/test/unit/tool-spawn.test.ts @@ -108,6 +108,11 @@ describe('toolSpawn', () => { 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'],