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

View file

@ -105,6 +105,9 @@ function fakeSpawn({ chunks = [], code = 0, error = null }) {
return () => {
const child = new EventEmitter();
child.stdout = new EventEmitter();
// The prompt is written to stdin rather than passed in argv, so the
// stand-in needs one or `runPrompt` throws before the child can close.
child.stdin = { end: () => {} };
queueMicrotask(() => {
if (error) {
child.emit("error", error);
@ -215,3 +218,47 @@ test("a negative case ignores skills that are not this repo's", () => {
// With no repo set, any invocation still fails a negative case.
assert.equal(sampleHit(null, foreign), false);
});
test("runPrompt sends the prompt on stdin, never in argv", () => {
// On Windows the CLI is a `.cmd` shim and can only start through a shell,
// where `cmd.exe` re-parses any metacharacter in an argument as syntax. Off
// Windows it also keeps the prompt out of the process table.
const prompt = "does it? (yes & no)";
let seen;
let written = null;
runPrompt(prompt, {
spawnFn: (command, args, options) => {
seen = { command, args, options };
const c = new EventEmitter();
c.stdout = new EventEmitter();
c.stdin = {
end: (text) => {
written = text;
},
};
queueMicrotask(() => c.emit("close", 0));
return c;
},
platform: "linux",
}).catch(() => {});
assert.equal(seen.command, "claude");
assert.ok(!seen.args.includes(prompt), "prompt must not appear in argv");
assert.deepEqual(seen.options.stdio, ["pipe", "pipe", "inherit"]);
assert.equal(written, prompt);
});
test("runPrompt asks for a shell on Windows", () => {
let seen;
runPrompt("p", {
spawnFn: (_c, _a, options) => {
seen = options;
const c = new EventEmitter();
c.stdout = new EventEmitter();
c.stdin = { end: () => {} };
queueMicrotask(() => c.emit("close", 0));
return c;
},
platform: "win32",
}).catch(() => {});
assert.equal(seen.shell, true);
});