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

@ -29,11 +29,16 @@
// npm run skills:eval -- testing # one skill's cases
// RUNS=5 THRESHOLD=0.8 npm run skills:eval
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseSkill, validateEvalCases } from "./lib/skill-manifest.mjs";
import { claudeSpawnArgs, probeClaudeVersion } from "./lib/claude-cli.mjs";
import {
parseClaudeVersion,
parseSkill,
validateEvalCases,
} from "./lib/skill-manifest.mjs";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SKILLS_DIR = path.join(ROOT, ".claude", "skills");
@ -217,13 +222,20 @@ export function sampleHit(expect, invoked, ours = null) {
* @param {{ spawnFn?: typeof spawn, cwd?: string }} [opts]
* @returns {Promise<Set<string>>}
*/
export function runPrompt(prompt, { spawnFn = spawn, cwd = ROOT } = {}) {
export function runPrompt(
prompt,
{ spawnFn = spawn, cwd = ROOT, platform = process.platform } = {},
) {
return new Promise((resolve, reject) => {
const p = spawnFn(
"claude",
// The prompt goes in on STDIN, not in argv. `claude -p` with piped stdin
// reads the prompt from it (verified), and that removes two problems at
// once: on Windows the CLI is a `.cmd` shim, so it can only be started
// through a shell, and `cmd.exe` would re-parse any prompt containing a
// metacharacter as syntax (Copilot). It also keeps the prompt out of the
// process table.
const { command, args, options } = claudeSpawnArgs(
[
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
@ -234,8 +246,10 @@ export function runPrompt(prompt, { spawnFn = spawn, cwd = ROOT } = {}) {
"--disallowedTools",
"Bash,Write,Edit,NotebookEdit",
],
{ cwd, stdio: ["ignore", "pipe", "inherit"] },
{ cwd, stdio: ["pipe", "pipe", "inherit"] },
platform,
);
const p = spawnFn(command, args, options);
let buf = "";
const invoked = new Set();
@ -255,12 +269,10 @@ export function runPrompt(prompt, { spawnFn = spawn, cwd = ROOT } = {}) {
}
resolve(invoked);
});
p.stdin?.end(prompt);
});
}
const hit = (invoked, skill) =>
[...invoked].some((payload) => payload.includes(skill));
async function pool(items, n, fn) {
const out = new Array(items.length);
let i = 0;
@ -276,9 +288,9 @@ async function pool(items, n, fn) {
}
async function main() {
if (spawnSync("claude", ["--version"], { stdio: "ignore" }).error) {
if (probeClaudeVersion(parseClaudeVersion) === null) {
console.error(
"skills:eval — the `claude` CLI is not on PATH. This eval needs it.",
"skills:eval — no usable `claude` CLI on PATH. This eval needs one.",
);
process.exit(1);
}