Skip to content

Commit 879e920

Browse files
authored
Refuse to spawn .bat/.cmd CLI scripts on Windows to prevent command injection (#1127)
## Summary Follow-up to the argv-injection fix in #1123 (HackerOne #3870101). On Windows installs that have no bundled `claude.exe` — sdist / source installs and platforms without a bundled wheel, notably Windows ARM64 — CLI discovery falls back to `shutil.which("claude")`, which resolves npm's `claude.cmd` batch shim. `CreateProcess` runs `.bat`/`.cmd` files by rewriting the spawn into `cmd.exe /c ...`, and cmd.exe re-parses the whole command line. `subprocess.list2cmdline` quotes for the MSVCRT argv rules only (it adds quotes only around whitespace, and cmd.exe does not honor `\"` escaping), so cmd.exe metacharacters inside an argument value — a `--resume` session title, the `--mcp-config` JSON, a system prompt — reach cmd.exe unescaped and can execute injected commands before the CLI even starts. The `--flag=value` equals form from #1123 does not help on this path: once cmd.exe re-parses the string there is no argv boundary left to protect. This is the "BatBadBut" vulnerability class (CVE-2024-27980). ## The fix Refuse to spawn a `.bat`/`.cmd` script as the CLI on Windows. There is no reliable escaping for cmd.exe (`%VAR%` expands even inside double quotes), so refusing is the only robust remediation — the same one Node.js shipped for CVE-2024-27980. The check runs once in `connect()`, immediately after the CLI path is resolved and before anything is spawned with it, so it covers every route to the executable path: - the `shutil.which("claude")` fallback that finds npm's `claude.cmd`, - an explicit `ClaudeAgentOptions(cli_path=...)` pointing at a `.bat`/`.cmd`, - the version probe, which spawns the same path before the main process. The extension test normalizes the path the way Win32 does before checking it — trailing dots and spaces stripped, `.`/`..` and repeated separators collapsed, NTFS alternate-data-stream specs (`claude.cmd:stream`, `claude:evil.cmd`) covered in both directions, drive-relative `C:claude.cmd`, and a bare `.cmd` treated as a batch extension (as `PathFindExtension` treats it). This is the same normalization Rust applied for CVE-2024-24576. It is deliberately plain string logic rather than pathlib, so it behaves identically on the POSIX CI hosts and on Windows. The error message points at the alternatives that avoid cmd.exe entirely: the native installer (`irm https://claude.ai/install.ps1 | iex`), an explicit `claude.exe` path via `ClaudeAgentOptions(cli_path=...)`, or a wheel for a platform that bundles `claude.exe`. ## Defense in depth With batch-script spawning refused, cmd.exe metacharacters are harmless — `list2cmdline` quotes correctly for native executables. `resume` and `session_id` are nevertheless the values applications most often take from external input, so on Windows they now reject cmd.exe metacharacters (`& | < > ^ % ! "`) and CR/LF. That keeps them inert even if a cmd.exe hop is ever reintroduced between the SDK and the CLI. No format is imposed beyond that (resume values may be arbitrary session titles, not only UUIDs), and POSIX behavior is unchanged. ## Related hardening `extra_args` now emits `--flag=value` when the value starts with `-`, so a dash-leading value binds to its flag instead of parsing as a separate CLI flag — the same class of issue the `--resume` equals-form change in #1123 closed, applied to the remaining two-token call site. ## Behavior changes on Windows - Launching a `.bat`/`.cmd` CLI (including npm's `claude.cmd` shim) now raises `CLIConnectionError` instead of spawning it. Windows users relying on the npm shim need the native installer, an explicit `claude.exe` path, or a wheel that bundles the CLI. - `resume` / `session_id` values containing cmd.exe metacharacters or newlines now raise `ValueError` on Windows — e.g. `resume="R&D notes"`. POSIX is unaffected. ## Recommended follow-up Where an npm-only Windows install has no `claude.exe`, the SDK could resolve the `.cmd` shim to the underlying `node.exe` + `cli.js` and launch node directly with a list argv, restoring npm-shim support without any cmd.exe hop. Not included here to keep this change a focused refusal. ## Scope - `src/claude_agent_sdk/_internal/transport/subprocess_cli.py`: `_reject_windows_batch_cli`, `_reject_windows_cmd_metacharacters`, the `connect()` chokepoint, and the `extra_args` equals form. - `tests/test_transport.py`: `TestWindowsBatchScriptRefusal`, `TestExtraArgsValueBinding`, `TestWindowsCmdMetacharacterRejection`. - No behavior change on Linux or macOS. ## Testing - Full suite: 1268 passed, 5 skipped (`pytest tests/`), plus `ruff check`, `ruff format --check`, and `mypy src/` clean. - 36 new tests; 30 of them fail with the source change reverted, so they exercise the fix rather than pre-existing behavior. The refusal tests assert `anyio.open_process` is called zero times — the batch script is refused before the version probe, not merely before the main spawn. - The Windows code paths are exercised by patching `platform.system()`; the actual cmd.exe re-parse is Windows-only behavior reasoned from `CreateProcess` / `list2cmdline` semantics rather than executed in CI.
1 parent 2d4ef94 commit 879e920

2 files changed

Lines changed: 709 additions & 10 deletions

File tree

src/claude_agent_sdk/_internal/transport/subprocess_cli.py

Lines changed: 203 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@
3030
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
3131
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"
3232

33+
# cmd.exe metacharacters (plus the quote character cmd.exe uses to toggle
34+
# its quoting state, and "!", which expands like "%" when delayed expansion
35+
# is enabled). subprocess.list2cmdline quotes arguments for the MSVCRT argv
36+
# rules only -- it adds quotes only around whitespace -- so in a
37+
# whitespace-free argument these characters reach a cmd.exe command line
38+
# verbatim. See _reject_windows_batch_cli / _reject_windows_cmd_metacharacters.
39+
_CMD_EXE_METACHARACTERS = '&|<>^%!"'
40+
3341
# Track live CLI subprocesses so we can terminate them when the parent Python
3442
# process exits. This mirrors the TypeScript SDK's parent-exit cleanup and
3543
# prevents orphaned `claude` processes from leaking when callers crash or exit
@@ -147,22 +155,75 @@ def _find_cli(self) -> str:
147155
return bundled_cli
148156

149157
# Fall back to system-wide search
158+
which_hit: str | None = None
150159
if cli := shutil.which("claude"):
151-
return cli
152-
153-
locations = [
154-
Path.home() / ".npm-global/bin/claude",
155-
Path("/usr/local/bin/claude"),
156-
Path.home() / ".local/bin/claude",
157-
Path.home() / "node_modules/.bin/claude",
158-
Path.home() / ".yarn/bin/claude",
159-
Path.home() / ".claude/local/claude",
160-
]
160+
if platform.system() != "Windows" or self._is_windows_native_exe(cli):
161+
return cli
162+
# Windows resolved something CreateProcess cannot run directly
163+
# as the CLI: npm's claude.cmd shim (which connect() refuses to
164+
# spawn) or an extensionless wrapper script from a git-bash /
165+
# WSL setup (which fails at spawn with WinError 193). shutil.which
166+
# walks PATH directory-major, so such an entry in an early PATH
167+
# directory shadows a native claude.exe installed in a later
168+
# one (within one directory the default PATHEXT would prefer
169+
# .EXE, so the shadowing is purely the directory order). Prefer
170+
# any discoverable native executable, and keep this hit only as
171+
# the last resort so a shim-only machine still gets the
172+
# explanatory batch-script refusal from connect(). The claude.exe
173+
# probe is vetted too: PATHEXT resolution can append an
174+
# extension and hand back "claude.exe.cmd".
175+
exe = shutil.which("claude.exe")
176+
if exe and self._is_windows_native_exe(exe):
177+
return exe
178+
which_hit = cli
179+
180+
if platform.system() == "Windows":
181+
# Only the native installer's claude.exe. Path.exists() does
182+
# no PATHEXT resolution, so the .exe name must be probed
183+
# explicitly. The POSIX-shaped entries below are deliberately
184+
# not probed on Windows: an extensionless match (a WSL / git-bash
185+
# script artifact at ~/.local/bin/claude) would preempt the
186+
# explanatory batch-script refusal with an opaque spawn
187+
# failure, and a rooted-but-driveless "/usr/local/bin/claude"
188+
# resolves against the current drive (C:\usr\local\bin\...),
189+
# a location another local user can create -- a
190+
# binary-planting probe.
191+
locations = [Path.home() / ".local/bin/claude.exe"]
192+
else:
193+
locations = [
194+
Path.home() / ".npm-global/bin/claude",
195+
Path("/usr/local/bin/claude"),
196+
Path.home() / ".local/bin/claude",
197+
Path.home() / "node_modules/.bin/claude",
198+
Path.home() / ".yarn/bin/claude",
199+
Path.home() / ".claude/local/claude",
200+
]
161201

162202
for path in locations:
163203
if path.exists() and path.is_file():
164204
return str(path)
165205

206+
if which_hit is not None:
207+
# No native executable was discoverable anywhere: return the
208+
# original which() hit so connect() raises the batch-script
209+
# refusal (with its remediation) for a shim, or the spawn error
210+
# for a wrapper script, rather than a bare not-found error.
211+
return which_hit
212+
213+
if platform.system() == "Windows":
214+
# npm's Windows install is a claude.cmd shim, which connect()
215+
# refuses (_reject_windows_batch_cli), so do not recommend it.
216+
raise CLINotFoundError(
217+
"Claude Code not found. Install the native claude.exe with "
218+
"(PowerShell):\n"
219+
" irm https://claude.ai/install.ps1 | iex\n"
220+
"\nOr install the claude-agent-sdk wheel for a platform that "
221+
"bundles claude.exe (e.g. Windows x64), or provide the path to "
222+
"a claude.exe via ClaudeAgentOptions:\n"
223+
" ClaudeAgentOptions(cli_path='C:\\\\path\\\\to\\\\claude.exe')\n"
224+
"\n(npm install -g @anthropic-ai/claude-code produces a claude.cmd "
225+
"shim, which this SDK refuses to run on Windows.)"
226+
)
166227
raise CLINotFoundError(
167228
"Claude Code not found. Install with:\n"
168229
" npm install -g @anthropic-ai/claude-code\n"
@@ -187,6 +248,122 @@ def _find_bundled_cli(self) -> str | None:
187248

188249
return None
189250

251+
@staticmethod
252+
def _is_windows_native_exe(cli_path: str) -> bool:
253+
"""Whether cli_path's final component names an image CreateProcess
254+
runs directly (.exe / .com), used only to decide which discovery
255+
result to prefer. It is not a security gate: every returned path
256+
still passes _reject_windows_batch_cli in connect().
257+
"""
258+
name = cli_path.replace("\\", "/").rsplit("/", 1)[-1]
259+
return name.rstrip(". ").lower().endswith((".exe", ".com"))
260+
261+
@staticmethod
262+
def _is_windows_batch_cli(cli_path: str) -> bool:
263+
"""Whether cli_path names a .bat/.cmd batch script on Windows.
264+
265+
Always False off Windows. See _reject_windows_batch_cli for why
266+
spawning such a script is refused.
267+
"""
268+
if platform.system() != "Windows":
269+
return False
270+
# Deliberately NOT pathlib: PureWindowsPath and PurePosixPath parse
271+
# several of the cases below differently (".cmd" has suffix ".cmd"
272+
# on POSIX but "" on Windows), and the tests run on POSIX CI while
273+
# the code runs on Windows. Plain string logic behaves identically
274+
# on both.
275+
#
276+
# Classify EVERY path component, not only the final one. Win32 opens
277+
# a path after lexical normalization -- "." / ".." collapsing,
278+
# repeated separators, and position-dependent trailing dot/space
279+
# trimming (a middle ".. " or "..." stays a literal name while a
280+
# final one trims to ".." or vanishes) -- and any attempt to
281+
# re-derive the effective final component here is a race against
282+
# that ruleset: get one rule slightly wrong and a spelling such as
283+
# "claude.cmd\\...\\.." resolves to claude.cmd on Windows while the
284+
# simulation lands on some other name. Refusing whenever ANY
285+
# component carries a batch extension closes that whole class
286+
# outright, because every normalization trick still has to spell
287+
# the .bat/.cmd component somewhere in the string. It costs
288+
# nothing legitimate: no real claude.exe lives beneath a directory
289+
# named like a batch file.
290+
#
291+
# Within a component, Win32 finds the extension with a last-dot
292+
# scan over the WHOLE component, stream spec included --
293+
# "claude:evil.cmd" has extension ".cmd" -- while an NTFS stream
294+
# spec also opens its base file -- "claude.cmd:stream" opens
295+
# claude.cmd -- and a drive prefix ("C:claude.cmd") rides in the
296+
# same component. Splitting each component on ":" covers all of
297+
# these: colons cannot appear in real file names, so no legitimate
298+
# segment is over-refused. Trailing dots and spaces, which Windows
299+
# strips at path resolution, are stripped per segment (the same
300+
# normalization Rust's CVE-2024-24576 fix applies), and a bare
301+
# ".cmd" counts as a batch extension (as Win32 PathFindExtension
302+
# treats it, and pathlib does not).
303+
return any(
304+
segment.rstrip(". ").lower().endswith((".bat", ".cmd"))
305+
for component in cli_path.replace("\\", "/").split("/")
306+
for segment in component.split(":")
307+
)
308+
309+
@staticmethod
310+
def _reject_windows_batch_cli(cli_path: str) -> None:
311+
"""Refuse to execute a .bat/.cmd script as the CLI on Windows.
312+
313+
Windows has no shebang mechanism: CreateProcess runs batch scripts
314+
by silently rewriting the spawn into a 'cmd.exe /c' invocation, and
315+
cmd.exe re-parses the whole command line at execution time.
316+
subprocess.list2cmdline quotes arguments for the MSVCRT argv rules
317+
only, not for cmd.exe, so cmd.exe metacharacters inside an argument
318+
value -- for example a session title passed to --resume -- reach
319+
cmd.exe unescaped and can execute injected commands. Reliable
320+
escaping for cmd.exe does not exist (%VAR% expands even inside
321+
double quotes), so spawning a batch script with runtime-provided
322+
arguments cannot be made safe. Refusing is the same remediation
323+
Node.js shipped for this vulnerability class (CVE-2024-27980,
324+
"BatBadBut").
325+
326+
In practice this refuses npm's claude.cmd shim, which _find_cli
327+
returns only when no native claude.exe is discoverable (for
328+
example sdist installs on a machine with just the npm shim). The
329+
alternatives in the error message avoid cmd.exe entirely.
330+
"""
331+
if not SubprocessCLITransport._is_windows_batch_cli(cli_path):
332+
return
333+
raise CLIConnectionError(
334+
f"Refusing to execute batch script {cli_path!r}: Windows runs "
335+
".bat/.cmd files via cmd.exe, which can execute commands "
336+
"injected through CLI arguments, and no reliable escaping for "
337+
"cmd.exe exists. Use a native claude executable instead: "
338+
"install Claude Code natively "
339+
"(irm https://claude.ai/install.ps1 | iex), point "
340+
"ClaudeAgentOptions(cli_path=...) at a claude.exe, or install "
341+
"the claude-agent-sdk wheel for a platform that bundles "
342+
"claude.exe (e.g. Windows x64)."
343+
)
344+
345+
@staticmethod
346+
def _reject_windows_cmd_metacharacters(option_name: str, value: str) -> None:
347+
"""Defense in depth for Windows: reject cmd.exe metacharacters.
348+
349+
With batch-script spawning refused (_reject_windows_batch_cli),
350+
these characters are harmless: list2cmdline quotes correctly for
351+
native executables. They are rejected anyway so that resume /
352+
session_id values, which applications commonly take from external
353+
input, stay inert even if a cmd.exe hop is ever reintroduced
354+
between the SDK and the CLI. No format is imposed beyond this
355+
(resume values may be arbitrary session titles, not only UUIDs),
356+
and POSIX behavior is unchanged.
357+
"""
358+
if platform.system() != "Windows":
359+
return
360+
bad = sorted({c for c in value if c in _CMD_EXE_METACHARACTERS or c in "\r\n"})
361+
if bad:
362+
raise ValueError(
363+
f"{option_name} value {value!r} contains characters that "
364+
f"are unsafe to pass on a Windows command line: {bad!r}"
365+
)
366+
190367
def _build_settings_value(self) -> str | None:
191368
"""Build settings value, merging sandbox settings if provided.
192369
@@ -355,9 +532,13 @@ def _build_command(self) -> list[str]:
355532
# a separate CLI flag -- letting an untrusted value inject arbitrary
356533
# flags. The equals form always binds the value to the flag.
357534
if self._options.resume:
535+
self._reject_windows_cmd_metacharacters("resume", self._options.resume)
358536
cmd.append(f"--resume={self._options.resume}")
359537

360538
if self._options.session_id:
539+
self._reject_windows_cmd_metacharacters(
540+
"session_id", self._options.session_id
541+
)
361542
cmd.append(f"--session-id={self._options.session_id}")
362543

363544
# Handle settings and sandbox: merge sandbox into settings if both are provided
@@ -431,6 +612,14 @@ def _build_command(self) -> list[str]:
431612
if value is None:
432613
# Boolean flag without value
433614
cmd.append(f"--{flag}")
615+
elif str(value).startswith("-"):
616+
# In the two-token form, a dash-leading value is not bound
617+
# to its flag when the CLI declares the option with an
618+
# optional value -- it parses as a separate flag instead
619+
# (the same injection the --resume change above closes).
620+
# The equals form always binds. Mirrors the equivalent
621+
# guard in the TypeScript SDK.
622+
cmd.append(f"--{flag}={value}")
434623
else:
435624
# Flag with value
436625
cmd.extend([f"--{flag}", str(value)])
@@ -483,6 +672,10 @@ async def connect(self) -> None:
483672
if self._cli_path is None:
484673
self._cli_path = await anyio.to_thread.run_sync(self._find_cli)
485674

675+
# Validate the resolved CLI before anything is spawned with it --
676+
# this guards the version probe below as well as the main spawn.
677+
self._reject_windows_batch_cli(self._cli_path)
678+
486679
if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"):
487680
await self._check_claude_version()
488681

0 commit comments

Comments
 (0)