inspector/scripts/verify-skills-cli.mjs
cliffhall 09b8f0e834 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>
2026-08-29 02:32:30 -04:00

132 lines
4.6 KiB
JavaScript

#!/usr/bin/env node
// Runs `claude plugin validate` — the AUTHORITATIVE skill schema — as a
// guaranteed step, resolving the CLI rather than hoping it is installed (#2163).
//
// `verify:skills` (inside `validate`) reimplements the parse and treats a
// missing CLI as a skip, deliberately: `npm run validate` is the fast inner
// loop, it must work offline, and a Node contributor should not need Claude Code
// installed to run it. But "skips when absent" plus "usually absent" adds up to
// "never runs", which made the acceptance criterion aspirational (Copilot).
//
// So the authoritative check gets its own step, in `local:gate` and in CI, and
// it does NOT skip: an already-installed CLI is used only when it matches the
// pin EXACTLY, and otherwise the pinned package is fetched with `npx -y`.
// Pinned rather than @latest because a validator that moves on its own can start
// failing a PR that changed nothing, which is how a gate loses its credibility —
// and exact rather than a floor because a newer local CLI is a DIFFERENT schema
// from CI's, which would let the same `local:gate` disagree across machines.
//
// It needs no authentication — verified against a scrubbed environment and a
// clean HOME.
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { probeClaudeVersion } from "./lib/claude-cli.mjs";
import { winShellArgs } from "./lib/win-shell-args.mjs";
import {
compareVersions,
parseClaudeVersion,
PINNED_CLI_VERSION,
} from "./lib/skill-manifest.mjs";
/** `PINNED_CLI_VERSION` as a comparable triple. */
const PINNED_VERSION_PARTS = parseClaudeVersion(PINNED_CLI_VERSION) ?? [];
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const SKILLS_DIR = ".claude/skills";
/**
* The argv that runs the pinned validator.
*
* A local CLI is used only when it matches `PINNED_CLI_VERSION` **exactly**.
* Accepting anything at or above the floor would defeat the pin: a maintainer on
* a newer CLI would validate against a different schema than CI's, so the same
* `local:gate` could disagree across machines — which is the failure a pin
* exists to prevent (Copilot). Everyone else runs the pinned package.
*
* @param {{ version: number[] | null }} local
* @returns {{ command: string, args: string[], via: string }}
*/
export function validatorCommand({ version }) {
if (
version !== null &&
compareVersions(version, PINNED_VERSION_PARTS) === 0
) {
return {
command: "claude",
args: ["plugin", "validate", SKILLS_DIR],
via: `local claude ${version.join(".")} (matches the pin)`,
};
}
const why =
version === null
? "no local CLI"
: `local CLI is ${version.join(".")}, not the pinned ${PINNED_CLI_VERSION}`;
return {
command: "npx",
args: [
"-y",
`@anthropic-ai/claude-code@${PINNED_CLI_VERSION}`,
"plugin",
"validate",
SKILLS_DIR,
],
via: `npx @anthropic-ai/claude-code@${PINNED_CLI_VERSION} (${why})`,
};
}
/**
* Run the authoritative validator.
*
* The probe and the spawn are injected so the orchestration itself is tested:
* on a machine with a working CLI, an actual run only ever walks the happy
* path, so a regression in the probe, the spawn-error branch, or the
* propagation of a rejected validator would leave `test:scripts` green while
* this gate quietly stopped gating (Copilot).
*
* @param {{ probe?: () => number[] | null,
* spawn?: (cmd: string, args: string[], opts: object) => {error?: Error, status?: number|null},
* log?: (msg: string) => void, error?: (msg: string) => void }} [io]
* @returns {number} Process exit code.
*/
export function runValidator(io = {}) {
const {
probe = () => probeClaudeVersion(parseClaudeVersion),
spawn = (cmd, args, opts) => spawnSync(cmd, args, opts),
log = console.log,
error = console.error,
platform = process.platform,
} = io;
const version = probe();
const { command, args, via } = validatorCommand({ version });
log(`verify:skills:cli — validating ${SKILLS_DIR} via ${via}…`);
const res = spawn(command, winShellArgs(args, platform), {
cwd: ROOT,
stdio: "inherit",
shell: platform === "win32",
});
if (res.error) {
error(
`\nverify:skills:cli — could not run \`${command}\`: ${res.error.message}`,
);
return 1;
}
if (res.status !== 0) {
error(
"\nverify:skills:cli — `claude plugin validate` rejected the skills.",
);
return 1;
}
return 0;
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
process.exit(runValidator());
}