chore: address Copilot review round 7 (Windows .cmd shims)

Node has refused shell-free `.cmd`/`.bat` spawns since CVE-2024-27980, and an
npm-installed `claude` is a `.cmd` shim on Windows — so every shell-free spawn
of it reported ENOENT there, each in a quiet way: `verify:skills` skipped its
authoritative hand-off, `verify:skills:cli` downloaded the pinned package it
already had (and failed offline), and `skills:eval` said "not on PATH".

- New `scripts/lib/claude-cli.mjs` is the one place that decides how `claude` is
  spawned: `shell` on win32, arguments through the existing `winShellArgs`
  quoting so `cmd.exe` cannot re-parse them as syntax (#1939). Unit-tested with
  the platform injected, since a win32-only bug no runner here executes is
  exactly the kind that ships.
- `verify-skills`, `verify-skills-cli` and `skill-eval` all route their probes
  and spawns through it.
- skill-eval sends the prompt on **stdin** rather than in argv. `claude -p` reads
  a piped prompt (verified end to end), which sidesteps `cmd.exe` metacharacter
  re-parsing entirely rather than relying on quoting a model-visible string, and
  keeps the prompt out of the process table.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 02:32:30 -04:00
commit 09b8f0e834
2 changed files with 233 additions and 45 deletions

View file

@ -0,0 +1,62 @@
// Spawning the `claude` CLI from the root scripts (#2163, Copilot).
//
// On Windows an npm-installed `claude` is a `.cmd` shim, and Node has refused
// shell-free `.cmd`/`.bat` spawns since the CVE-2024-27980 hardening — so a
// plain `spawnSync("claude", …)` reports ENOENT there even though the CLI is
// installed. Every consumer of that would then be wrong in a *quiet* way:
// `verify:skills` would skip its authoritative hand-off, `verify:skills:cli`
// would download the pinned package it already has (and fail offline), and
// `skills:eval` would report "not on PATH".
//
// The shell that fixes it introduces the #1939 problem — `cmd.exe` re-parses a
// space-joined string, so any argument holding a metacharacter becomes syntax —
// so arguments go through the same `winShellArgs` quoting the npm/npx call
// sites use. This is deliberately the ONLY place that decides either question.
import { spawnSync } from "node:child_process";
import { winShellArgs } from "./win-shell-args.mjs";
/**
* `spawnSync` arguments for a `claude` invocation, correct on every platform.
*
* @param {string[]} args
* @param {object} [options] Passed through to `spawnSync`.
* @param {string} [platform] Defaults to the current platform; injectable for tests.
* @returns {{ command: string, args: string[], options: object }}
*/
export function claudeSpawnArgs(
args,
options = {},
platform = process.platform,
) {
return {
command: "claude",
args: winShellArgs(args, platform),
options: { ...options, shell: platform === "win32" },
};
}
/**
* Read the installed CLI's version, or null when there is no usable one.
*
* "Usable" deliberately includes parsing the output: a `claude` that answers
* `--version` with something unrecognizable cannot be trusted to be the pinned
* one either, and the callers all treat null as "use the pinned package".
*
* @param {(text: string) => number[] | null} parseVersion
* @param {{ spawn?: typeof spawnSync, platform?: string }} [io]
* @returns {number[] | null}
*/
export function probeClaudeVersion(
parseVersion,
{ spawn = spawnSync, platform } = {},
) {
const { command, args, options } = claudeSpawnArgs(
["--version"],
{ encoding: "utf8" },
platform,
);
const res = spawn(command, args, options);
if (res.error || res.status !== 0) return null;
return parseVersion(res.stdout ?? "");
}

View file

@ -0,0 +1,76 @@
// Tests for the one place that decides how `claude` is spawned (#2163, Copilot).
//
// The behavior under test only differs ON Windows, and these run on whatever
// the contributor has — so the platform is injected rather than sniffed. A
// win32-only bug that no CI runner here executes is precisely the kind that
// ships.
import { test } from "node:test";
import assert from "node:assert/strict";
import { claudeSpawnArgs, probeClaudeVersion } from "./claude-cli.mjs";
test("spawns without a shell off Windows", () => {
const { command, args, options } = claudeSpawnArgs(
["plugin", "validate", ".claude/skills"],
{ cwd: "/repo" },
"linux",
);
assert.equal(command, "claude");
assert.deepEqual(args, ["plugin", "validate", ".claude/skills"]);
assert.equal(options.shell, false);
assert.equal(options.cwd, "/repo");
});
test("uses a shell on Windows, where the CLI is a .cmd shim", () => {
// Node has refused shell-free `.cmd` spawns since CVE-2024-27980, so without
// this the CLI reads as absent even when it is installed.
const { options } = claudeSpawnArgs(["--version"], {}, "win32");
assert.equal(options.shell, true);
});
test("quotes arguments that cmd.exe would otherwise re-parse", () => {
const { args } = claudeSpawnArgs(["-p", "does it? (yes & no)"], {}, "win32");
assert.deepEqual(args, ["-p", '"does it? (yes & no)"']);
// The same arguments are untouched off Windows.
assert.deepEqual(
claudeSpawnArgs(["-p", "does it? (yes & no)"], {}, "linux").args,
["-p", "does it? (yes & no)"],
);
});
test("probeClaudeVersion returns the parsed version on success", () => {
const version = probeClaudeVersion(
(t) => (t.trim() === "2.1.250" ? [2, 1, 250] : null),
{
spawn: () => ({ status: 0, stdout: "2.1.250" }),
platform: "linux",
},
);
assert.deepEqual(version, [2, 1, 250]);
});
test("probeClaudeVersion returns null for every unusable outcome", () => {
for (const res of [
{ error: new Error("ENOENT") },
{ status: 1, stdout: "" },
{ status: 0, stdout: "something else" },
]) {
assert.equal(
probeClaudeVersion(() => null, { spawn: () => res, platform: "linux" }),
null,
);
}
});
test("probeClaudeVersion asks for the shell on Windows", () => {
let seen;
probeClaudeVersion(() => [2, 1, 250], {
spawn: (_c, _a, options) => {
seen = options;
return { status: 0, stdout: "2.1.250" };
},
platform: "win32",
});
assert.equal(seen.shell, true);
assert.equal(seen.encoding, "utf8");
});