Skip to content

fix(cli): spawn agent shadow commands through a shell on Windows#730

Open
Koh0920 wants to merge 2 commits into
devfrom
fix/windows-agent-shadow-command-spawn
Open

fix(cli): spawn agent shadow commands through a shell on Windows#730
Koh0920 wants to merge 2 commits into
devfrom
fix/windows-agent-shadow-command-spawn

Conversation

@Koh0920

@Koh0920 Koh0920 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What

Fixes a Windows-only crash when the self-healing agent (run_v03_lifecycle_steps) tries to refresh dependencies in the shadow workspace. On ato-store capsule runs the agent reported:

E999 — agent setup attempt failed during run_v03_lifecycle_steps: failed to run
agent command 'npm install' in \?\C:\Users\...\.ato\tmp\agent\runs\run-...\workspace\

exit code 2 / process_crash, no readiness signal.

Root cause

AgentSessionExecutor::run_shadow_command spawned the package manager differently from the proven lifecycle runner. Two Windows-specific defects:

  1. std::process::Command::new("npm") cannot launch the npm.cmd shim. On Windows the std spawner only appends .exe and never consults PATHEXT, so npm / pnpm / bun (all .cmd shims) fail to spawn. The working provision path (run_lifecycle_shell_command) avoids this by going through cmd /D /S /C.
  2. \?\ verbatim working directory. absolute_path used std::fs::canonicalize, which on Windows returns a \?\ extended-length path. cmd.exe and the npm shims reject it as a current directory.

Fix

  • run_shadow_command now wraps the command in cmd /D /S /C "<cmd>" on Windows and sh -c "<cmd>" elsewhere — mirroring run_lifecycle_shell_command. The allowlist + shell-operator validation (parse_safe_command) is preserved; only the spawn mechanism changes.
  • absolute_path switches to dunce::canonicalize so the \?\ prefix never reaches downstream spawns.
  • Adds dunce = "1.0" as a direct dependency (already present transitively in the lockfile).
  • New regression test absolute_path_does_not_return_verbatim_prefix.

Testing

  • cargo check -p ato-cli — passes, no new warnings.
  • cargo test -p ato-cli --lib agent::tests::absolute_path_does_not_return_verbatim_prefix — passes.

Koh0920 added 2 commits June 16, 2026 16:08
The self-healing agent (run_v03_lifecycle_steps) ran shadow lifecycle
commands via `Command::new("npm")` directly with a `\\?\` verbatim
working directory. Both break on Windows:

- `std::process::Command` only appends `.exe`, never consults PATHEXT,
  so it cannot resolve the `npm.cmd` / `bun.cmd` / `pnpm.cmd` shims and
  the process fails to spawn (E999 / exit code 2 / process_crash).
- `std::fs::canonicalize` returns a `\\?\` extended-length path which
  cmd.exe and the npm shims reject as a current directory.

Align `run_shadow_command` with the proven `run_lifecycle_shell_command`
path: wrap the (still allowlist-validated) command in `cmd /D /S /C` on
Windows and `sh -c` elsewhere. Switch `absolute_path` to
`dunce::canonicalize` so the verbatim prefix never reaches downstream
spawns. Adds a regression test guarding the prefix strip.
Review follow-up: the previous revision validated the command with
parse_safe_command but then discarded the parsed tokens and handed the
raw string to `cmd /C` / `sh -c`. That reintroduced shell interpretation,
so `npm install & evil`, `npm install $(...)` and backtick substitution
would execute despite the allowlist — a shell-injection regression.

Keep execution strictly argv-based instead:

- `build_shadow_spawn` validates + allowlists, then resolves the program
  to a concrete path. On Windows `resolve_shadow_program` resolves the
  `.cmd`/`.exe` shim via `which` (std cannot find a bare-name `.cmd`); std
  then spawns the resolved `.cmd` through cmd.exe with each argv element
  individually escaped. No shell ever sees the command string.
- Harden `parse_safe_command` to also reject single `&`, backtick, `$(`,
  `${` (defense in depth across cmd.exe and POSIX sh).

Tests: parse_safe_command_rejects_single_ampersand,
parse_safe_command_rejects_command_substitution,
build_shadow_spawn_keeps_argv_and_rejects_shell_ops, plus an accept-path
test for plain package-manager commands. Also makes the pre-existing
session_store artifact-dir assertion separator-agnostic so it passes on
Windows.
@Koh0920

Koh0920 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor Author

Addressed: shell-injection regression (blocker)

You're right — handing the raw string to cmd /C / sh -c after validation reintroduced shell interpretation and broke the argv sandbox. Switched to option 1: strictly argv-based execution.

Changes in a8fefaca

  • No shell, ever. New build_shadow_spawn validates + allowlists, then resolves the program to a concrete path and returns { program, args }. run_shadow_command spawns Command::new(program).args(args) — the command string is never passed to a shell.
  • Windows .cmd shim resolution without a shell. resolve_shadow_program resolves npm/pnpm/bun to their real .cmd/.exe path via which (std's Command only appends .exe, so a bare-name .cmd can't be found). std (>= 1.77.2, post-CVE-2024-24576) then spawns the resolved .cmd through cmd.exe with each argv element individually escaped — argv stays argv.
  • Hardened parse_safe_command as defense in depth: now also rejects single &, backtick, $(, ${ (covers both cmd.exe and POSIX sh), on top of the existing && || ; | < > newline.

Tests added (all green)

  • parse_safe_command_rejects_single_ampersand
  • parse_safe_command_rejects_command_substitution ($(...), ${...}, backtick)
  • parse_safe_command_still_accepts_plain_package_manager_commands (regression guard against over-blocking)
  • build_shadow_spawn_keeps_argv_and_rejects_shell_ops — asserts clean argv for valid commands, rejection of & / $(...) / backtick / |, and (Windows) that the program resolves to an absolute path with an executable extension.

Also made the pre-existing session_store_uses_ato_tmp_agent_and_skips_tmp_copy assertion separator-agnostic — it was hard-coded to POSIX / and fails on Windows independently of this change (the final Path::join(run_id) uses \).

dunce::canonicalize for absolute_path is unchanged, per your note.

Full cargo test -p ato-cli --lib application::agent::tests: 10 passed, 0 failed. Ready for re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant