Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 186 additions & 10 deletions src/claude_agent_sdk/_internal/transport/subprocess_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
_DEFAULT_MAX_BUFFER_SIZE = 1024 * 1024 # 1MB buffer limit
MINIMUM_CLAUDE_CODE_VERSION = "2.0.0"

# cmd.exe metacharacters (plus the quote character cmd.exe uses to toggle
# its quoting state, and "!", which expands like "%" when delayed expansion
# is enabled). subprocess.list2cmdline quotes arguments for the MSVCRT argv
# rules only -- it adds quotes only around whitespace -- so in a
# whitespace-free argument these characters reach a cmd.exe command line
# verbatim. See _reject_windows_batch_cli / _reject_windows_cmd_metacharacters.
_CMD_EXE_METACHARACTERS = '&|<>^%!"'

# Track live CLI subprocesses so we can terminate them when the parent Python
# process exits. This mirrors the TypeScript SDK's parent-exit cleanup and
# prevents orphaned `claude` processes from leaking when callers crash or exit
Expand Down Expand Up @@ -147,22 +155,68 @@
return bundled_cli

# Fall back to system-wide search
shim: str | None = None
if cli := shutil.which("claude"):
return cli

locations = [
Path.home() / ".npm-global/bin/claude",
Path("/usr/local/bin/claude"),
Path.home() / ".local/bin/claude",
Path.home() / "node_modules/.bin/claude",
Path.home() / ".yarn/bin/claude",
Path.home() / ".claude/local/claude",
]
if not self._is_windows_batch_cli(cli):
return cli
# Windows resolved a .bat/.cmd shim (npm's claude.cmd), which
# connect() refuses to spawn. shutil.which walks PATH
# directory-major, and within a directory PATHEXT tries .CMD
# before .EXE, so an npm shim early on PATH shadows a native
# claude.exe installed in a later directory. Prefer any
# discoverable native executable, and keep the shim only as the
# last resort so a shim-only machine still gets the explanatory
# batch-script refusal from connect().
if exe := shutil.which("claude.exe"):

Check warning on line 170 in src/claude_agent_sdk/_internal/transport/subprocess_cli.py

View check run for this annotation

Claude / Claude Code Review

Comment states PATHEXT order backwards (.CMD before .EXE)

The rationale comment for the shim-preference branch states the PATHEXT semantics backwards: it claims "within a directory PATHEXT tries .CMD before .EXE", but the default PATHEXT (Windows' and CPython's `_WIN_DEFAULT_PATHEXT`) is `.COM;.EXE;.BAT;.CMD;...`, so `shutil.which` finds `claude.exe` BEFORE `claude.cmd` within a single directory — the shadowing this branch fixes comes solely from the directory-major PATH walk. No runtime impact; reword this comment and the duplicated claim in the `test
Comment thread
claude[bot] marked this conversation as resolved.
Outdated
return exe

Check warning on line 171 in src/claude_agent_sdk/_internal/transport/subprocess_cli.py

View check run for this annotation

Claude / Claude Code Review

Windows discovery trusts non-batch which() hits unvetted, bypassing the native-exe rescue (PATHEXT claude.exe.cmd match; extensionless script hit)

On Windows with Python 3.12+, `_find_cli()` trusts two `shutil.which()` results that the native-exe rescue from 53062ca never vets: the `shutil.which("claude.exe")` probe can return a PATHEXT-appended `claude.exe.cmd` unvetted (skipping the `~/.local/bin/claude.exe` fallback and the deliberate return-shim path, so `connect()` refuses naming the junk artifact), and a non-batch but non-spawnable `which("claude")` hit — e.g. an extensionless WSL/git-bash wrapper script earlier on PATH than a native
Comment thread
qing-ant marked this conversation as resolved.
shim = cli

if platform.system() == "Windows":
# Only the native installer's claude.exe. Path.exists() does
# no PATHEXT resolution, so the .exe name must be probed
# explicitly. The POSIX-shaped entries below are deliberately
# not probed on Windows: an extensionless match (a WSL / git-bash
# script artifact at ~/.local/bin/claude) would preempt the
# explanatory batch-script refusal with an opaque spawn
# failure, and a rooted-but-driveless "/usr/local/bin/claude"
# resolves against the current drive (C:\usr\local\bin\...),
# a location another local user can create -- a
# binary-planting probe.
locations = [Path.home() / ".local/bin/claude.exe"]
else:
locations = [
Path.home() / ".npm-global/bin/claude",
Path("/usr/local/bin/claude"),
Path.home() / ".local/bin/claude",
Path.home() / "node_modules/.bin/claude",
Path.home() / ".yarn/bin/claude",
Path.home() / ".claude/local/claude",
]

for path in locations:
if path.exists() and path.is_file():
return str(path)

if shim is not None:
# No native executable was discoverable anywhere: return the
# shim so connect() raises the batch-script refusal (with its
# remediation) rather than a bare not-found error.
return shim

if platform.system() == "Windows":
# npm's Windows install is a claude.cmd shim, which connect()
# refuses (_reject_windows_batch_cli), so do not recommend it.
raise CLINotFoundError(
"Claude Code not found. Install the native claude.exe with "
"(PowerShell):\n"
" irm https://claude.ai/install.ps1 | iex\n"
"\nOr install the claude-agent-sdk wheel for a platform that "
"bundles claude.exe (e.g. Windows x64), or provide the path to "
"a claude.exe via ClaudeAgentOptions:\n"
" ClaudeAgentOptions(cli_path='C:\\\\path\\\\to\\\\claude.exe')\n"
"\n(npm install -g @anthropic-ai/claude-code produces a claude.cmd "
"shim, which this SDK refuses to run on Windows.)"
)
Comment thread
qing-ant marked this conversation as resolved.
raise CLINotFoundError(
"Claude Code not found. Install with:\n"
" npm install -g @anthropic-ai/claude-code\n"
Expand All @@ -187,6 +241,112 @@

return None

@staticmethod
def _is_windows_batch_cli(cli_path: str) -> bool:
"""Whether cli_path names a .bat/.cmd batch script on Windows.

Always False off Windows. See _reject_windows_batch_cli for why
spawning such a script is refused.
"""
if platform.system() != "Windows":
return False
# Deliberately NOT pathlib: PureWindowsPath and PurePosixPath parse
# several of the cases below differently (".cmd" has suffix ".cmd"
# on POSIX but "" on Windows), and the tests run on POSIX CI while
# the code runs on Windows. Plain string logic behaves identically
# on both.
#
# Classify EVERY path component, not only the final one. Win32 opens
# a path after lexical normalization -- "." / ".." collapsing,
# repeated separators, and position-dependent trailing dot/space
# trimming (a middle ".. " or "..." stays a literal name while a
# final one trims to ".." or vanishes) -- and any attempt to
# re-derive the effective final component here is a race against
# that ruleset: get one rule slightly wrong and a spelling such as
# "claude.cmd\\...\\.." resolves to claude.cmd on Windows while the
# simulation lands on some other name. Refusing whenever ANY
# component carries a batch extension closes that whole class
# outright, because every normalization trick still has to spell
# the .bat/.cmd component somewhere in the string. It costs
# nothing legitimate: no real claude.exe lives beneath a directory
# named like a batch file.
#
# Within a component, Win32 finds the extension with a last-dot
# scan over the WHOLE component, stream spec included --
# "claude:evil.cmd" has extension ".cmd" -- while an NTFS stream
# spec also opens its base file -- "claude.cmd:stream" opens
# claude.cmd -- and a drive prefix ("C:claude.cmd") rides in the
# same component. Splitting each component on ":" covers all of
# these: colons cannot appear in real file names, so no legitimate
# segment is over-refused. Trailing dots and spaces, which Windows
# strips at path resolution, are stripped per segment (the same
# normalization Rust's CVE-2024-24576 fix applies), and a bare
# ".cmd" counts as a batch extension (as Win32 PathFindExtension
# treats it, and pathlib does not).
return any(
segment.rstrip(". ").lower().endswith((".bat", ".cmd"))
for component in cli_path.replace("\\", "/").split("/")
for segment in component.split(":")
)

@staticmethod
def _reject_windows_batch_cli(cli_path: str) -> None:
"""Refuse to execute a .bat/.cmd script as the CLI on Windows.

Windows has no shebang mechanism: CreateProcess runs batch scripts
by silently rewriting the spawn into a 'cmd.exe /c' invocation, and
cmd.exe re-parses the whole command line at execution time.
subprocess.list2cmdline quotes arguments for the MSVCRT argv rules
only, not for cmd.exe, so cmd.exe metacharacters inside an argument
value -- for example a session title passed to --resume -- reach
cmd.exe unescaped and can execute injected commands. Reliable
escaping for cmd.exe does not exist (%VAR% expands even inside
double quotes), so spawning a batch script with runtime-provided
arguments cannot be made safe. Refusing is the same remediation
Node.js shipped for this vulnerability class (CVE-2024-27980,
"BatBadBut").

In practice this refuses npm's claude.cmd shim, which _find_cli
returns only when no native claude.exe is discoverable (for
example sdist installs on a machine with just the npm shim). The
alternatives in the error message avoid cmd.exe entirely.
"""
if not SubprocessCLITransport._is_windows_batch_cli(cli_path):
return
raise CLIConnectionError(
f"Refusing to execute batch script {cli_path!r}: Windows runs "
".bat/.cmd files via cmd.exe, which can execute commands "
"injected through CLI arguments, and no reliable escaping for "
"cmd.exe exists. Use a native claude executable instead: "
"install Claude Code natively "
"(irm https://claude.ai/install.ps1 | iex), point "
"ClaudeAgentOptions(cli_path=...) at a claude.exe, or install "
"the claude-agent-sdk wheel for a platform that bundles "
"claude.exe (e.g. Windows x64)."
)
Comment thread
claude[bot] marked this conversation as resolved.

@staticmethod
def _reject_windows_cmd_metacharacters(option_name: str, value: str) -> None:
"""Defense in depth for Windows: reject cmd.exe metacharacters.

With batch-script spawning refused (_reject_windows_batch_cli),
these characters are harmless: list2cmdline quotes correctly for
native executables. They are rejected anyway so that resume /
session_id values, which applications commonly take from external
input, stay inert even if a cmd.exe hop is ever reintroduced
between the SDK and the CLI. No format is imposed beyond this
(resume values may be arbitrary session titles, not only UUIDs),
and POSIX behavior is unchanged.
"""
if platform.system() != "Windows":
return
bad = sorted({c for c in value if c in _CMD_EXE_METACHARACTERS or c in "\r\n"})
if bad:
raise ValueError(
f"{option_name} value {value!r} contains characters that "
f"are unsafe to pass on a Windows command line: {bad!r}"
)

def _build_settings_value(self) -> str | None:
"""Build settings value, merging sandbox settings if provided.

Expand Down Expand Up @@ -355,9 +515,13 @@
# a separate CLI flag -- letting an untrusted value inject arbitrary
# flags. The equals form always binds the value to the flag.
if self._options.resume:
self._reject_windows_cmd_metacharacters("resume", self._options.resume)
cmd.append(f"--resume={self._options.resume}")

if self._options.session_id:
self._reject_windows_cmd_metacharacters(
"session_id", self._options.session_id
)
cmd.append(f"--session-id={self._options.session_id}")

# Handle settings and sandbox: merge sandbox into settings if both are provided
Expand Down Expand Up @@ -431,6 +595,14 @@
if value is None:
# Boolean flag without value
cmd.append(f"--{flag}")
elif str(value).startswith("-"):
# In the two-token form, a dash-leading value is not bound
# to its flag when the CLI declares the option with an
# optional value -- it parses as a separate flag instead
# (the same injection the --resume change above closes).
# The equals form always binds. Mirrors the equivalent
# guard in the TypeScript SDK.
cmd.append(f"--{flag}={value}")
else:
# Flag with value
cmd.extend([f"--{flag}", str(value)])
Expand Down Expand Up @@ -483,6 +655,10 @@
if self._cli_path is None:
self._cli_path = await anyio.to_thread.run_sync(self._find_cli)

# Validate the resolved CLI before anything is spawned with it --
# this guards the version probe below as well as the main spawn.
self._reject_windows_batch_cli(self._cli_path)

Comment thread
qing-ant marked this conversation as resolved.
if not os.environ.get("CLAUDE_AGENT_SDK_SKIP_VERSION_CHECK"):
await self._check_claude_version()

Expand Down
Loading
Loading