diff --git a/.changeset/sturdy-voles-run.md b/.changeset/sturdy-voles-run.md
new file mode 100644
index 000000000..79be1ccc8
--- /dev/null
+++ b/.changeset/sturdy-voles-run.md
@@ -0,0 +1,5 @@
+---
+type: Added
+pr: 3621
+---
+**Windows binary resolution now has one owner** — GSD resolves a command name to the file Windows can actually start, in the single platform seam, instead of four divergent copies. Reviewer lanes, `execTool`, and the capability spawn path all share it, so a `.cmd`/`.bat` shim resolves and runs where it previously failed with `spawn ENOENT`. macOS and Linux behavior is unchanged. (#3411)
diff --git a/CONTEXT.md b/CONTEXT.md
index b847c8491..4b7571310 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -825,7 +825,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Shell Command Projection Module (expanded glossary entry, 2026-05-13)
-Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty, isSpawnTimeout), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. `isSpawnTimeout` is the single shared "did this subprocess time out" predicate (error.code==='ETIMEDOUT' only — cross-platform-safe; does not require signal==='SIGTERM'), consumed by worktree-safety.cts, worktree-base-ref.cts, commands.cts, and this module's own dispatchGsdCommand (#3050 — "Generative Fix Divergence"). `projectPathExportLine(targetDir)` is the single source of the `export PATH="
:$PATH"` line for all three PATH-persistence lanes (repair, persist, win32 Git Bash) — it double-quote-escapes for the line's final rc-file context before any lane single-quotes it for its own `echo` transport, closing the #3118 command-substitution injection where a lane re-escaped the line itself and let a `$(…)` in the target dir execute on every new shell; the win32 cmd.exe lane fails closed (empty actions) whenever the target dir contains `"`, since that character is reserved on Windows and would otherwise close cmd's quoted region, and now tags that empty result with a typed `PATH_ACTION_REASON` (`win32_reserved_quote`) so it stays distinguishable from the unrelated "no target directory given" empty result (`no_target_dir`, #3118); the fish lane emits `fish_add_path -- ''` (`--` end-of-options separator, verified empirically against fish 4.8.1), since a leading-dash target dir is otherwise misparsed by fish's argparse-based option scanning regardless of quoting; `escapeTomlDoubleQuotedString` now escapes TOML's required control characters (U+0000-U+0008, U+000A-U+001F, U+007F), not just backslash and quote, closing a raw-newline/CR/NUL config.toml parse failure (#3118). Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
+Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty, isSpawnTimeout), Windows binary resolution (resolveExecutableBinary, projectSpawnInvocation), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. WINDOWS BINARY RESOLUTION — which file a declared command name actually names, and what must be handed to `spawnSync` to start it — is owned here as of #3411 (epic #3411 Phase 1), which found the seam declaration untrue for this axis: four divergent implementations had grown outside it (`execNpm`'s `shell:true`, `execTool`'s absence of any handling, a private PATH+PATHEXT scan in `gsd-core/bin/gsd-tools.cjs`, and a fourth candidate-extension array in `fallow-runner.cts`), and #3275's fix to one provably never reached the others. `resolveExecutableBinary(name, {platform, env})` returns the RESOLVED PATH (the prior `hasBinary` computed it and threw it away): on win32 it tries PATHEXT entries ONLY and never the bare name, because npm global installs drop an extensionless POSIX `sh` shim beside `foo.CMD` and a bare-name-first scan resolves to it, leaving the ENOENT unchanged (#3275); a name already carrying a PATHEXT-listed extension is tried as-is BEFORE the append loop, and a suffix outside PATHEXT is not an extension. On POSIX it answers existence only, and `execTool` does not consult it there at all — the bare name goes to `spawnSync` unchanged and Node's own PATH search does the work, keeping macOS/Linux byte-identical for a symbol whose blast radius is CRITICAL (167 affected symbols, 53 files). `projectSpawnInvocation(command, args, {platform, env})` is the inseparable second half: `CreateProcess` cannot execute a `.cmd`/`.bat` at all, so resolution alone does not fix Windows, and exporting only the resolver would leave every caller to re-derive the mediation — which is how the four copies accumulated. It mediates through `ComSpec` with an EXPLICIT argv array (`/d /s /c`), never `shell: true`: that is CVE-2024-27980's argument-injection vector and Node 26's DEP0190. Mediation keys on the target — the resolved path, or the declared name when resolution found nothing — so a `.cmd` resident in the current directory (which PATH-only resolution misses but `cmd.exe /c` still finds) keeps working, while a BARE unresolved name is passed through untouched so the spawn fails with ENOENT rather than cmd.exe's exit 9009, preserving `_spawnResult`'s `': not found'` contract. `gsd-core/bin/gsd-tools.cjs`'s `resolveSpawnBinary` is the `bin/` entry point onto this and holds no copy of the logic. CALLERS CHOOSE HOW MUCH OF THE PROJECTION TO ADOPT, and the asymmetry is deliberate: `windowsVerbatimArguments` marks the cases where mediation was REQUIRED (the caller must take `command` and `args` together, or the batch file cannot start at all), whereas a merely-RESOLVED path is an offer a caller may decline. `execTool` declines it and keeps spawning the DECLARED name — libuv's `CreateProcess` path already performs PATH+PATHEXT search, so resolving a `.exe` there buys nothing while changing what 167 dependents observe being spawned; `tests/graphify.test.cjs` pins that contract by spying on `spawnSync`'s first argument, and the Windows CI lane caught the violation when `execTool` briefly adopted the resolved path (`python3` arriving as `C:\…\python3.EXE`). `deps.spawn` accepts it, because that lane's `hasBinary` probe answers from the same resolver and probe and spawn must agree on the exact file (#3445). Env lookups go through a case-insensitive read: Windows names the variable `Path`, `process.env` is a case-insensitive proxy that hides this, and `execTool`'s `{...process.env, ...opts.env}` spread produces a PLAIN object that keeps the OS casing and loses the proxy — an exact-case `env['PATH']` there returns undefined and the scan silently sees nothing. Known gap at Phase 1: `src/fallow-runner.cts` still carries its own resolver (Phase 2), and no lint ratchet yet rejects a bare-binary spawn outside this seam (Phase 3). `isSpawnTimeout` is the single shared "did this subprocess time out" predicate (error.code==='ETIMEDOUT' only — cross-platform-safe; does not require signal==='SIGTERM'), consumed by worktree-safety.cts, worktree-base-ref.cts, commands.cts, and this module's own dispatchGsdCommand (#3050 — "Generative Fix Divergence"). `projectPathExportLine(targetDir)` is the single source of the `export PATH=":$PATH"` line for all three PATH-persistence lanes (repair, persist, win32 Git Bash) — it double-quote-escapes for the line's final rc-file context before any lane single-quotes it for its own `echo` transport, closing the #3118 command-substitution injection where a lane re-escaped the line itself and let a `$(…)` in the target dir execute on every new shell; the win32 cmd.exe lane fails closed (empty actions) whenever the target dir contains `"`, since that character is reserved on Windows and would otherwise close cmd's quoted region, and now tags that empty result with a typed `PATH_ACTION_REASON` (`win32_reserved_quote`) so it stays distinguishable from the unrelated "no target directory given" empty result (`no_target_dir`, #3118); the fish lane emits `fish_add_path -- ''` (`--` end-of-options separator, verified empirically against fish 4.8.1), since a leading-dash target dir is otherwise misparsed by fish's argparse-based option scanning regardless of quoting; `escapeTomlDoubleQuotedString` now escapes TOML's required control characters (U+0000-U+0008, U+000A-U+001F, U+007F), not just backslash and quote, closing a raw-newline/CR/NUL config.toml parse failure (#3118). Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
Invariants:
- Result shape: all exec* functions return `{ exitCode, stdout, stderr }`; never throw on non-zero exit code.
diff --git a/docs/explanation/security-model.md b/docs/explanation/security-model.md
index f9c6dbb54..ab5523b74 100644
--- a/docs/explanation/security-model.md
+++ b/docs/explanation/security-model.md
@@ -255,6 +255,51 @@ hide malicious content in diffs.
---
+## Layer 4 — Subprocess execution
+
+GSD starts external programs constantly: git, npm, reviewer CLIs declared by
+capabilities, and whatever a gate predicate names. Every one of those is a
+place where an argument could become a command. One module owns the whole
+question — `src/shell-command-projection.cts`, the single platform seam.
+
+**No `shell: true` for binary invocation.** Passing `shell: true` on Windows is
+the mechanism behind CVE-2024-27980: the shell re-parses the argument list, so
+a value containing `&` or `|` stops being data and becomes a second command.
+Node 26 additionally deprecates `shell: true` alongside an argument array
+(DEP0190), because arguments are concatenated rather than escaped. GSD resolves
+binaries explicitly instead.
+
+**Explicit resolution, not shell lookup.** `resolveExecutableBinary` scans
+`PATH` and, on Windows, the `PATHEXT` extensions, and returns the resolved
+path. It never tries the bare name on Windows: npm global installs drop an
+extensionless POSIX `sh` shim beside `foo.CMD`, and resolving to that shim is
+how the reviewer lanes failed with `spawn ENOENT` (#3275). On macOS and Linux
+the bare name goes to `spawnSync` unchanged, so the operating system's own
+lookup keeps doing the work.
+
+**Mediating `.cmd` and `.bat` safely.** Windows `CreateProcess` cannot execute a
+batch file at all, so one must be run through `cmd.exe`. That is where the
+injection risk actually lives, and it is not solved by resolution alone.
+`projectSpawnInvocation` builds the command line itself and passes it through
+verbatim: one outer quote pair that `cmd /c` strips, every token inside
+force-quoted, embedded quotes doubled. Force-quoting is the point — an unquoted
+`a&calc` is split by `cmd` into two commands, while a quoted `"a&calc"` is one
+literal argument. This is the shape Rust's standard library adopted for the
+sibling CVE-2024-24576.
+
+Relying on the default argument escaping would not be enough. Node's own
+CVE-2024-27980 protection fires only when the program being started is itself
+the `.bat` or `.cmd`; once the program is `cmd.exe`, that check no longer
+applies, and the underlying quoting only quotes arguments containing spaces,
+tabs, or quotes — never one containing a bare `&`.
+
+An argument containing a carriage return or newline is refused rather than
+mediated. A newline cannot be represented in a Windows command line, so
+mediating it would silently truncate the argument; failing visibly is the
+safer outcome.
+
+---
+
## Trade-offs and limits
The security model described here meaningfully reduces the attack surface for
@@ -292,6 +337,16 @@ research agents — but novel jailbreaks and low-signal injections may still pas
undetected. Defence in depth means each layer makes the attack harder, not that
any single layer makes it impossible.
+**What subprocess execution does not eliminate:** `cmd.exe` expands `%VAR%`
+inside a `/c` string, and there is no escape for `%` outside a batch file. An
+argument containing `%FOO%` is therefore substituted with the environment
+value before the target program sees it. That is information disclosure, not
+arbitrary execution — the force-quoting still prevents an argument from
+becoming a second command — and it is the same residual limit Rust's standard
+library documents for its own batch-file handling. Callers that pass untrusted
+text as an argument to a Windows `.cmd` or `.bat` should not assume the value
+arrives byte-identical.
+
**Reporting vulnerabilities.** Report via private GitHub security advisory at
`https://github.com/open-gsd/gsd-core/security/advisories/new`. Do not open
public issues. See [SECURITY.md](../../SECURITY.md) for the response timeline
diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs
index 311cfa444..4b79ac099 100755
--- a/gsd-core/bin/gsd-tools.cjs
+++ b/gsd-core/bin/gsd-tools.cjs
@@ -1415,11 +1415,15 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// PATH search already worked there, and the #3275 acceptance contract
// holds macOS/Linux behavior unchanged. A name that resolves to nothing
// falls back to the declared name so the ENOENT still surfaces (#3086).
- const isWin = process.platform === 'win32';
- const target = isWin ? (resolveSpawnBinary(binary) || binary) : binary;
- const winShim = isWin && /\.(cmd|bat)$/i.test(path.basename(target));
- const spawnBinary = winShim ? (process.env.ComSpec || 'cmd.exe') : target;
- const spawnArgv = winShim ? ['/d', '/s', '/c', target, ...argv] : argv;
+ // #3411: the resolve-then-mediate pair is one seam call now. Both halves had
+ // private copies here; `projectSpawnInvocation` owns them, so a fix to either
+ // reaches every spawn site instead of only this one.
+ //
+ // Unlike execTool, this lane adopts the RESOLVED path even for a non-batch
+ // binary: that is the behavior #3445 shipped and `deps.hasBinary` answers
+ // from the same resolver, so probe and spawn must agree on the exact file.
+ const { projectSpawnInvocation } = require('./lib/shell-command-projection.cjs');
+ const { command: spawnBinary, args: spawnArgv, windowsVerbatimArguments } = projectSpawnInvocation(binary, argv);
const r = cp.spawnSync(spawnBinary, spawnArgv, {
input: opts.input,
encoding: 'utf8',
@@ -1431,6 +1435,7 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
// child only. Passing a fresh object leaves `process.env` untouched, so nothing leaks
// into the orchestrating session or into the next lane.
...(opts.env ? { env: { ...process.env, ...opts.env } } : {}),
+ ...(windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
});
return {
status: r.status,
@@ -3617,32 +3622,16 @@ function dispatchOverlayCapabilityCommand({ command, args, cwd, raw, error, load
*
* Path-like names (any '/' or '\') bypass the PATH scan: the name is already an
* address, so it passes through when the file exists and is a file.
+ *
+ * #3411: the scan itself now lives in the declared platform seam
+ * (`src/shell-command-projection.cts` → `resolveExecutableBinary`). This function is
+ * the `bin/` entry point onto it and holds no copy of the logic — `CONTEXT.md`
+ * declares that file "All OS-facing I/O; single platform seam", and a private
+ * duplicate here is what made it untrue.
*/
function resolveSpawnBinary(name, platform = process.platform, env = process.env) {
- if (!name) return null;
- if (name.includes('/') || name.includes('\\')) {
- try { return fs.statSync(name).isFile() ? name : null; } catch { return null; }
- }
- const segments = String(env.PATH || '').split(path.delimiter).filter(Boolean);
- if (platform !== 'win32') {
- for (const dir of segments) {
- const candidate = path.join(dir, name);
- try {
- if (fs.statSync(candidate).isFile()) return candidate;
- } catch { /* next candidate */ }
- }
- return null;
- }
- const exts = String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean);
- for (const dir of segments) {
- for (const ext of exts) {
- const candidate = path.join(dir, name + ext);
- try {
- if (fs.statSync(candidate).isFile()) return candidate;
- } catch { /* next candidate */ }
- }
- }
- return null;
+ const { resolveExecutableBinary } = require('./lib/shell-command-projection.cjs');
+ return resolveExecutableBinary(name, { platform, env });
}
const HOST_COMMAND_ROUTERS = {
diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts
index 5b7558b57..3dd1feb87 100644
--- a/src/shell-command-projection.cts
+++ b/src/shell-command-projection.cts
@@ -643,15 +643,254 @@ export function execNpm(args: string[], opts: { cwd?: string; timeout?: number }
return _spawnResult(result, 'npm');
}
+/**
+ * Default PATHEXT when Windows does not supply one. Matches the value
+ * `gsd-tools.cjs`'s `resolveSpawnBinary` shipped in #3275; kept identical so the
+ * delegation is a behavior-preserving move rather than a redefinition.
+ */
+const DEFAULT_PATHEXT = '.EXE;.CMD;.BAT;.COM';
+
+/** Windows extensions that must be mediated through cmd.exe rather than spawned. */
+const CMD_MEDIATED_EXT = /\.(cmd|bat)$/i;
+
+function _isFile(candidate: string): boolean {
+ try {
+ return fs.statSync(candidate).isFile();
+ } catch {
+ // Missing, unreadable (EACCES), or a broken link — all mean "not this one".
+ return false;
+ }
+}
+
+/**
+ * Read an environment variable by name, case-insensitively.
+ *
+ * Windows environment variable names are case-insensitive and conventionally
+ * cased `Path` / `ComSpec`, and `process.env` is a case-insensitive proxy that
+ * hides the difference. Spreading it (`{ ...process.env, ...opts.env }`, which
+ * `execTool` does whenever a caller supplies `opts.env`) produces a PLAIN object
+ * that keeps the OS's actual casing and loses the proxy — so an exact-case
+ * `env['PATH']` lookup returns undefined there and the PATH scan silently sees
+ * nothing. Caught by the Windows CI lane on #3617; the #3445 tests never hit it
+ * because they pass uppercase keys explicitly.
+ *
+ * Exact match wins when present, so a caller who sets the canonical name pays
+ * no scan.
+ */
+function _envGet(env: NodeJS.ProcessEnv, name: string): string | undefined {
+ const exact = env[name];
+ if (exact !== undefined) return exact;
+ const lower = name.toLowerCase();
+ for (const key of Object.keys(env)) {
+ if (key.toLowerCase() === lower) return env[key];
+ }
+ return undefined;
+}
+
+/** cmd.exe's own quoting rule inside a `/c` string: a literal quote is doubled. */
+function _cmdQuoteToken(token: string): string {
+ return `"${token.replace(/"/g, '""')}"`;
+}
+
+/**
+ * Build a single verbatim command-line string for `cmd.exe /c` where every
+ * token is force-quoted, then wrap the whole thing in one more outer pair.
+ * cmd.exe strips exactly one outer quote pair when the string begins with a
+ * quote and contains at least two — so the outer wrap disappears and what's
+ * left is a sequence of individually-quoted tokens. Force-quoting is the
+ * point: an unquoted `a&b` is split by cmd's own metacharacter parsing, but a
+ * quoted `"a&b"` is one literal argument.
+ */
+function _buildVerbatimCmdLine(target: string, args: string[]): string {
+ return `"${[target, ...args].map(_cmdQuoteToken).join(' ')}"`;
+}
+
+/**
+ * #3411: resolve a DECLARED command name to the file a spawn can actually start.
+ * The single canonical answer to "where is this binary?" for the whole tree.
+ *
+ * This is the seam `CONTEXT.md` declares as "All OS-facing I/O; single platform
+ * seam". Four divergent implementations of this logic existed (#3411): `execNpm`'s
+ * `shell:true`, `execTool`'s absence of any handling, `gsd-tools.cjs`'s private
+ * scan, and `fallow-runner.cts`'s own candidate array. #3275 folded two of them
+ * together in `bin/`; this lifts that resolver into the seam so `bin/` delegates
+ * instead of owning a copy.
+ *
+ * **win32** tries PATHEXT entries ONLY — never the bare name. npm global installs
+ * drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
+ * bare-name-first scan resolves to it, the cmd.exe mediation gate sees no `.cmd`,
+ * and the ENOENT returns unchanged (field-reported on Windows 11 — see #3275).
+ * A name that ALREADY carries a PATHEXT-listed extension is tried as-is first, so
+ * `foo.exe` resolves to `foo.exe` rather than being probed as `foo.exe.EXE`; a
+ * suffix that is not in PATHEXT (`foo.txt`) is not an extension and only feeds the
+ * append loop.
+ *
+ * **POSIX** answers EXISTENCE by scanning PATH for the bare name. `execTool` does
+ * NOT consult this on POSIX — the bare name goes to spawnSync unchanged and Node's
+ * own PATH search does the work, so macOS/Linux behavior is untouched (#3275
+ * acceptance contract).
+ *
+ * Path-like names (any `/` or `\`) bypass the PATH scan: the name is already an
+ * address, so it passes through when it names an existing file.
+ *
+ * @returns the resolved path, or `null` when nothing matched. Callers fall back to
+ * the declared name on `null` so a genuine ENOENT still surfaces (#3086).
+ */
+export function resolveExecutableBinary(
+ name: string | null | undefined,
+ opts: { platform?: string; env?: NodeJS.ProcessEnv } = {},
+): string | null {
+ if (!name) return null;
+ if (name.includes('/') || name.includes('\\')) {
+ return _isFile(name) ? name : null;
+ }
+ const env = opts.env ?? process.env;
+ const platform = opts.platform ?? process.platform;
+ const segments = String(_envGet(env, 'PATH') || '').split(path.delimiter).filter(Boolean);
+
+ if (platform !== 'win32') {
+ for (const dir of segments) {
+ const candidate = path.join(dir, name);
+ if (_isFile(candidate)) return candidate;
+ }
+ return null;
+ }
+
+ const exts = String(_envGet(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean);
+ // A name already ending in a PATHEXT-listed extension is an address, not a stem:
+ // probing `foo.exe` as `foo.exe.EXE` would miss the file sitting right there.
+ // Compared case-insensitively because PATHEXT casing is not guaranteed.
+ const lower = name.toLowerCase();
+ const carriesKnownExt = exts.some((ext) => lower.endsWith(ext.toLowerCase()));
+
+ for (const dir of segments) {
+ if (carriesKnownExt) {
+ const asIs = path.join(dir, name);
+ if (_isFile(asIs)) return asIs;
+ }
+ for (const ext of exts) {
+ const candidate = path.join(dir, name + ext);
+ if (_isFile(candidate)) return candidate;
+ }
+ }
+ return null;
+}
+
+/**
+ * #3411: project a declared `(command, args)` into the pair `spawnSync` can
+ * actually execute on this platform.
+ *
+ * Resolution alone does not make Windows work: `CreateProcess` cannot execute a
+ * `.cmd`/`.bat` at all, so the cmd.exe mediation is inseparable from the lookup.
+ * Exporting only the resolver would leave every caller to re-derive that half —
+ * which is precisely how #3411's four copies accumulated.
+ *
+ * cmd.exe is invoked as an ordinary program with an EXPLICIT argv array, never via
+ * `shell: true`. `shell:true` on Windows is the mechanism behind CVE-2024-27980
+ * (argument injection through `.bat`/`.cmd`), and Node 26 deprecates it with an
+ * args array (DEP0190) because arguments are concatenated rather than escaped.
+ *
+ * Mediation fires when the target — the resolved path, or the declared name when
+ * resolution found nothing — carries a `.cmd`/`.bat` extension. A BARE name that
+ * resolved to nothing is passed through verbatim so the spawn fails with ENOENT;
+ * mediating it would turn `{exitCode:127, 'foo: not found'}` into cmd.exe's exit
+ * 9009 and silently change the not-found contract callers depend on.
+ *
+ * POSIX is a strict no-op: the declared command is returned unchanged and the
+ * environment is never consulted.
+ *
+ * The mediated command line is built VERBATIM rather than left to libuv: libuv's
+ * `quote_cmd_arg` only force-quotes an argument that contains a space, tab, or
+ * quote — it does not know about cmd.exe metacharacters (`&`, `|`, `>`, `<`,
+ * `^`, ...) at all, so an argument like `a&calc` reaches cmd.exe unquoted and
+ * gets re-parsed as two commands (the CVE-2024-27980 argument-injection class).
+ * Node's own CVE-2024-27980 escaping does not help here because it only fires
+ * when the spawned FILE itself is a `.bat`/`.cmd` — in this seam the spawned
+ * file is `cmd.exe`, not the target. Building the line ourselves and passing
+ * `windowsVerbatimArguments: true` (the shape Rust's std uses for the sibling
+ * CVE-2024-24576) means every token is force-quoted inside one outer pair, so
+ * a metacharacter inside a quoted token can never split the command line.
+ *
+ * KNOWN LIMIT: `%VAR%` still expands inside a cmd `/c` string, and there is no
+ * escape for `%` outside a batch file — an argument containing `%FOO%` is
+ * substituted with the environment value regardless of quoting. That is an
+ * information-disclosure limit, not arbitrary execution, and it's the same
+ * limit Rust's std documents for its own `CommandExt::raw_arg` escape hatch.
+ *
+ * CALLER CHOICE: this function's return value carries two independently
+ * adoptable pieces of information, and a caller may take either, both, or
+ * neither. `windowsVerbatimArguments: true` marks the cases where mediation
+ * was REQUIRED — the caller MUST adopt `command`+`args` together, since a
+ * `.cmd`/`.bat` genuinely cannot be spawned any other way. A merely-resolved
+ * `.exe` path (no mediation flag set) is only an OFFER: a caller may decline
+ * it and keep spawning the declared name instead, to hold its own observable
+ * contract stable. `execTool` (this file, below) is exactly such a caller —
+ * it adopts the mediated pair when `windowsVerbatimArguments` is set, but
+ * otherwise passes the declared `program`/`args` through untouched.
+ */
+export function projectSpawnInvocation(
+ command: string,
+ args: string[] = [],
+ opts: { platform?: string; env?: NodeJS.ProcessEnv } = {},
+): { command: string; args: string[]; resolved: string | null; windowsVerbatimArguments?: boolean } {
+ const platform = opts.platform ?? process.platform;
+ if (platform !== 'win32') return { command, args, resolved: null };
+
+ const env = opts.env ?? process.env;
+ const resolved = resolveExecutableBinary(command, { platform, env });
+ // Mediate against the resolved path when we have one, else against the declared
+ // name. An unresolved name is mediated ONLY when it already declares .cmd/.bat:
+ // PATH-only resolution misses a batch file sitting in the current directory,
+ // which `cmd.exe /c` still finds — the behavior gsd-tools.cjs shipped before
+ // this consolidation, preserved here rather than silently narrowed.
+ const target = resolved ?? command;
+ if (!CMD_MEDIATED_EXT.test(path.basename(target))) {
+ // A BARE name that resolved to nothing is passed through untouched so the
+ // spawn fails with ENOENT. Mediating it would turn {exitCode:127,
+ // ': not found'} into cmd.exe's exit 9009 and silently change the
+ // not-found contract `_spawnResult` and its 53 dependent files rely on.
+ return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
+ }
+ // A CR/LF cannot be represented inside a Windows command line at all — cmd.exe
+ // treats it as a line terminator, so mediating it would silently truncate the
+ // argument rather than pass it through. Fail visibly instead: fall back to the
+ // unmediated shape so the spawn either fails with ENOENT (bare unresolved name)
+ // or hands the raw string to CreateProcess, whichever the caller was already
+ // prepared to see for a non-.cmd/.bat target.
+ if (/[\r\n]/.test(target) || args.some((a) => /[\r\n]/.test(a))) {
+ return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
+ }
+ return {
+ command: String(_envGet(env, 'ComSpec') || 'cmd.exe'),
+ args: ['/d', '/s', '/c', _buildVerbatimCmdLine(target, args)],
+ resolved,
+ windowsVerbatimArguments: true,
+ };
+}
+
export function execTool(program: string, args: string[], opts: { cwd?: string; env?: Record; timeout?: number } = {}): SpawnResultOutput {
- const result = childProcess.spawnSync(program, args, {
+ // #3411: Windows cannot spawn a .cmd/.bat at all — CreateProcess refuses it —
+ // so those are mediated through cmd.exe. Everything else keeps the DECLARED
+ // program name: libuv's CreateProcess path already performs PATH + PATHEXT
+ // search, so resolving a .exe here would buy nothing and would change what
+ // this seam's 167 dependents observe being spawned. `tests/graphify.test.cjs`
+ // pins that contract by spying on spawnSync's first argument. POSIX never
+ // reaches the mediation branch at all.
+ const spawnEnv = opts.env ? { ...process.env, ...opts.env } : undefined;
+ const invocation = projectSpawnInvocation(program, args, { env: spawnEnv ?? process.env });
+ const mediated = invocation.windowsVerbatimArguments === true;
+ const result = childProcess.spawnSync(mediated ? invocation.command : program, mediated ? invocation.args : args, {
cwd: opts.cwd,
- env: opts.env ? { ...process.env, ...opts.env } : undefined,
+ env: spawnEnv,
encoding: 'utf-8',
stdio: 'pipe',
timeout: opts.timeout ?? 30_000,
windowsHide: true,
+ ...(mediated ? { windowsVerbatimArguments: true } : {}),
});
+ // Stamp the DECLARED name, never the resolved path: `_spawnResult` renders
+ // `${program}: not found`, and callers across 53 files match on the string they
+ // passed. Resolution must not leak an absolute path into that message.
return _spawnResult(result, program);
}
diff --git a/tests/shell-command-projection-dispatch.test.cjs b/tests/shell-command-projection-dispatch.test.cjs
index f5d0457af..c68a45fa9 100644
--- a/tests/shell-command-projection-dispatch.test.cjs
+++ b/tests/shell-command-projection-dispatch.test.cjs
@@ -10,6 +10,8 @@ const {
execGit,
execNpm,
execTool,
+ resolveExecutableBinary,
+ projectSpawnInvocation,
probeTty,
normalizeContent,
platformWriteSync,
@@ -113,6 +115,676 @@ describe('execTool', () => {
});
});
+// ─── resolveExecutableBinary (#3411) ────────────────────────────────────────
+// Platform is always INJECTED via opts.platform/opts.env — every win32 case
+// runs on macOS/Linux too. See .gsd/phase/feat-3411-windows-binary-seam/50-test-matrix.md.
+
+describe('resolveExecutableBinary (#3411)', () => {
+ test('R1: is exported as a function', () => {
+ assert.equal(typeof resolveExecutableBinary, 'function');
+ });
+
+ test('R2: win32 resolves the .CMD', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(resolved, path.join(dir, 'foo.CMD'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R3: win32 resolves the .EXE', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.EXE'), '');
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(resolved, path.join(dir, 'foo.EXE'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R4: never the extensionless shim', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'codex'), '');
+ fs.writeFileSync(path.join(dir, 'codex.CMD'), '');
+ const resolved = resolveExecutableBinary('codex', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(resolved, path.join(dir, 'codex.CMD'));
+ assert.match(path.basename(resolved), /\.(cmd|bat)$/i);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R5: extensionless-only → null', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'codex'), '');
+ const resolved = resolveExecutableBinary('codex', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R6: honors PATHEXT order', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'gem.CMD'), '');
+ fs.writeFileSync(path.join(dir, 'gem.EXE'), '');
+ const cmdFirst = resolveExecutableBinary('gem', { platform: 'win32', env: { PATH: dir, PATHEXT: '.CMD;.EXE' } });
+ assert.equal(cmdFirst, path.join(dir, 'gem.CMD'));
+ const exeFirst = resolveExecutableBinary('gem', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(exeFirst, path.join(dir, 'gem.EXE'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R7: lowercase PATHEXT', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'gem.cmd'), '');
+ const resolved = resolveExecutableBinary('gem', { platform: 'win32', env: { PATH: dir, PATHEXT: '.cmd' } });
+ assert.equal(resolved, path.join(dir, 'gem.cmd'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R8: PATHEXT default', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'gem.CMD'), '');
+ const resolved = resolveExecutableBinary('gem', { platform: 'win32', env: { PATH: dir } });
+ assert.equal(resolved, path.join(dir, 'gem.CMD'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R9: PATHEXT-suffixed name resolves as-is', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'tool.exe'), '');
+ const resolved = resolveExecutableBinary('tool.exe', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE' } });
+ assert.equal(resolved, path.join(dir, 'tool.exe'));
+ assert.notEqual(resolved, path.join(dir, 'tool.exe.EXE'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R10: non-PATHEXT suffix is not an extension', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'note.txt'), '');
+ const resolved = resolveExecutableBinary('note.txt', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE;.CMD' } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R11: append loop over a dotted name', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'note.txt.EXE'), '');
+ const resolved = resolveExecutableBinary('note.txt', { platform: 'win32', env: { PATH: dir, PATHEXT: '.EXE' } });
+ assert.equal(resolved, path.join(dir, 'note.txt.EXE'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R12: PATH order', () => {
+ const dir1 = createTempDir();
+ const dir2 = createTempDir();
+ const dir3 = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir2, 'foo.EXE'), '');
+ const PATH = [dir1, dir2, dir3].join(path.delimiter);
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH, PATHEXT: '.EXE' } });
+ assert.equal(resolved, path.join(dir2, 'foo.EXE'));
+ } finally {
+ cleanup(dir1);
+ cleanup(dir2);
+ cleanup(dir3);
+ }
+ });
+
+ test('R13: empty PATH → null', () => {
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: '' } });
+ assert.equal(resolved, null);
+ });
+
+ test('R14: empty PATH segments', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.EXE'), '');
+ const PATH = dir + path.delimiter + path.delimiter + dir;
+ assert.doesNotThrow(() => {
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH, PATHEXT: '.EXE' } });
+ assert.equal(resolved, path.join(dir, 'foo.EXE'));
+ });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R15: path-like passthrough', () => {
+ const dir = createTempDir();
+ try {
+ const staged = path.join(dir, 'foo.cmd');
+ fs.writeFileSync(staged, '');
+ const resolved = resolveExecutableBinary(staged, { platform: 'win32', env: { PATH: dir } });
+ assert.equal(resolved, staged);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R16: missing path-like → null', () => {
+ const dir = createTempDir();
+ try {
+ const missing = path.join(dir, 'nope.cmd');
+ const resolved = resolveExecutableBinary(missing, { platform: 'win32', env: { PATH: dir } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R17: directory is not a binary', () => {
+ const dir = createTempDir();
+ try {
+ const resolved = resolveExecutableBinary(dir, { platform: 'win32', env: { PATH: dir } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R18: empty name short-circuits', () => {
+ assert.equal(resolveExecutableBinary('', { platform: 'win32', env: { PATH: '/x' } }), null);
+ assert.equal(resolveExecutableBinary(null, { platform: 'win32', env: { PATH: '/x' } }), null);
+ assert.equal(resolveExecutableBinary(undefined, { platform: 'win32', env: { PATH: '/x' } }), null);
+ });
+
+ test('R19: posix resolves bare', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo'), '');
+ const resolved = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir } });
+ assert.equal(resolved, path.join(dir, 'foo'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R20: posix miss → null', () => {
+ const dir = createTempDir();
+ try {
+ const resolved = resolveExecutableBinary('foo', { platform: 'linux', env: { PATH: dir } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R21: posix ignores PATHEXT', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'gem.CMD'), '');
+ const resolved = resolveExecutableBinary('gem', { platform: 'linux', env: { PATH: dir, PATHEXT: '.CMD' } });
+ assert.equal(resolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R22: unreadable candidate is skipped', () => {
+ const dir1 = createTempDir();
+ const dir2 = createTempDir();
+ const originalStatSync = fs.statSync;
+ try {
+ fs.writeFileSync(path.join(dir2, 'foo.EXE'), '');
+ const firstCandidate = path.join(dir1, 'foo.EXE');
+ fs.statSync = (candidate, ...rest) => {
+ if (candidate === firstCandidate) {
+ throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
+ }
+ return originalStatSync(candidate, ...rest);
+ };
+ const PATH = [dir1, dir2].join(path.delimiter);
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH, PATHEXT: '.EXE' } });
+ assert.equal(resolved, path.join(dir2, 'foo.EXE'));
+ } finally {
+ fs.statSync = originalStatSync;
+ cleanup(dir1);
+ cleanup(dir2);
+ }
+ });
+
+ test('R23: resolves when PATH is spelled Path (Windows casing)', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { Path: dir, PATHEXT: '.CMD' } });
+ assert.equal(resolved, path.join(dir, 'foo.CMD'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R24: resolves when PATHEXT is spelled Pathext', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.XYZ'), '');
+ const resolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: dir, Pathext: '.XYZ' } });
+ assert.equal(resolved, path.join(dir, 'foo.XYZ'));
+
+ // Negative control: without the differently-cased Pathext key, '.XYZ'
+ // is not in the default PATHEXT, so resolution must fail.
+ const unresolved = resolveExecutableBinary('foo', { platform: 'win32', env: { PATH: dir } });
+ assert.equal(unresolved, null);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('R25: an exact-case key wins over a differently-cased one', () => {
+ const dirExact = createTempDir();
+ const dirOther = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dirExact, 'foo.CMD'), '');
+ const resolved = resolveExecutableBinary('foo', {
+ platform: 'win32',
+ env: { PATH: dirExact, Path: dirOther, PATHEXT: '.CMD' },
+ });
+ assert.equal(resolved, path.join(dirExact, 'foo.CMD'));
+ } finally {
+ cleanup(dirExact);
+ cleanup(dirOther);
+ }
+ });
+});
+
+// ─── projectSpawnInvocation (#3411) ─────────────────────────────────────────
+
+// Mirrors the seam's own `_cmdQuoteToken` (a literal `"` is doubled) so
+// expectations here are built the same way the implementation builds them,
+// without importing the private helper.
+function _q(token) {
+ return `"${String(token).replace(/"/g, '""')}"`;
+}
+
+describe('projectSpawnInvocation (#3411)', () => {
+ test('P1: .CMD mediates through cmd.exe', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = path.join(dir, 'foo.CMD');
+ const env = { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a', 'b'], { platform: 'win32', env });
+ assert.deepEqual(result, {
+ command: 'C:\\Windows\\System32\\cmd.exe',
+ args: ['/d', '/s', '/c', `"${_q(resolved)} ${_q('a')} ${_q('b')}"`],
+ resolved,
+ windowsVerbatimArguments: true,
+ });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P2: .BAT mediates', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.BAT'), '');
+ const resolved = path.join(dir, 'foo.BAT');
+ const env = { PATH: dir, PATHEXT: '.BAT', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a'], { platform: 'win32', env });
+ assert.deepEqual(result, {
+ command: 'C:\\Windows\\System32\\cmd.exe',
+ args: ['/d', '/s', '/c', `"${_q(resolved)} ${_q('a')}"`],
+ resolved,
+ windowsVerbatimArguments: true,
+ });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P3: .EXE spawns directly', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.EXE'), '');
+ const resolved = path.join(dir, 'foo.EXE');
+ const env = { PATH: dir, PATHEXT: '.EXE' };
+ const result = projectSpawnInvocation('foo', ['a'], { platform: 'win32', env });
+ assert.deepEqual(result, { command: resolved, args: ['a'], resolved });
+ assert.equal(result.args.includes('/c'), false);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P4: ComSpec default', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD' };
+ const result = projectSpawnInvocation('foo', [], { platform: 'win32', env });
+ assert.equal(result.command, 'cmd.exe');
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P5: unresolved never mediates', () => {
+ const dir = createTempDir();
+ try {
+ const env = { PATH: dir, PATHEXT: '.EXE;.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('missing-tool', ['a'], { platform: 'win32', env });
+ assert.deepEqual(result, { command: 'missing-tool', args: ['a'], resolved: null });
+ assert.equal(result.command.includes('cmd'), false);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P6: posix is a strict no-op', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a'], { platform: 'linux', env });
+ assert.deepEqual(result, { command: 'foo', args: ['a'], resolved: null });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P7: empty argv', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = path.join(dir, 'foo.CMD');
+ const env = { PATH: dir, PATHEXT: '.CMD' };
+ const result = projectSpawnInvocation('foo', [], { platform: 'win32', env });
+ assert.deepEqual(result.args, ['/d', '/s', '/c', `"${_q(resolved)}"`]);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P8: argv is not shell-interpolated', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD' };
+ const originalArgs = ['a b', 'x&y', 'q"z'];
+ const result = projectSpawnInvocation('foo', originalArgs, { platform: 'win32', env });
+ // Each original argument content survives intact — quoted, never split or
+ // concatenated by cmd's own metacharacter parsing.
+ const line = result.args[3];
+ assert.ok(line.includes(_q('a b')));
+ assert.ok(line.includes(_q('x&y')));
+ assert.ok(line.includes(_q('q"z')));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P9: unresolved name declaring .cmd still mediates', () => {
+ const dir = createTempDir();
+ try {
+ const env = { PATH: dir, PATHEXT: '.EXE', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('missing.cmd', ['a'], { platform: 'win32', env });
+ assert.deepEqual(result, {
+ command: 'C:\\Windows\\System32\\cmd.exe',
+ args: ['/d', '/s', '/c', `"${_q('missing.cmd')} ${_q('a')}"`],
+ resolved: null,
+ windowsVerbatimArguments: true,
+ });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P10: unresolved BARE name still does not mediate', () => {
+ const dir = createTempDir();
+ try {
+ const env = { PATH: dir, PATHEXT: '.EXE', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('missing', ['a'], { platform: 'win32', env });
+ assert.deepEqual(result, { command: 'missing', args: ['a'], resolved: null });
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P11: mediated args are force-quoted so cmd metacharacters cannot split', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a&calc', 'b|c', 'd>e'], { platform: 'win32', env });
+ assert.equal(result.windowsVerbatimArguments, true);
+ assert.equal(result.args.length, 4);
+ const line = result.args[3];
+ assert.ok(line.includes('"a&calc"'));
+ assert.ok(line.includes('"b|c"'));
+ assert.ok(line.includes('"d>e"'));
+ // The bare unquoted sequence must not appear outside of a quoted token —
+ // every occurrence of `a&calc` in the line is immediately preceded by a
+ // quote and followed by a quote.
+ let idx = -1;
+ while ((idx = line.indexOf('a&calc', idx + 1)) !== -1) {
+ assert.equal(line[idx - 1], '"');
+ assert.equal(line[idx + 'a&calc'.length], '"');
+ }
+ assert.equal(line.startsWith('"'), true);
+ assert.equal(line.endsWith('"'), true);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P12: embedded quotes are doubled', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD' };
+ const result = projectSpawnInvocation('foo', ['he said "hi"'], { platform: 'win32', env });
+ const line = result.args[3];
+ assert.ok(line.includes('he said ""hi""'));
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P13: an argument containing a newline is not mediated', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = path.join(dir, 'foo.CMD');
+ const env = { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a\nb'], { platform: 'win32', env });
+ assert.deepEqual(result, { command: resolved, args: ['a\nb'], resolved });
+ assert.equal(result.args.includes('/d'), false);
+ assert.equal(result.windowsVerbatimArguments, undefined);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P14: non-mediated returns never set windowsVerbatimArguments', () => {
+ const dir = createTempDir();
+ try {
+ // POSIX no-op.
+ const posixResult = projectSpawnInvocation('foo', ['a'], { platform: 'linux', env: {} });
+ assert.equal(posixResult.windowsVerbatimArguments, undefined);
+
+ // Unresolved bare name.
+ const bareEnv = { PATH: dir, PATHEXT: '.EXE', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const bareResult = projectSpawnInvocation('missing', ['a'], { platform: 'win32', env: bareEnv });
+ assert.equal(bareResult.windowsVerbatimArguments, undefined);
+
+ // Resolved .EXE.
+ fs.writeFileSync(path.join(dir, 'foo.EXE'), '');
+ const exeEnv = { PATH: dir, PATHEXT: '.EXE' };
+ const exeResult = projectSpawnInvocation('foo', ['a'], { platform: 'win32', env: exeEnv });
+ assert.equal(exeResult.windowsVerbatimArguments, undefined);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test("P15: the mediated line round-trips through cmd's own outer-quote rule", () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = path.join(dir, 'foo.CMD');
+ const env = { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a'], { platform: 'win32', env });
+ const line = result.args[3];
+ // Outer pair immediately followed by the target's own opening quote.
+ assert.equal(line.startsWith('""'), true);
+ // Stripping the outer pair leaves the individually-quoted tokens, whose
+ // first token is the quoted resolved path.
+ const stripped = line.slice(1, -1);
+ assert.equal(stripped.startsWith(_q(resolved)), true);
+ } finally {
+ cleanup(dir);
+ }
+ });
+
+ test('P16: ComSpec is read case-insensitively', () => {
+ const dir = createTempDir();
+ try {
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const env = { PATH: dir, PATHEXT: '.CMD', COMSPEC: 'C:\\custom\\cmd.exe' };
+ const result = projectSpawnInvocation('foo', ['a'], { platform: 'win32', env });
+ assert.equal(result.command, 'C:\\custom\\cmd.exe');
+ } finally {
+ cleanup(dir);
+ }
+ });
+});
+
+// ─── execTool (#3411 windows resolution) ────────────────────────────────────
+// Drives spawnSync via mock.method(childProcess, 'spawnSync') — the seam
+// imports childProcess as a namespace precisely so this interception works.
+
+describe('execTool (#3411 windows resolution)', () => {
+ test('E4: declared name survives into stderr', (t) => {
+ mock.method(childProcess, 'spawnSync', () => ({
+ error: Object.assign(new Error('x'), { code: 'ENOENT' }),
+ }));
+ t.after(() => mock.restoreAll());
+
+ const result = execTool('some-absent-tool', []);
+ assert.equal(result.stderr, 'some-absent-tool: not found');
+ assert.equal(result.exitCode, 127);
+ });
+
+ test('E6: posix passes the bare name', (t) => {
+ if (process.platform === 'win32') { t.skip('posix-only assertion'); return; }
+
+ let receivedCommand = null;
+ mock.method(childProcess, 'spawnSync', (command) => {
+ receivedCommand = command;
+ return { status: 0, stdout: '', stderr: '', signal: null, error: null };
+ });
+ t.after(() => mock.restoreAll());
+
+ execTool('some-declared-name', []);
+ assert.equal(receivedCommand, 'some-declared-name');
+ });
+
+ test('E7: execTool spawns the DECLARED name when no mediation is required', (t) => {
+ let received = null;
+ mock.method(childProcess, 'spawnSync', (command, args) => {
+ received = { command, args };
+ return { status: 0, stdout: '', stderr: '', signal: null, error: null };
+ });
+ t.after(() => mock.restoreAll());
+
+ execTool('some-plain-tool', ['--x']);
+
+ assert.equal(received.command, 'some-plain-tool');
+ assert.deepEqual(received.args, ['--x']);
+ });
+
+ test('E1: posix execTool still runs a real subprocess', () => {
+ const result = execTool(process.execPath, ['-e', 'process.stdout.write("ok")']);
+ assert.equal(result.exitCode, 0);
+ assert.equal(result.stdout, 'ok');
+ });
+
+ test('E2: posix ENOENT contract', (t) => {
+ if (process.platform === 'win32') { t.skip('posix ENOENT shape'); return; }
+
+ const result = execTool('gsd-definitely-absent-binary-9f3a', []);
+ assert.equal(result.exitCode, 127);
+ assert.equal(result.stderr, 'gsd-definitely-absent-binary-9f3a: not found');
+ });
+
+ test('E3: execTool mediates a .cmd through cmd.exe on win32', (t) => {
+ const dir = createTempDir();
+ fs.writeFileSync(path.join(dir, 'foo.CMD'), '');
+ const resolved = path.join(dir, 'foo.CMD');
+
+ let received = null;
+ mock.method(childProcess, 'spawnSync', (command, args) => {
+ received = { command, args };
+ return { status: 0, stdout: '', stderr: '', signal: null, error: null };
+ });
+ t.after(() => {
+ mock.restoreAll();
+ cleanup(dir);
+ });
+
+ execTool('foo', ['x'], { env: { PATH: dir, PATHEXT: '.CMD', ComSpec: 'C:\\Windows\\System32\\cmd.exe' } });
+
+ if (process.platform === 'win32') {
+ assert.deepEqual(received, {
+ command: 'C:\\Windows\\System32\\cmd.exe',
+ args: ['/d', '/s', '/c', `"${_q(resolved)} ${_q('x')}"`],
+ });
+ } else {
+ // Non-win32: projectSpawnInvocation is a strict no-op (process.platform
+ // drives it, not the injected env), so execTool must hand spawnSync the
+ // bare declared name unchanged — the POSIX no-op contract.
+ assert.deepEqual(received, { command: 'foo', args: ['x'] });
+ }
+ });
+
+ test('E5: options survive mediation', (t) => {
+ let receivedOptions = null;
+ mock.method(childProcess, 'spawnSync', (_command, _args, options) => {
+ receivedOptions = options;
+ return { status: 0, stdout: '', stderr: '', signal: null, error: null };
+ });
+ t.after(() => mock.restoreAll());
+
+ execTool('some-tool', [], { cwd: '/tmp/x', env: { FOO: 'bar' }, timeout: 1234 });
+
+ assert.equal(receivedOptions.cwd, '/tmp/x');
+ assert.equal(receivedOptions.timeout, 1234);
+ assert.equal(receivedOptions.env.FOO, 'bar');
+ // Case-insensitive: process.env's actual key casing is OS-dependent (Windows
+ // conventionally sets `Path`, not `PATH`), and the merged object here is a
+ // plain spread of process.env — it no longer benefits from the case-insensitive
+ // proxy behavior process.env itself has.
+ assert.equal(Object.keys(receivedOptions.env).some((k) => k.toLowerCase() === 'path'), true);
+ });
+});
+
// ─── dispatchGsdCommand (#2102 Stage 2 — subprocess-shim dispatch to gsd-tools.cjs) ──
//
// The command-routing hub (`createHub()`) has no fully-populated factory