Predict the fate of a Windows command line before you spawn it.
Every process-spawn API on Windows funnels through layers that don't agree with
each other: the caller serializes argv one way, name resolution probes the
filesystem three different ways, cmd.exe re-parses the string under its own
rules, and the target program splits it back into argv with yet another parser.
spawnfate simulates every layer — offline, without executing anything — and
tells you where the command dies or gets mangled, and why.
$ spawnfate npx -v
== resolution ==
resolved: (none)
== findings ==
[info] Resolve R1.14/R1.15: "C:\...\nodejs\npx" exists but is invisible
to libuv's resolver (no extension, not .com/.exe) — it is NOT a candidate
[FATAL] Resolve R1.14: libuv tried 156 candidate(s), none exist — spawn fails ENOENT
== verdict ==
DIES at Resolve: FileNotFoundThe npx shim is sitting right there on PATH — but spawn('npx') still fails,
because libuv's candidate list only ever tries .com and .exe. Meanwhile
spawn('npx.cmd') throws EINVAL (CVE-2024-27980), spawn('npx', {shell:true})
works — but only because cmd.exe re-resolves the name under completely different
rules — and Rust's Command::new("npx") finds npx.cmd through a fourth
resolver (which-style: PATH only, never CWD, PE-verified). Four resolvers,
four fates. This tool shows all of them.
Every spawn wrapper on Windows re-implements the same band-aids privately
(cross-spawn, execa, every agent CLI's escapeArgForCmd). The post-CVE-2024
world made it worse: Node now throws EINVAL for .bat/.cmd, Rust's std
returns InvalidInput for unescapable args, and the error messages never say
which layer broke your string. Nobody built the diagnostic — so here it is.
What it does that nothing else does:
- Four resolvers, modeled separately.
CreateProcessbuilt-in search, libuv'ssearch_path(.com/.exeonly, CWD-first, PATHEXT ignored), cmd's own PATH/PATHEXT walk, andwhich-style caller resolution (PATH only, never CWD,GetBinaryTypeWfor extensionless hits — what RustCommand/ deno_task_shell / Goexecdo). The same name resolves differently depending on which one is asking — that difference is the bug half the time. - cmd.exe re-parse. The
/c//squote-strip decision, metachar hazard scan (& | < > ^),%VAR%expansion (fires even inside quotes), delayed!expansion!, newline injection. - Target parser divergence. The same string splits differently under
post-2008 MSVCRT,
CommandLineToArgvW, Go's own parser, and batch%1rules."a""b c"is one arg for MSVC programs and two args for a Go binary. - A conformance corpus (
corpus/*.yaml): every rule ships with a machine-checkable case — declarative file trees, environment, call, expected verdict. Any reimplementation in any language can run the same cases.
spawnfate selftest materializes each corpus case's declared filesystem into a
temp dir and runs a real node spawn (or the bundled Rust spawnprobe for
winspawn cases) with the remapped PATH/cwd, then compares what actually
happened with what we predicted.
$ spawnfate selftest
ok node-npx-enoent
ok node-npx-cmd-einval
ok node-explicit-sh-193 (Node reports EFTYPE — libuv maps 193)
ok cmd-resolver-sees-pathext
...
selftest: 32 verified against real spawn, 0 divergedPredictions that disagree with reality are bugs in the model, and the suite fails. That's the bar this project holds itself to.
# what does Node's spawn('npx', ['-y', 'pkg', 'C:\My Dir\']) do?
spawnfate npx -y pkg "C:\My Dir\"
# same call but through shell:true (cmd.exe re-parse)
spawnfate --shell npx -v
# Rust/Go/deno-style spawn instead: Command::new("npx") — the which-style
# resolver finds npx.cmd where Node dies ENOENT (CWD is never searched)
spawnfate --producer winspawn npx -v
# the target is a Go binary, not an MSVCRT program?
spawnfate --target go prog "a""b c"
# a raw CreateProcess(lpApplicationName=NULL) command line
spawnfate raw "setup.cmd /q"
# you have the error, not the argv
spawnfate explain "Error: spawn npx ENOENT"
# machine-readable
spawnfate --json npxExit codes: 0 predicted to run, 1 dies before user code, 3 argv cannot be
serialized without mangling. For explain: 0 a surface matched, 4 nothing
known did.
spawnfate explain "<error text>" reads what you already have — a Node stack, a
Python traceback, a Rust io error, cmd's own complaint — and names the layer
that produced it, with the prescription. Every hit cites the spec rule and the
corpus case it rests on, and prints the command that turns the reading into a
verdict. It is a differential, not a verdict: spawnfate <file> <args> stays
authoritative. See docs/explain.md.
spawnfate mcp runs an MCP server over stdio exposing one tool,
analyze_spawn — the same prediction the CLI gives, callable by any
MCP-capable agent. Configure once (claude mcp add spawnfate -- spawnfate mcp),
and an agent can ask “will this spawn die, and why?” before it issues the
call — turning ENOENT/EINVAL mysteries into a looked-up answer with a
prescription attached. Synthetic environments (env.files, env.path)
let it answer for machines that aren't this one. See docs/mcp.md.
- Windows (the whole point). Rust 1.7x+ to build from source.
selftestadditionally needsnodeon PATH as the ground-truth spawner.
- Not an executor. It never runs your command. (
selftestruns only the corpus's own declared cases, in a temp dir it created.) - Not a fixer. It diagnoses and prescribes; applying the fix is your call.
- Not complete. PowerShell's two-hop parsing,
CreateProcess's implicit-.batline reconstruction, and non-MSVCRT runtime parsers are modeled but flagged where the semantics are uncertain — the corpus is where uncertainty goes to be measured.
The rule spec — every parse layer's semantics, tagged [DOC]/[SRC]/[EMP]/[UNC]
by evidence level — lives in docs/spec-v0.md. The corpus
references rules by id (R1.14, R2.3, …) so claims stay checkable.
MIT OR Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.