Spike outcome for #3625: evaluate cross-spawn / nano-spawn / execa against the hand-rolled Windows binary resolution and cmd.exe mediation that epic #3411 Phase 1 (PR #3621) is landing in the platform seam. Verdict: stay hand-rolled, with revisit-if conditions recorded so the call is not re-litigated in a future PR. Evidence, per the issue's "Done when" list: - Sync/async verdict per candidate. nano-spawn is async-only — settled by its own README, which lists "synchronous execution" among the features execa has and it does not. cross-spawn exposes `.sync`. execa exposes execaSync, which its own docs discourage. - CVE-2024-27980 escaping verdict per candidate. None uses shell:true. cross-spawn independently arrives at the SAME mechanism the seam uses: cmd.exe /d /s /c with a pre-escaped line and windowsVerbatimArguments. That validates the seam's approach rather than superseding it. - Maturity axes scored with measured data (registry metadata 2026-08-18, transitive footprint measured by install). Two premises in the issue did not survive measurement, both recorded: - The CRITICAL 167-symbol/53-file blast radius is a `direction:both` measurement, inflated by downstream callees. A call-shape change ripples to CALLERS: upstream at depth 15 is 14 symbols / 7 files, MEDIUM, and it terminates at depth 4. The decision does not rest on the CRITICAL figure. - The cited vendoring precedent path does not exist; the real one is gsd-core/bin/lib/vendor/re2js.cjs. Decisive against the only structurally-eligible candidate (cross-spawn): it resolves process.cwd() first on Windows even when an explicit PATH is supplied, calls process.chdir() during resolution, and keys escape depth on a node_modules/.bin/*.cmd path regex of the same shape #3411 was filed to delete. Adoption would also break execTool's observable not-found contract across 53 dependent files. Doc-only: docs/adr/** plus a root-level CONTEXT.md pointer. The CONTEXT.md addition is a new line rather than an edit to the seam's glossary paragraph, which PR #3621 rewrites wholesale — same-line edits would conflict on merge. ADR index regenerated via scripts/gen-adr-index.cjs --write. lint:ci exit 0; lint-docs-required and changeset/lint both run with GITHUB_BASE_REF=next and report ok_no_user_facing_changes. Closes #3625 Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -836,6 +836,8 @@ Invariants:
|
||||
|
||||
Migration: Phases 1-4 (#3465-#3468) shipped 2026-05-13 — seam additions, subprocess-dispatch migration (6 files), fs migration (15 files / 215 call sites), and core.cjs compat-export removal are all complete; see ADR-0009's 2026-05-13 Update section.
|
||||
|
||||
Dependency posture: this seam OWNS its Windows binary resolution and cmd.exe mediation rather than delegating to `cross-spawn`, `nano-spawn`, or `execa` — evaluated and decided in ADR-3625 (#3625). Do not re-litigate it in a PR; read the ADR. Headlines: `nano-spawn` is async-only and cannot back a `spawnSync` seam; `cross-spawn` is the only structurally-eligible candidate and independently arrives at the SAME escaping mechanism this seam uses (`cmd.exe /d /s /c` + pre-escaped line + `windowsVerbatimArguments`), which validates the approach rather than superseding it — but it resolves `process.cwd()` FIRST on Windows even when an explicit PATH is supplied (a binary-planting surface), calls `process.chdir()` during resolution, and keys its escape depth on a `node_modules/.bin/*.cmd` path regex of the same fragile shape #3411 was filed to delete. Adoption would also break `execTool`'s observable not-found contract across 53 dependent files. The decision carries revisit-if conditions (notably: if the seam ever needs an async dispatch shape, re-run the comparison — the honest sync→async ripple is 14 symbols / 7 files upstream, NOT the 167/53 figure, which is a `direction:both` measurement inflated by downstream callees).
|
||||
|
||||
---
|
||||
|
||||
## Session log (chronological, append-only, one line per session)
|
||||
|
||||
305
docs/adr/3625-vetted-spawn-library-evaluation.md
Normal file
305
docs/adr/3625-vetted-spawn-library-evaluation.md
Normal file
@@ -0,0 +1,305 @@
|
||||
# ADR-3625: The platform seam keeps its own Windows binary resolution rather than adopting a spawn library
|
||||
|
||||
- **Status:** Accepted
|
||||
- **Date:** 2026-08-18
|
||||
- **Issue:** [#3625](https://github.com/open-gsd/gsd-core/issues/3625)
|
||||
- **Epic:** [#3411](https://github.com/open-gsd/gsd-core/issues/3411) — evaluated against its Phase 1 ([#3621](https://github.com/open-gsd/gsd-core/pull/3621))
|
||||
- **Scope:** `src/shell-command-projection.cts` subprocess dispatch (`execTool`, `execNpm`, `execGit`)
|
||||
- **Amends:** none. Constrained by [ADR-0009](0009-shell-command-projection-module.md).
|
||||
|
||||
**Decision summary.** GSD Core keeps its own Windows binary resolution and `cmd.exe` mediation and
|
||||
does **not** adopt `cross-spawn`, `nano-spawn`, or `execa`. `nano-spawn` is async-only and cannot
|
||||
back a `spawnSync` seam. `cross-spawn` is the only structurally eligible candidate, and it
|
||||
independently arrives at the *same* escaping mechanism the seam already uses — which validates the
|
||||
approach rather than superseding it — but it resolves `process.cwd()` first on Windows, mutates
|
||||
global state via `process.chdir()` during resolution, and would break an observable contract across
|
||||
53 dependent files. The decision carries explicit revisit-if conditions so it is not simply inertia.
|
||||
|
||||
## Context
|
||||
|
||||
`#3625` asked whether GSD Core should stop hand-rolling Windows binary resolution — PATH + PATHEXT
|
||||
scanning, the extensionless npm shim problem, `.cmd`/`.bat` mediation through `cmd.exe`, and
|
||||
case-insensitive environment reads — and adopt a maintained library instead. The question is fair:
|
||||
epic `#3411` exists precisely because we got this wrong four separate times, and
|
||||
`/choose-boring-technology` is a real argument against re-deriving a known-hard problem.
|
||||
|
||||
This ADR records the evaluation and its outcome, so a future author finds the decision rather than
|
||||
re-deriving it.
|
||||
|
||||
### What was actually compared
|
||||
|
||||
The baseline is **not** `next`. Epic `#3411` Phase 1 (PR `#3621`, open at the time of writing) adds
|
||||
roughly **241 production lines** to the seam plus 672 lines of tests and a 55-line security-posture
|
||||
document. That implementation already:
|
||||
|
||||
- resolves through `PATHEXT` entries only and **never** the bare name on win32 — the extensionless
|
||||
npm POSIX shim that is the confirmed root cause of `#3275`;
|
||||
- reads `PATH`, `PATHEXT`, and `ComSpec` case-insensitively, because spreading `process.env` into a
|
||||
plain object loses the case-insensitive proxy;
|
||||
- mediates `.cmd`/`.bat` through `cmd.exe` with an **explicit argv array**, never `shell: true`;
|
||||
- builds the mediated command line **verbatim**, force-quoting every token inside one outer pair and
|
||||
passing `windowsVerbatimArguments: true` — the same shape Rust's standard library adopted for the
|
||||
sibling CVE-2024-24576;
|
||||
- documents its residual `%VAR%` expansion limit rather than hiding it.
|
||||
|
||||
Candidates were scored against *that*, not against a naive `spawnSync(name, args)`.
|
||||
|
||||
## Two premises in the issue that measurement did not support
|
||||
|
||||
Recorded because both are load-bearing in the original argument.
|
||||
|
||||
**1. The blast-radius figure is measured in the wrong direction.** The issue cites `get_impact`
|
||||
depth 4 reporting 167 affected symbols across 53 files, rated **CRITICAL**, as the reason a
|
||||
sync → async conversion "is a far larger epic than `#3411`". That figure is `direction: both`
|
||||
(re-measured 2026-08-18: 168 symbols, 53 files, `total_affected_is_lower_bound: true`), and it is
|
||||
inflated by **downstream callees** — the functions `execTool` calls. A call-shape change does not
|
||||
ripple downstream; it ripples to **callers**. Measured upstream and transitively:
|
||||
|
||||
| Query | Result | Rating |
|
||||
|---|---|---|
|
||||
| `get_impact(execTool, direction=both, depth=4)` | 168 symbols / 53 files, truncated, lower-bound | CRITICAL |
|
||||
| `get_impact(execTool, direction=upstream, depth=15)` | **14 symbols / 7 files / 6 flows**, terminates at depth 4, not truncated | **MEDIUM** |
|
||||
|
||||
The honest sync → async ripple for `execTool` is 14 symbols across 7 files, and it closes at depth 4
|
||||
(`mcp-server.cts::runServer`). That is a real cost but it is not epic-scale, and the decision below
|
||||
does **not** rest on the CRITICAL figure.
|
||||
|
||||
**2. The vendoring precedent exists, but not at the cited path.** The issue points at
|
||||
`eslint-rules/lib/vendor/re2js.cjs`; that path does not exist. The real vendored copy is
|
||||
`gsd-core/bin/lib/vendor/re2js.cjs` (with `src/vendor/re2js.d.cts`). The precedent stands — it was
|
||||
simply mislocated.
|
||||
|
||||
## Candidate evaluation
|
||||
|
||||
Registry metadata read from `registry.npmjs.org` on 2026-08-18; behavior read from published
|
||||
package source at the versions named.
|
||||
|
||||
### Maturity axes
|
||||
|
||||
| Axis | `cross-spawn` 7.0.6 | `nano-spawn` 2.1.0 | `execa` 10.0.1 |
|
||||
|---|---|---|---|
|
||||
| Adoption (weekly downloads) | 201.7 M | 3.9 M | 135.9 M |
|
||||
| Age (first publish) | 2014-06-30 | 2024-08-19 | 2015-12-05 |
|
||||
| Latest release | **2024-11-18 (~21 months ago)** | 2026-04-01 | 2026-07-31 |
|
||||
| Release cadence | 58 versions; dormant since the CVE burst | 10 versions; steady | 66 versions; steady |
|
||||
| Backing | single maintainer (`satazor`) | `sindresorhus`, `ehmicky` | `sindresorhus`, `ehmicky` |
|
||||
| Interface stability | stable, effectively frozen | 2.x, young | 10.x, actively evolving |
|
||||
| License | MIT | MIT | MIT |
|
||||
| Module format | **CommonJS** | ESM-only | ESM-only |
|
||||
| Node engines | `>= 8` | `>= 20.17` | `>= 22` |
|
||||
| Transitive footprint (measured) | **6 packages, 160 KB** | **1 package, 76 KB** | **18 packages, 1.4 MB** |
|
||||
| Prior security advisory | **CVE-2024-21538** (ReDoS, High, CVSS 7.7) in the escaping regex; fixed 7.0.5 / 6.0.6 | none found | none found |
|
||||
|
||||
For context, GSD Core currently ships **two** runtime dependencies
|
||||
(`@anthropic-ai/claude-agent-sdk`, `ws`) plus one optional (`fallow`).
|
||||
|
||||
### Verdict 1 — synchronous call shape
|
||||
|
||||
The seam is `spawnSync` throughout. A candidate without a synchronous shape is structurally
|
||||
disqualified regardless of its other merits.
|
||||
|
||||
| Candidate | Sync? | Evidence |
|
||||
|---|---|---|
|
||||
| `cross-spawn` | **Yes** | `index.js` exports `module.exports.sync = spawnSync`, wrapping `cp.spawnSync` |
|
||||
| `nano-spawn` | **No** | Its own README lists "synchronous execution" among the features `execa` has and it does not; the API is Promise/`AsyncIterable` throughout |
|
||||
| `execa` | **Yes** | `execaSync()` / `$.sync()`, though execa's own docs state synchronous execution "is generally discouraged as it holds the CPU and prevents parallelization", and it drops streams, IPC, piping, and signal-based termination |
|
||||
|
||||
`nano-spawn` — the library the issue leads with — is **async-only** and therefore cannot back this
|
||||
seam without the sync → async conversion the issue itself sets aside. That is settled by the
|
||||
package's own documentation, not by inference.
|
||||
|
||||
### Verdict 2 — CVE-2024-27980 argument-escaping posture
|
||||
|
||||
The class is argument injection through `.bat`/`.cmd` on Windows, whose mechanism is `shell: true`
|
||||
and unquoted `cmd.exe` metacharacters.
|
||||
|
||||
| Candidate | Uses `shell: true`? | Mechanism | Verdict |
|
||||
|---|---|---|---|
|
||||
| `cross-spawn` | **No** | Resolves the command, then when the target is not `.com`/`.exe` rewrites to `cmd.exe /d /s /c "<line>"` with `windowsVerbatimArguments: true`; arguments are quoted, backslash-doubled, then caret-escaped across cmd's metacharacter set (parentheses, brackets, percent, bang, caret, quote, backtick, angle brackets, ampersand, pipe, semicolon, comma, space, star, question mark) | **Safe against the class.** Note this is *the same mechanism* PR `#3621` implements |
|
||||
| `nano-spawn` | No | Documents `.cmd`/`.bat` and `PATHEXT` support without a shell | Not applicable — disqualified on sync |
|
||||
| `execa` | **No** | Its Windows documentation states "Execa does not require a shell (nor a `cmd.exe /c` prefix) for this" and resolves missing extensions via `PATHEXT` | Safe against the class |
|
||||
| **hand-rolled (`#3621`)** | **No** | Explicit `cmd.exe` argv array; every token force-quoted inside one outer pair; `windowsVerbatimArguments: true`; CR/LF rejected rather than silently truncated | Safe against the class |
|
||||
|
||||
**The most important result of this spike is that no candidate is safer than what we built.**
|
||||
`cross-spawn` — the ecosystem's most-deployed answer, at 201 M weekly downloads — arrives at the
|
||||
identical mechanism: `cmd.exe /d /s /c` with a pre-escaped line and `windowsVerbatimArguments: true`.
|
||||
Independent convergence on the same primitive **corroborates** the seam's approach — it does not by
|
||||
itself establish correctness, and no Windows execution was performed here — but it does convert "we
|
||||
hand-rolled it" into "we hand-rolled it, and the most-used library in the ecosystem reached the same
|
||||
primitive independently."
|
||||
|
||||
### Verdict 3 — Windows resolution semantics
|
||||
|
||||
This is where the candidates and the seam actually diverge, and it decides the recommendation.
|
||||
|
||||
| Property | `cross-spawn` | hand-rolled (`#3621`) |
|
||||
|---|---|---|
|
||||
| Bare extensionless name tried on a dotless command? | **No** — `which`'s `getPathInfo` prepends the empty extension only when the command contains a dot, so `codex` probes `.EXE`/`.CMD`/`.BAT`/`.COM` and resolves `codex.CMD`. `#3275` is **not** reproduced | No, by explicit design |
|
||||
| `process.cwd()` searched? | **Yes, first, on Windows — unconditionally, even when an explicit `PATH` is supplied** (`which`'s `pathEnv` prepends `process.cwd()` before `opt.path`) | **No** — PATH only |
|
||||
| Global process state mutated during resolution? | **Yes** — calls `process.chdir()` when `options.cwd` is set, restoring in `finally`; disabled in worker threads | No |
|
||||
| Escape depth | Heuristic: double-escapes metacharacters only when the resolved path matches `node_modules[\\/].bin[\\/][^\\/]+\.cmd$` | No degree of freedom — every token force-quoted once |
|
||||
| `%VAR%` expansion inside the mediated line | Attempts to caret-escape `%` and `!` | Not escaped; **documented** as a known limit |
|
||||
|
||||
Three of these are adoption blockers on their own:
|
||||
|
||||
1. **`process.cwd()`-first resolution is a binary-planting surface.** A `codex.CMD` dropped in the
|
||||
project directory would win over the real one on Windows, and passing an explicit `PATH` does not
|
||||
prevent it. GSD executes tool binaries in directories whose contents are, by construction, the
|
||||
thing being worked on. The seam scans `PATH` only, deliberately.
|
||||
2. **`process.chdir()` is a global side effect** in a function billed as a drop-in. `execTool`
|
||||
accepts `opts.cwd`, GSD runs concurrent work, and `process.chdir` is unavailable in worker
|
||||
threads.
|
||||
3. **The escape-depth heuristic is keyed on a path regex** — structurally the same shape as the
|
||||
`/\.(cmd|bat)$/i` test in `bin/gsd-tools.cjs` that `#3411` identified as never matching. A
|
||||
cmd-shim outside `node_modules/.bin/` gets single-escaped.
|
||||
|
||||
The one axis where `cross-spawn` may do better is `%VAR%`: it attempts to caret-escape `%` and `!`,
|
||||
where the seam does not and documents the gap. Whether `^%` actually suppresses expansion is
|
||||
contested — `cmd.exe` performs percent-expansion before caret processing — and settling it requires
|
||||
executing on Windows, which this spike did not do. Recorded as **unresolved, narrow, and
|
||||
information-disclosure-only**, not as a reason to adopt.
|
||||
|
||||
## Observable-contract cost (Hyrum's Law)
|
||||
|
||||
`execTool` has 53 dependent files. Its observable behavior includes the **declared** program name
|
||||
stamped into `` `${program}: not found` ``, `exitCode: 127` on not-found, and — per PR `#3621` —
|
||||
`spawnSync`'s first argument, pinned by a spy in `tests/graphify.test.cjs`. Adopting `cross-spawn`
|
||||
changes all three: it passes `cmd.exe` or a resolved absolute path as argument 0, and it signals
|
||||
not-found by setting `result.error` from `verifyENOENTSync` rather than through the exit-127 shape
|
||||
the seam's callers match on. This is a breaking change to a contract 53 files consume, in exchange
|
||||
for behavior we already have.
|
||||
|
||||
## Forever-cost of adopting
|
||||
|
||||
What we could never remove once it landed:
|
||||
|
||||
- **The upstream.** `cross-spawn` has had no release since 2024-11-18 and has one maintainer. Its
|
||||
one security advisory to date was in exactly the code we would be depending on. Adopting a dormant
|
||||
single-maintainer package for a security-sensitive path means we would likely be the ones fixing
|
||||
the next issue anyway — with a fork or a patch, on someone else's schedule.
|
||||
- **The dependency graph.** 2 runtime dependencies become 8 (`cross-spawn`, `which`, `isexe`,
|
||||
`path-key`, `shebang-command`, `shebang-regex`) — a 4× increase in runtime supply-chain surface
|
||||
for a tool whose own `docs/explanation/security-model.md` opens on supply-chain risk. `execa`
|
||||
would make it 20.
|
||||
- **The matrix.** We would still test Windows behavior ourselves, because the observable contract is
|
||||
ours, not the library's.
|
||||
|
||||
### The vendoring option, considered separately
|
||||
|
||||
The issue notes in-tree precedent for taking a third party into `bin/lib` "when the case is made."
|
||||
The precedent is real, though not at the cited path — it is `gsd-core/bin/lib/vendor/re2js.cjs`
|
||||
(with `src/vendor/re2js.d.cts`), not `eslint-rules/lib/vendor/`. It is also not free:
|
||||
`scripts/lint-vendored-deps.cjs` exists specifically to keep the vendored copy pinned to its
|
||||
`node_modules` original, and it runs in `lint:ci`.
|
||||
|
||||
Vendoring is rejected here regardless of which candidate. It keeps the maintenance burden, adds a
|
||||
drift gate, and forfeits the one thing a dependency is actually for — upstream security fixes
|
||||
arriving without our involvement. For a path whose only historical advisory was a CVE in the
|
||||
escaping code, that is the worst of the three options.
|
||||
|
||||
### Partial adoption: take the escaping, keep our resolution
|
||||
|
||||
The objections above are aimed at `cross-spawn` as a whole, and it is fair to ask whether the good
|
||||
half can be taken without the bad. It is technically possible: `cross-spawn` publishes `lib` and
|
||||
declares **no `exports` map**, so `require('cross-spawn/lib/util/escape')` resolves today. That would
|
||||
import the escaping the ADR praises while keeping the seam's PATH-only resolution, sidestepping both
|
||||
the `process.cwd()`-first lookup and the `process.chdir()` side effect.
|
||||
|
||||
Rejected, for reasons that are genuinely different from the full-adoption ones:
|
||||
|
||||
1. **It is a deep import into an undocumented internal path.** The absence of an `exports` map is
|
||||
what makes it reachable, not a promise that it is public. Adding an `exports` map is a routine
|
||||
change that would break us silently, and on a dormant upstream we would be pinned at a version to
|
||||
avoid it — which reintroduces the vendoring problem without the drift gate.
|
||||
2. **The two escaping strategies are not drop-in interchangeable.** `escape.argument` is designed to
|
||||
pair with `cross-spawn`'s own line assembly — escape the command, escape each argument, join on
|
||||
spaces, wrap once. Our seam force-quotes each token instead. Taking one function without its
|
||||
assembly would be a re-derivation wearing a dependency's clothes, which is the precise failure
|
||||
mode `#3411` exists to end.
|
||||
3. **It still costs six packages for roughly forty lines**, and the only behavioral delta it buys is
|
||||
the `%`/`!` caret-escaping — the one axis this ADR records as *unresolved*. Paying a permanent
|
||||
supply-chain cost to import a contested improvement is the wrong trade.
|
||||
|
||||
If the `%VAR%` question is ever resolved in `cross-spawn`'s favor, the cheap move is to port that one
|
||||
behavior into the seam under our own test, not to take the dependency for it.
|
||||
|
||||
## Decision
|
||||
|
||||
**Stay hand-rolled.** The seam keeps ownership of Windows binary resolution and `cmd.exe` mediation
|
||||
as implemented by epic `#3411`.
|
||||
|
||||
This is not "we already wrote it." It rests on measured properties:
|
||||
|
||||
1. No candidate is **safer** — `cross-spawn` independently arrives at the identical escaping
|
||||
mechanism, which validates the seam's approach rather than superseding it.
|
||||
2. The only structurally-eligible candidate (`cross-spawn`; the others fail on sync or on footprint)
|
||||
is **less safe on Windows resolution** — `process.cwd()`-first lookup and a `process.chdir()`
|
||||
side effect — and carries an escape-depth heuristic of the same fragile shape `#3411` was filed
|
||||
to eliminate.
|
||||
3. Adoption **breaks an observable contract** across 53 files in exchange for behavior already
|
||||
present.
|
||||
4. It quadruples the runtime dependency graph, on a dormant single-maintainer upstream, for a
|
||||
security-sensitive path.
|
||||
|
||||
`/choose-boring-technology`'s first step is "solve the problem with what you have." Here that is
|
||||
`node:child_process` plus resolution logic that is written, tested, documented, and contract-bound.
|
||||
The boring choice and the hand-rolled choice are the same choice.
|
||||
|
||||
**Conditions attached.** A bare keep-verdict is what allowed four divergent implementations to grow.
|
||||
This decision holds only while:
|
||||
|
||||
- resolution and mediation stay **in the seam**, with the three ad-hoc copies deleted by `#3411`
|
||||
Phases 2–3, not kept in sync;
|
||||
- the guard from `#3411`'s goal list — rejecting a `spawn`/`execFile` of a bare binary name outside
|
||||
the seam — actually lands, alongside the existing `eslint-rules/no-unbounded-spawn.cjs`,
|
||||
`no-bare-npm-exec.cjs`, `no-unguarded-nonportable-exec.cjs`, and `require-subprocess-timeout.cjs`;
|
||||
- the Windows CI lane keeps covering the mediated path.
|
||||
|
||||
If those erode, the argument for owning this code erodes with them.
|
||||
|
||||
### Revisit if
|
||||
|
||||
- The seam needs an **asynchronous** dispatch shape for an unrelated reason. The sync constraint is
|
||||
what disqualifies `nano-spawn` and most of `execa`'s value; if it lifts, re-run this comparison —
|
||||
the honest ripple is 14 symbols across 7 files, not 168.
|
||||
- `cross-spawn` resumes releases **and** gains a way to disable `process.cwd()`-first resolution.
|
||||
- A maintained, CommonJS-requirable, `spawnSync`-shaped library appears that does PATH-only
|
||||
resolution with no global side effects.
|
||||
- Node's own `child_process` gains first-class `.cmd`/`.bat` resolution making all of this dead code
|
||||
— the outcome that would let us delete rather than delegate.
|
||||
|
||||
## Consequences
|
||||
|
||||
- `#3411` Phases 2 and 3 proceed unchanged. This ADR was explicitly scoped not to block them, and
|
||||
nothing measured here changes their work.
|
||||
- The seam's `CONTEXT.md` entry gains a pointer to this ADR, so the next author who wonders "why
|
||||
didn't we just use `cross-spawn`?" finds the answer instead of re-deriving it.
|
||||
- We continue to own this code, including the next Windows defect in it. That is the accepted cost,
|
||||
and it is smaller than the alternatives measured above.
|
||||
- The `%VAR%` question is left open and documented. If it is ever worth closing, the fix is ours to
|
||||
make in one place.
|
||||
|
||||
## Known limits of this evaluation
|
||||
|
||||
- The baseline is PR `#3621`, which was **open and unmerged** on 2026-08-18. If it changes materially
|
||||
before merge, the comparison's baseline moves with it.
|
||||
- **No Windows execution was performed.** Every Windows claim here comes from reading published
|
||||
package source and official documentation, not from running the candidates on Windows. The `%VAR%`
|
||||
question is the one place where that limit is load-bearing, and it is flagged as unresolved above.
|
||||
- Registry metadata is a point-in-time reading; maintenance judgments age.
|
||||
|
||||
## References
|
||||
|
||||
- Spike issue: `#3625`
|
||||
- Epic: `#3411`; Phase 1 PR: `#3621`; confirmed instances `#3275`, `#3329`
|
||||
- Seam ADR: [ADR-0009](0009-shell-command-projection-module.md)
|
||||
- Security posture: `docs/explanation/security-model.md` — note the **subprocess-execution** section
|
||||
cited above is not on `next` at the time of writing; it lands with PR `#3621`
|
||||
- `cross-spawn` 7.0.6 — `index.js`, `lib/parse.js`, `lib/util/escape.js`, `lib/util/resolveCommand.js`;
|
||||
`which` 2.x `which.js` (`getPathInfo`)
|
||||
- CVE-2024-21538 (`cross-spawn` ReDoS, High/7.7, patched 7.0.5 & 6.0.6) —
|
||||
<https://github.com/advisories/GHSA-3xgq-45jj-v275>
|
||||
- `nano-spawn` 2.1.0 `readme.md` (execa comparison section); `execa` 10.0.1 `docs/execution.md`,
|
||||
`docs/windows.md`
|
||||
- CVE-2024-27980 (Node.js Windows `.bat`/`.cmd` argument injection); CVE-2024-24576 (Rust std,
|
||||
sibling class); Node.js DEP0190 (`shell: true` with an args array)
|
||||
@@ -255,6 +255,7 @@ These govern the system as it stands. Cite these.
|
||||
| [ADR-3408](3408-state-write-path-preservation.md) | STATE.md Write Path — One Declared Policy, One Write Seam | Accepted | — |
|
||||
| [ADR-3409](3409-unreachable-shell-guard-arms.md) | Shell Guards Must Observe Their Own Failure Arm | Accepted | — |
|
||||
| [ADR-3574](3574-install-materialization-primitives.md) | Install materialization shares primitives, not one writer | Accepted | — |
|
||||
| [ADR-3625](3625-vetted-spawn-library-evaluation.md) | The platform seam keeps its own Windows binary resolution rather than adopting a spawn library | Accepted | — |
|
||||
| [ADR-3660](3660-runtime-artifact-layout-module.md) | Runtime Artifact Layout Module owns per-runtime artifact placement | Accepted | [ADR-1239](1239-gsd-embeddable-orchestration-engine.md) |
|
||||
|
||||
### Proposed
|
||||
|
||||
Reference in New Issue
Block a user