Skip to content

Repository files navigation

spawnfate

CI

Predict the fate of a Windows command line before you spawn it.

spawnfate demo: npx dies ENOENT under spawn, but --shell routes through cmd and finds a bash shim

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.

Demo

$ 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: FileNotFound

The 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.

Why this exists

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. CreateProcess built-in search, libuv's search_path (.com/.exe only, CWD-first, PATHEXT ignored), cmd's own PATH/PATHEXT walk, and which-style caller resolution (PATH only, never CWD, GetBinaryTypeW for extensionless hits — what Rust Command / deno_task_shell / Go exec do). 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//s quote-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 %1 rules. "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.

Verified against reality

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 diverged

Predictions that disagree with reality are bugs in the model, and the suite fails. That's the bar this project holds itself to.

Usage

# 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 npx

Exit 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.

Got an error instead of an argv?

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.

For AI agents (MCP)

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.

Requirements

  • Windows (the whole point). Rust 1.7x+ to build from source.
  • selftest additionally needs node on PATH as the ground-truth spawner.

What it is NOT

  • Not an executor. It never runs your command. (selftest runs 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-.bat line reconstruction, and non-MSVCRT runtime parsers are modeled but flagged where the semantics are uncertain — the corpus is where uncertainty goes to be measured.

Design doc

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.

License

MIT OR Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.

About

Predict the fate of a Windows command line before you spawn it — layered simulation of name resolution, argv serialization, cmd.exe re-parse, and target argv parsing. Ships a conformance corpus + selftest verified against real spawns.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages