From c7d1531d9d63a35430333039f8c975bba4ef0411 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:11:51 +0000 Subject: [PATCH 1/9] feat(api): add `mid-conversation-tool-changes-2026-07-01` beta --- .stats.yml | 4 ++-- src/anthropic/types/anthropic_beta_param.py | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 9f6a9116..836a7e7f 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 131 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-6d5c96a475b06ff067a4491fc2258181f0426af6c64bcfd4be5d167a8c0d9d16.yml +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-6f50d4734ed3d091e593f0add6090591d197529834e3b15ccc83a70c88c2fc6d.yml openapi_spec_hash: d2deb0fef6a15bf53cc6c53f07973a54 -config_hash: 447f76a00c2affe40a8b4c43129e17a3 +config_hash: d335041db7fb23dedd07cf9ff13a1565 diff --git a/src/anthropic/types/anthropic_beta_param.py b/src/anthropic/types/anthropic_beta_param.py index 5f88f3d2..c505e560 100644 --- a/src/anthropic/types/anthropic_beta_param.py +++ b/src/anthropic/types/anthropic_beta_param.py @@ -42,5 +42,6 @@ "fallback-credit-2026-06-01", "fallback-credit-2026-07-01", "agent-memory-2026-07-22", + "mid-conversation-tool-changes-2026-07-01", ], ] From b8d41762e264fe6721847f77daa337eab4905162 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:59:33 +0000 Subject: [PATCH 2/9] chore(docs): small updates to descriptions --- .stats.yml | 4 ++-- .../types/beta/beta_redacted_thinking_block.py | 12 ++++++++++++ .../beta/beta_redacted_thinking_block_param.py | 4 ++++ src/anthropic/types/beta/beta_signature_delta.py | 5 +++++ src/anthropic/types/beta/beta_thinking_block.py | 13 +++++++++++++ .../types/beta/beta_thinking_block_param.py | 8 ++++++++ src/anthropic/types/beta/beta_thinking_delta.py | 5 +++++ src/anthropic/types/redacted_thinking_block.py | 12 ++++++++++++ .../types/redacted_thinking_block_param.py | 4 ++++ src/anthropic/types/signature_delta.py | 5 +++++ src/anthropic/types/thinking_block.py | 13 +++++++++++++ src/anthropic/types/thinking_block_param.py | 8 ++++++++ src/anthropic/types/thinking_delta.py | 5 +++++ 13 files changed, 96 insertions(+), 2 deletions(-) diff --git a/.stats.yml b/.stats.yml index 836a7e7f..f75e1974 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 131 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-6f50d4734ed3d091e593f0add6090591d197529834e3b15ccc83a70c88c2fc6d.yml -openapi_spec_hash: d2deb0fef6a15bf53cc6c53f07973a54 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/anthropic/anthropic-c48ea225ef1d1685f19d56df1b647b69026a203fe6ad6cb913c832856d223418.yml +openapi_spec_hash: e9c540d82f06430a4b554f73df4ef914 config_hash: d335041db7fb23dedd07cf9ff13a1565 diff --git a/src/anthropic/types/beta/beta_redacted_thinking_block.py b/src/anthropic/types/beta/beta_redacted_thinking_block.py index b27bd933..623e32e5 100644 --- a/src/anthropic/types/beta/beta_redacted_thinking_block.py +++ b/src/anthropic/types/beta/beta_redacted_thinking_block.py @@ -9,5 +9,17 @@ class BetaRedactedThinkingBlock(BaseModel): data: str + """ + The contents of this redacted thinking block, returned when portions of the + model's thinking were safety-redacted. This field is opaque and encrypted, with + no readable content. + + Pass `redacted_thinking` blocks back to the API unchanged when continuing a + multi-turn conversation. + + See + [extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#redacted-thinking-blocks) + for details. + """ type: Literal["redacted_thinking"] diff --git a/src/anthropic/types/beta/beta_redacted_thinking_block_param.py b/src/anthropic/types/beta/beta_redacted_thinking_block_param.py index cc7d870f..8c226282 100644 --- a/src/anthropic/types/beta/beta_redacted_thinking_block_param.py +++ b/src/anthropic/types/beta/beta_redacted_thinking_block_param.py @@ -9,5 +9,9 @@ class BetaRedactedThinkingBlockParam(TypedDict, total=False): data: Required[str] + """ + The `data` value of this redacted thinking block, exactly as returned by the API + in a previous response. Opaque and encrypted; pass it back unchanged. + """ type: Required[Literal["redacted_thinking"]] diff --git a/src/anthropic/types/beta/beta_signature_delta.py b/src/anthropic/types/beta/beta_signature_delta.py index a3586826..5c5b7c27 100644 --- a/src/anthropic/types/beta/beta_signature_delta.py +++ b/src/anthropic/types/beta/beta_signature_delta.py @@ -9,5 +9,10 @@ class BetaSignatureDelta(BaseModel): signature: str + """ + The `signature` for this thinking block: an opaque value used to verify that the + block was generated by Claude when it is passed back to the API. Delivered in a + `signature_delta` event just before the block's `content_block_stop` event. + """ type: Literal["signature_delta"] diff --git a/src/anthropic/types/beta/beta_thinking_block.py b/src/anthropic/types/beta/beta_thinking_block.py index 9a9c1df8..afdb1d5f 100644 --- a/src/anthropic/types/beta/beta_thinking_block.py +++ b/src/anthropic/types/beta/beta_thinking_block.py @@ -9,7 +9,20 @@ class BetaThinkingBlock(BaseModel): signature: str + """ + A value used to verify that this thinking block was generated by Claude when it + is passed back to the API. + + This is an opaque field and should not be interpreted or parsed. When passing + thinking blocks back to the API (required when using tools with extended + thinking), pass them back exactly as received, with this field intact. + + See + [extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) + for details. + """ thinking: str + """The text of Claude's thinking process for this block.""" type: Literal["thinking"] diff --git a/src/anthropic/types/beta/beta_thinking_block_param.py b/src/anthropic/types/beta/beta_thinking_block_param.py index 5bd43180..20651987 100644 --- a/src/anthropic/types/beta/beta_thinking_block_param.py +++ b/src/anthropic/types/beta/beta_thinking_block_param.py @@ -9,7 +9,15 @@ class BetaThinkingBlockParam(TypedDict, total=False): signature: Required[str] + """ + The `signature` value of this thinking block, exactly as returned by the API in + a previous response. Used to verify that the block was generated by Claude. + + Thinking blocks must be passed back unmodified and in their original order; a + modified block results in a 400 `invalid_request_error`. + """ thinking: Required[str] + """The `thinking` text of this block as returned by the API.""" type: Required[Literal["thinking"]] diff --git a/src/anthropic/types/beta/beta_thinking_delta.py b/src/anthropic/types/beta/beta_thinking_delta.py index 7edba71e..1042ced4 100644 --- a/src/anthropic/types/beta/beta_thinking_delta.py +++ b/src/anthropic/types/beta/beta_thinking_delta.py @@ -22,5 +22,10 @@ class BetaThinkingDelta(BaseModel): """ thinking: str + """The incremental `thinking` text for this content block. + + Concatenate the `thinking` values of successive `thinking_delta` events to + assemble the block's full `thinking` value. + """ type: Literal["thinking_delta"] diff --git a/src/anthropic/types/redacted_thinking_block.py b/src/anthropic/types/redacted_thinking_block.py index 4850b335..5d76e981 100644 --- a/src/anthropic/types/redacted_thinking_block.py +++ b/src/anthropic/types/redacted_thinking_block.py @@ -9,5 +9,17 @@ class RedactedThinkingBlock(BaseModel): data: str + """ + The contents of this redacted thinking block, returned when portions of the + model's thinking were safety-redacted. This field is opaque and encrypted, with + no readable content. + + Pass `redacted_thinking` blocks back to the API unchanged when continuing a + multi-turn conversation. + + See + [extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking#redacted-thinking-blocks) + for details. + """ type: Literal["redacted_thinking"] diff --git a/src/anthropic/types/redacted_thinking_block_param.py b/src/anthropic/types/redacted_thinking_block_param.py index 0933188c..2c92714a 100644 --- a/src/anthropic/types/redacted_thinking_block_param.py +++ b/src/anthropic/types/redacted_thinking_block_param.py @@ -9,5 +9,9 @@ class RedactedThinkingBlockParam(TypedDict, total=False): data: Required[str] + """ + The `data` value of this redacted thinking block, exactly as returned by the API + in a previous response. Opaque and encrypted; pass it back unchanged. + """ type: Required[Literal["redacted_thinking"]] diff --git a/src/anthropic/types/signature_delta.py b/src/anthropic/types/signature_delta.py index 55d15189..ce8142db 100644 --- a/src/anthropic/types/signature_delta.py +++ b/src/anthropic/types/signature_delta.py @@ -9,5 +9,10 @@ class SignatureDelta(BaseModel): signature: str + """ + The `signature` for this thinking block: an opaque value used to verify that the + block was generated by Claude when it is passed back to the API. Delivered in a + `signature_delta` event just before the block's `content_block_stop` event. + """ type: Literal["signature_delta"] diff --git a/src/anthropic/types/thinking_block.py b/src/anthropic/types/thinking_block.py index 7f98b500..7cdc9b18 100644 --- a/src/anthropic/types/thinking_block.py +++ b/src/anthropic/types/thinking_block.py @@ -9,7 +9,20 @@ class ThinkingBlock(BaseModel): signature: str + """ + A value used to verify that this thinking block was generated by Claude when it + is passed back to the API. + + This is an opaque field and should not be interpreted or parsed. When passing + thinking blocks back to the API (required when using tools with extended + thinking), pass them back exactly as received, with this field intact. + + See + [extended thinking](https://platform.claude.com/docs/en/build-with-claude/extended-thinking) + for details. + """ thinking: str + """The text of Claude's thinking process for this block.""" type: Literal["thinking"] diff --git a/src/anthropic/types/thinking_block_param.py b/src/anthropic/types/thinking_block_param.py index d310c7f6..668d11f8 100644 --- a/src/anthropic/types/thinking_block_param.py +++ b/src/anthropic/types/thinking_block_param.py @@ -9,7 +9,15 @@ class ThinkingBlockParam(TypedDict, total=False): signature: Required[str] + """ + The `signature` value of this thinking block, exactly as returned by the API in + a previous response. Used to verify that the block was generated by Claude. + + Thinking blocks must be passed back unmodified and in their original order; a + modified block results in a 400 `invalid_request_error`. + """ thinking: Required[str] + """The `thinking` text of this block as returned by the API.""" type: Required[Literal["thinking"]] diff --git a/src/anthropic/types/thinking_delta.py b/src/anthropic/types/thinking_delta.py index fb79933c..b8f8d425 100644 --- a/src/anthropic/types/thinking_delta.py +++ b/src/anthropic/types/thinking_delta.py @@ -9,5 +9,10 @@ class ThinkingDelta(BaseModel): thinking: str + """The incremental `thinking` text for this content block. + + Concatenate the `thinking` values of successive `thinking_delta` events to + assemble the block's full `thinking` value. + """ type: Literal["thinking_delta"] From d52999b4f66002ffe8263756c59ab0e276c74264 Mon Sep 17 00:00:00 2001 From: Robert Craigie Date: Tue, 4 Aug 2026 11:03:36 -0700 Subject: [PATCH 3/9] chore: ensure all dependencies have major version constraints (#274) --- pyproject.toml | 13 +++--- scripts/lint | 3 ++ scripts/utils/check-dependency-caps.py | 57 ++++++++++++++++++++++++++ uv.lock | 18 ++++---- 4 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 scripts/utils/check-dependency-caps.py diff --git a/pyproject.toml b/pyproject.toml index 1d959624..30cee067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "typing-extensions>=4.14, <5", "anyio>=3.5.0, <5", "distro>=1.7.0, <2", - "sniffio", + "sniffio>=1, <2", "jiter>=0.4.0, <1", "docstring-parser>=0.15,<1", ] @@ -39,11 +39,11 @@ classifiers = [ ] [project.optional-dependencies] -aiohttp = ["aiohttp", "httpx_aiohttp>=0.1.9, <1"] +aiohttp = ["aiohttp>=3, <4", "httpx_aiohttp>=0.1.9, <1"] vertex = ["google-auth[requests] >=2, <3"] google_cloud = ["google-auth[requests] >=2, <3"] -aws = ["boto3 >= 1.28.57", "botocore >= 1.31.57"] -bedrock = ["boto3 >= 1.28.57", "botocore >= 1.31.57"] +aws = ["boto3 >=1.28.57, <2", "botocore >=1.31.57, <2"] +bedrock = ["boto3 >=1.28.57, <2", "botocore >=1.31.57, <2"] mcp = ["mcp>=1.0, <3; python_version >= '3.10'"] webhooks = ["standardwebhooks >= 1.0.1, < 2"] @@ -55,6 +55,7 @@ Repository = "https://github.com/anthropics/anthropic-sdk-python" [tool.uv] managed = true required-version = ">=0.9" +add-bounds = "major" # Ensure the lockfile always uses public PyPI, regardless of contributor's global uv config index = [{ url = "https://pypi.org/simple", default = true }] conflicts = [ @@ -86,6 +87,8 @@ dev = [ "inline-snapshot>=0.28.0", "griffe>=1", "http-snapshot[httpx]==0.1.9", + "packaging", + "tomli; python_version < '3.11'", ] pydantic-v1 = [ "pydantic>=1.9.0,<2", @@ -96,7 +99,7 @@ pydantic-v2 = [ ] [build-system] -requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme"] +requires = ["hatchling==1.26.3", "hatch-fancy-pypi-readme>=22.4, <26"] build-backend = "hatchling.build" [tool.hatch.build] diff --git a/scripts/lint b/scripts/lint index f5eb832b..0532dc7c 100755 --- a/scripts/lint +++ b/scripts/lint @@ -12,6 +12,9 @@ else uv run ruff check . fi +echo "==> Checking dependency version caps" +uv run python scripts/utils/check-dependency-caps.py + echo "==> Running pyright" uv run pyright diff --git a/scripts/utils/check-dependency-caps.py b/scripts/utils/check-dependency-caps.py new file mode 100644 index 00000000..1a29aa50 --- /dev/null +++ b/scripts/utils/check-dependency-caps.py @@ -0,0 +1,57 @@ +"""Ensure every production and build dependency in pyproject.toml has an upper version bound. + +Unbounded requirements (e.g. `httpx>=0.25.0`) mean a future major release of a +dependency can break users at install time without us noticing, so we require an +explicit cap (e.g. `httpx>=0.25.0,<1`). +""" + +from __future__ import annotations + +import sys +from typing import Any +from pathlib import Path + +from packaging.requirements import Requirement + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +# operators that constrain the maximum installable version +UPPER_BOUND_OPERATORS = {"<", "<=", "==", "===", "~="} + + +def has_upper_bound(requirement: Requirement) -> bool: + return any(spec.operator in UPPER_BOUND_OPERATORS for spec in requirement.specifier) + + +def main() -> None: + pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" + with open(pyproject_path, "rb") as f: + pyproject = tomllib.load(f) + + project: dict[str, Any] = pyproject["project"] + build_system: dict[str, Any] = pyproject.get("build-system", {}) + + requirement_tables: dict[str, list[str]] = {"project.dependencies": project.get("dependencies", [])} + optional_dependencies: dict[str, list[str]] = project.get("optional-dependencies", {}) + for extra, requirements in optional_dependencies.items(): + requirement_tables[f"project.optional-dependencies.{extra}"] = requirements + requirement_tables["build-system.requires"] = build_system.get("requires", []) + + missing_caps: list[str] = [] + for group, requirements in requirement_tables.items(): + for raw in requirements: + requirement = Requirement(raw) + if not has_upper_bound(requirement): + missing_caps.append(f" [{group}] {raw}") + + if missing_caps: + print("The following dependencies are missing an upper version bound (e.g. `>=2, <3`):") + print("\n".join(missing_caps)) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock index 2584b5e3..9164877a 100644 --- a/uv.lock +++ b/uv.lock @@ -194,7 +194,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.117.0" +version = "0.120.2" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -244,6 +244,7 @@ dev = [ { name = "importlib-metadata" }, { name = "inline-snapshot" }, { name = "mypy" }, + { name = "packaging" }, { name = "pyright" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, { name = "pytest", version = "9.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, @@ -255,6 +256,7 @@ dev = [ { name = "ruff" }, { name = "time-machine", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, { name = "time-machine", version = "3.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-9-anthropic-mcp' and extra == 'group-9-anthropic-pydantic-v1') or (extra == 'group-9-anthropic-pydantic-v1' and extra == 'group-9-anthropic-pydantic-v2')" }, ] pydantic-v1 = [ { name = "pydantic", version = "1.10.26", source = { registry = "https://pypi.org/simple" } }, @@ -265,12 +267,12 @@ pydantic-v2 = [ [package.metadata] requires-dist = [ - { name = "aiohttp", marker = "extra == 'aiohttp'" }, + { name = "aiohttp", marker = "extra == 'aiohttp'", specifier = ">=3,<4" }, { name = "anyio", specifier = ">=3.5.0,<5" }, - { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.28.57" }, - { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.28.57" }, - { name = "botocore", marker = "extra == 'aws'", specifier = ">=1.31.57" }, - { name = "botocore", marker = "extra == 'bedrock'", specifier = ">=1.31.57" }, + { name = "boto3", marker = "extra == 'aws'", specifier = ">=1.28.57,<2" }, + { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.28.57,<2" }, + { name = "botocore", marker = "extra == 'aws'", specifier = ">=1.31.57,<2" }, + { name = "botocore", marker = "extra == 'bedrock'", specifier = ">=1.31.57,<2" }, { name = "distro", specifier = ">=1.7.0,<2" }, { name = "docstring-parser", specifier = ">=0.15,<1" }, { name = "google-auth", extras = ["requests"], marker = "extra == 'google-cloud'", specifier = ">=2,<3" }, @@ -280,7 +282,7 @@ requires-dist = [ { name = "jiter", specifier = ">=0.4.0,<1" }, { name = "mcp", marker = "python_full_version >= '3.10' and extra == 'mcp'", specifier = ">=1.0,<3" }, { name = "pydantic", specifier = ">=1.9.0,<3" }, - { name = "sniffio" }, + { name = "sniffio", specifier = ">=1,<2" }, { name = "standardwebhooks", marker = "extra == 'webhooks'", specifier = ">=1.0.1,<2" }, { name = "typing-extensions", specifier = ">=4.14,<5" }, ] @@ -295,6 +297,7 @@ dev = [ { name = "importlib-metadata", specifier = ">=6.7.0" }, { name = "inline-snapshot", specifier = ">=0.28.0" }, { name = "mypy", specifier = "==1.17" }, + { name = "packaging" }, { name = "pyright", specifier = "==1.1.399" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -303,6 +306,7 @@ dev = [ { name = "rich", specifier = ">=13.7.1" }, { name = "ruff" }, { name = "time-machine" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] pydantic-v1 = [{ name = "pydantic", specifier = ">=1.9.0,<2" }] pydantic-v2 = [ From 578d606aa6162a514e784733a267fb6931083a47 Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 21:27:39 +0000 Subject: [PATCH 4/9] fix(lib): add a safe helper-executable resolver that never searches the working directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `anthropic.lib._executable` (`find_executable`, `require_executable`, `resolve_argv`, `run`, `ExecutableNotFoundError`, `WINDOWS_NATIVE_EXTENSIONS`): a hand-written PATH walk that only searches fully-absolute PATH entries (never the CWD — not implicitly on Windows, not via "."/empty/relative entries), only returns native `.exe`/`.com` images on Windows, honours explicit paths as given, and always hands the OS an absolute argv[0]. Mirrors Claude Code's safeExecutableResolver; the guarantees (G1–G5) and the shared test vectors (V1–V12, W1–W3, P1) match the sibling Anthropic SDKs. Motivated by HackerOne #3901184. --- src/anthropic/lib/_executable.py | 282 +++++++++++++++++++++++++++ tests/lib/test_executable.py | 319 +++++++++++++++++++++++++++++++ 2 files changed, 601 insertions(+) create mode 100644 src/anthropic/lib/_executable.py create mode 100644 tests/lib/test_executable.py diff --git a/src/anthropic/lib/_executable.py b/src/anthropic/lib/_executable.py new file mode 100644 index 00000000..d7056203 --- /dev/null +++ b/src/anthropic/lib/_executable.py @@ -0,0 +1,282 @@ +r"""Safe resolution and invocation of helper executables (``rg``, ``git``, …). + +**Safe executable resolution.** This SDK never launches a helper program by bare +name. Every program it spawns is either an absolute path it constructed itself, or a +bare name resolved by the SDK's own ``find_executable`` — which searches only the +fully-absolute entries of ``PATH``, never the current working directory (neither +implicitly, as Windows ``CreateProcess``/``shutil.which``/libuv do, nor via ``.``/empty/ +relative ``PATH`` entries), and on Windows returns only native executables +(``.exe``/``.com``). The absolute path it returns is what is handed to the OS. This holds +on every platform, so a file planted in a directory the user merely *works in* (a +cloned repository, an extracted archive) is never selected as a helper binary. + +Guarantees (the tests and the sibling SDKs cross-reference these IDs): + +- **G1 — absolute argv[0].** The program path handed to the OS process-creation + API is always absolute. Bare names never reach ``subprocess``/``anyio``: + resolve first (:func:`find_executable`/:func:`require_executable`/ + :func:`resolve_argv`), then spawn the absolute result (:func:`run` does both). +- **G2 — the working directory is never a search location.** Not implicitly + (as Windows does), not via ``.``, not via an empty ``PATH`` entry (which POSIX + ``execvp`` treats as the CWD), not via a relative entry (``bin``, + ``..\tools``), and on Windows not via drive-relative (``C:bin``) or + rooted-but-driveless (``\bin``) entries either. Only *fully absolute* entries + are searched: POSIX ``/…``; Windows ``X:\…``/``X:/…`` or UNC + ``\\server\share\…``. Surrounding double quotes on a Windows entry are + stripped before the check (``"C:\Program Files\Git\cmd"`` is legal in + ``PATH``). No ``~`` or ``%VAR%``/``$VAR`` expansion — the OS does not expand + these at spawn time either. An unset or empty ``PATH`` finds nothing (there is + deliberately no ``os.defpath`` fallback). +- **G3 — Windows: native images only.** For a bare name the candidates are the + name with each *allowed* extension appended — default + :data:`WINDOWS_NATIVE_EXTENSIONS` (``.exe``, ``.com``); never ``.bat``/``.cmd`` + (they run via ``cmd.exe /c`` and re-parse the command line) and never an + extensionless file. A name that already ends in an allowed extension is tried + as-is only; a name with any other extension (``tool.js``, or ``claude.cmd`` + under the default allow-list) has no candidates — pass the full file name + (``python3.12.exe``) when the program name itself contains a dot. The + allow-list is a parameter so a caller can *detect* (not run) a ``.cmd`` shim + to print a helpful error. On POSIX the name is tried as-is only. +- **G4 — explicit paths are the caller's decision.** If ``name`` contains a + path separator (``/``; on Windows also ``\`` or a drive prefix) no search + happens: the absolute, normalised form is returned iff it is an existing + regular file (and executable, on POSIX), else "not found". ``./tool`` + deliberately resolves against the CWD — the caller asked for exactly that. +- **G5 — one implementation, enforced.** ``shutil.which`` and + ``distutils.spawn.find_executable`` are banned repo-wide by ruff + (``[tool.ruff.lint.flake8-tidy-imports.banned-api]`` in ``pyproject.toml``), + and ``tests/lib/test_executable_policy.py`` fails if any process-spawning + call under ``src/anthropic`` passes a program name literal that is not an + absolute path. + +A match is a regular file (symlinks followed, so a *directory* named ``rg`` is +skipped) that is executable (``os.access(X_OK)``) on POSIX; on Windows +existence plus an allowed extension suffices. The result is +``normpath(join(entry, candidate))`` — not ``realpath``, so Homebrew/Scoop +symlink-farm spellings are preserved — and nothing is cached (``PATH`` and the +CWD can change; the walk is cheap). + +Why not ``shutil.which(name, path=sanitized)``: on Windows CPython prepends +``os.curdir`` to the search list *even when* ``path=`` is given — +unconditionally before 3.12, and from 3.12 unless +``NoDefaultCurrentDirectoryInExePath`` is set — so the walk is hand-written. + +This mirrors Claude Code's ``safeExecutableResolver``; keep it in sync with the +other Anthropic SDKs (claude-agent-sdk-python, anthropic-sdk-python, +anthropic-sdk-typescript, anthropic-sdk-go), which implement the same +guarantees against the same shared test vectors. +""" + +from __future__ import annotations + +import os +import re +import stat +import errno +import ntpath +import subprocess +from typing import Any, Final, cast +from collections.abc import Sequence + +__all__ = [ + "WINDOWS_NATIVE_EXTENSIONS", + "ExecutableNotFoundError", + "find_executable", + "require_executable", + "resolve_argv", + "run", +] + +WINDOWS_NATIVE_EXTENSIONS: Final = (".exe", ".com") +"""Default Windows candidate extensions (G3): native images only — never +``.bat``/``.cmd`` and never extensionless.""" + +# The public functions look this up at call time; the pure helpers below take an +# explicit ``windows`` flag instead so the Windows rules are testable on any host. +_IS_WINDOWS = os.name == "nt" + +# ``X:\…`` / ``X:/…`` — a drive letter, a colon and a separator. Drive-relative +# ``C:bin`` (resolved against the per-drive CWD) deliberately fails this. +_WINDOWS_DRIVE_ABSOLUTE_RE: Final = re.compile(r"^[A-Za-z]:[\\/]") +# ``\\server\share…`` — two separators, a server name, separator(s), a share. +_WINDOWS_UNC_RE: Final = re.compile(r"^[\\/]{2}[^\\/]+[\\/]+[^\\/]+") + +# Not program names on any platform; short-circuit rather than generate +# candidates like ``..exe`` for them. +_NEVER_PROGRAM_NAMES: Final = frozenset({"", ".", ".."}) + + +class ExecutableNotFoundError(FileNotFoundError): + """Raised by :func:`require_executable` (and so :func:`resolve_argv` / + :func:`run`) when a program cannot be resolved under G2–G4. + + Subclasses :class:`FileNotFoundError` so existing ``except OSError`` / + ``except FileNotFoundError`` handlers around spawn sites keep working. + """ + + name: str + """The program name (or explicit path) that could not be resolved.""" + + def __init__(self, name: str) -> None: + super().__init__( + errno.ENOENT, + "executable not found (only absolute PATH entries are searched; the current directory never is)", + name, + ) + self.name = name + + +def _strip_enclosing_quotes(entry: str) -> str: + """``"C:\\Program Files\\X"`` → ``C:\\Program Files\\X`` (legal in a Windows ``PATH``).""" + if len(entry) >= 2 and entry[0] == '"' and entry[-1] == '"': + return entry[1:-1] + return entry + + +def _is_searchable_path_entry(entry: str, *, windows: bool) -> bool: + """G2: is this ``PATH`` entry *fully absolute* under the given flavour's rules? + + Empty, ``.``, relative, and (Windows) drive-relative ``C:bin`` / + rooted-but-driveless ``\\bin`` entries are not — each of those would make the + current working directory a search location. + """ + if windows: + entry = _strip_enclosing_quotes(entry) + return bool(_WINDOWS_DRIVE_ABSOLUTE_RE.match(entry) or _WINDOWS_UNC_RE.match(entry)) + return entry.startswith("/") + + +def _candidate_names( + name: str, *, windows: bool, windows_extensions: Sequence[str] = WINDOWS_NATIVE_EXTENSIONS +) -> list[str]: + """G3: the file names to probe inside each searchable ``PATH`` entry for ``name``. + + POSIX: the name as-is. Windows: ``rg`` → ``[rg.exe, rg.com]``; ``rg.exe`` → + ``[rg.exe]``; ``claude.cmd`` / ``tool.js`` → ``[]`` under the default + allow-list (a caller may widen ``windows_extensions`` to *detect* a shim). + """ + if not windows: + return [name] + lowered = name.lower() + if any(lowered.endswith(ext.lower()) for ext in windows_extensions): + return [name] + if ntpath.splitext(name)[1]: + return [] + return [name + ext for ext in windows_extensions] + + +def _is_explicit_path(name: str, *, windows: bool) -> bool: + """G4: does ``name`` spell out a location (so no ``PATH`` search must happen)?""" + if "/" in name: + return True + return windows and ("\\" in name or bool(ntpath.splitdrive(name)[0])) + + +def _is_executable_file(path: str, *, windows: bool) -> bool: + """Regular file (symlinks followed — a directory named ``rg`` is not a match) + and, on POSIX, executable by us. On Windows existence + extension suffices.""" + try: + st = os.stat(path) + except (OSError, ValueError): # ValueError: embedded NUL and the like + return False + if not stat.S_ISREG(st.st_mode): + return False + return windows or os.access(path, os.X_OK) + + +def _find_executable(name: str, *, path: str, windows: bool, windows_extensions: Sequence[str]) -> str | None: + """:func:`find_executable` with the platform flavour made explicit. + + ``windows`` selects the *rules* (entry check, candidate names, separator set, + ``;`` vs ``:``); joining and probing always go through the host ``os.path`` / + ``os.stat``, which is what lets the Windows rules be exercised on a POSIX host. + """ + if name in _NEVER_PROGRAM_NAMES: + return None + if _is_explicit_path(name, windows=windows): + explicit = os.path.abspath(name) + return explicit if _is_executable_file(explicit, windows=windows) else None + candidates = _candidate_names(name, windows=windows, windows_extensions=windows_extensions) + if not candidates: + return None + for raw_entry in path.split(";" if windows else ":"): + entry = _strip_enclosing_quotes(raw_entry) if windows else raw_entry + if not _is_searchable_path_entry(entry, windows=windows): + continue + for candidate in candidates: + full = os.path.normpath(os.path.join(entry, candidate)) + if _is_executable_file(full, windows=windows): + return full + return None + + +def find_executable( + name: str, + *, + path: str | None = None, + windows_extensions: Sequence[str] = WINDOWS_NATIVE_EXTENSIONS, +) -> str | None: + """Resolve ``name`` to an absolute executable path, or ``None`` — never via the CWD. + + See the module docstring for the full contract. In short: only fully-absolute + ``PATH`` entries are searched (G2); on Windows only ``windows_extensions`` + candidates — native ``.exe``/``.com`` images by default — can match (G3); a + ``name`` containing a path separator is not searched for at all but checked + as given, relative to the CWD if it is relative (G4); and any hit is returned + as a normalised absolute path (G1). + + Args: + name: Bare program name (``"rg"``) or an explicit path (``"./rg"``, + ``"/usr/bin/rg"``). + path: Search path to use instead of ``os.environ["PATH"]``. An unset or + empty search path finds nothing. + windows_extensions: Windows-only candidate extensions, tried in order. + Widen it (e.g. add ``".cmd"``) only to *detect* a shim, not to run one. + """ + return _find_executable( + name, + path=os.environ.get("PATH", "") if path is None else path, + windows=_IS_WINDOWS, + windows_extensions=windows_extensions, + ) + + +def require_executable( + name: str, + *, + path: str | None = None, + windows_extensions: Sequence[str] = WINDOWS_NATIVE_EXTENSIONS, +) -> str: + """:func:`find_executable`, but raise :class:`ExecutableNotFoundError` instead of returning ``None``.""" + found = find_executable(name, path=path, windows_extensions=windows_extensions) + if found is None: + raise ExecutableNotFoundError(name) + return found + + +def resolve_argv( + argv: Sequence[str | os.PathLike[str]], + *, + path: str | None = None, + windows_extensions: Sequence[str] = WINDOWS_NATIVE_EXTENSIONS, +) -> list[str]: + """Return ``argv`` with ``argv[0]`` replaced by its :func:`require_executable` + resolution (G1); the remaining arguments are passed through untouched. + + Use this in front of async spawn APIs (``anyio.open_process`` / + ``anyio.run_process``), which are deliberately not wrapped here. + """ + args = [os.fspath(arg) for arg in argv] + if not args: + raise ValueError("argv must contain at least the program to run") + args[0] = require_executable(args[0], path=path, windows_extensions=windows_extensions) + return args + + +def run(argv: Sequence[str | os.PathLike[str]], /, **kwargs: Any) -> subprocess.CompletedProcess[Any]: + """``subprocess.run(resolve_argv(argv), **kwargs)`` — the blessed synchronous way + to run a helper program by name. Raises :class:`ExecutableNotFoundError` + (a :class:`FileNotFoundError`) when ``argv[0]`` cannot be resolved.""" + # ``cast``: with ``**kwargs`` pyright cannot pick a single ``subprocess.run`` + # overload (they differ only in the ``CompletedProcess`` type parameter). + return cast("subprocess.CompletedProcess[Any]", subprocess.run(resolve_argv(argv), **kwargs)) diff --git a/tests/lib/test_executable.py b/tests/lib/test_executable.py new file mode 100644 index 00000000..56fa369c --- /dev/null +++ b/tests/lib/test_executable.py @@ -0,0 +1,319 @@ +"""Tests for :mod:`anthropic.lib._executable` — safe helper-executable resolution. + +The vector IDs (V1–V12, W1–W3, P1) and guarantee IDs (G1–G5) are shared with the +sibling Anthropic SDKs (claude-agent-sdk-python, anthropic-sdk-typescript, +anthropic-sdk-go); keep them in sync when changing behaviour. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from dataclasses import dataclass + +import pytest + +from anthropic.lib._executable import ( + WINDOWS_NATIVE_EXTENSIONS, + ExecutableNotFoundError, + run, + resolve_argv, + find_executable, + _candidate_names, + _find_executable, + require_executable, + _is_searchable_path_entry, +) + +posix_only = pytest.mark.skipif(sys.platform == "win32", reason="exercises the POSIX flavour on the real filesystem") + +SEP = os.pathsep + + +def _make_executable(path: Path, body: str = "#!/bin/sh\nexit 0\n") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + path.chmod(0o755) + return path + + +@dataclass +class Layout: + """``bin/`` holds the real tools; ``plant/`` is the CWD an attacker controls.""" + + bin: Path + plant: Path + dirs: Path + + +@pytest.fixture +def layout(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Layout: + bin_dir = tmp_path / "bin" + _make_executable(bin_dir / "tool") + _make_executable(bin_dir / "tool.exe") + plant = tmp_path / "plant" + _make_executable(plant / "tool") + _make_executable(plant / "tool.exe") + _make_executable(plant / "rel" / "sub" / "tool") + # V7: a *directory* named like the tool inside a searchable entry. + dirs = tmp_path / "dirs" + (dirs / "tool").mkdir(parents=True) + monkeypatch.chdir(plant) + # Nothing may leak in from the host PATH. + monkeypatch.setenv("PATH", "") + return Layout(bin=bin_dir, plant=plant, dirs=dirs) + + +# --------------------------------------------------------------------------- # +# V1–V12: shared vectors, host flavour against the real filesystem # +# --------------------------------------------------------------------------- # + + +@posix_only +def test_v1_absolute_entry_is_searched(layout: Layout) -> None: + assert find_executable("tool", path=str(layout.bin)) == str(layout.bin / "tool") + + +@posix_only +def test_v2_leading_empty_entry_is_not_the_cwd(layout: Layout) -> None: + found = find_executable("tool", path="" + SEP + str(layout.bin)) + assert found == str(layout.bin / "tool") + assert found != str(layout.plant / "tool") + + +@posix_only +def test_v3_dot_entry_is_not_the_cwd(layout: Layout) -> None: + assert find_executable("tool", path="." + SEP + str(layout.bin)) == str(layout.bin / "tool") + + +@posix_only +def test_v4_relative_entry_is_skipped(layout: Layout) -> None: + assert (layout.plant / "rel" / "sub" / "tool").is_file() + assert find_executable("tool", path="rel/sub" + SEP + str(layout.bin)) == str(layout.bin / "tool") + + +@posix_only +def test_v5_only_cwdish_entries_find_nothing(layout: Layout) -> None: + # Every entry below points (directly or relatively) at a real ``tool`` — via the CWD. + assert (layout.plant / "tool").is_file() and (layout.plant / "rel" / "sub" / "tool").is_file() + assert find_executable("tool", path=SEP.join([".", "", "rel/sub", "./rel/sub", "..", "../plant"])) is None + + +@posix_only +def test_v6_non_executable_file_is_not_a_match(layout: Layout) -> None: + (layout.bin / "tool").chmod(0o644) + assert find_executable("tool", path=str(layout.bin)) is None + + +@posix_only +def test_v7_directory_named_like_the_tool_is_skipped(layout: Layout) -> None: + assert (layout.dirs / "tool").is_dir() + assert find_executable("tool", path=str(layout.dirs) + SEP + str(layout.bin)) == str(layout.bin / "tool") + + +@posix_only +def test_v8_explicit_relative_path_is_honoured(layout: Layout) -> None: + """G4: ``./tool`` is the caller's explicit decision and resolves against the CWD.""" + found = find_executable("./tool", path=str(layout.bin)) + assert found is not None and os.path.isabs(found) + assert found == str(layout.plant / "tool") + + +@posix_only +def test_v9_absolute_name_is_returned_as_is_or_not_found(layout: Layout) -> None: + assert find_executable(str(layout.bin / "tool"), path="") == str(layout.bin / "tool") + assert find_executable(str(layout.bin / "missing"), path=str(layout.bin)) is None + # An absolute path to something that is not a regular executable file is not found either. + assert find_executable(str(layout.dirs / "tool"), path=str(layout.bin)) is None + + +@posix_only +def test_v10_result_is_absolute_and_normalised(layout: Layout) -> None: + found = find_executable("tool", path=str(layout.bin) + "/./") + assert found is not None + assert os.path.isabs(found) + assert found == os.path.normpath(found) == str(layout.bin / "tool") + + +@pytest.mark.parametrize("name", ["", ".", ".."]) +def test_v11_degenerate_names_are_not_found(layout: Layout, name: str) -> None: + assert find_executable(name, path=str(layout.bin)) is None + + +@posix_only +def test_v12_unset_or_empty_path_finds_nothing(layout: Layout, monkeypatch: pytest.MonkeyPatch) -> None: + """No ``os.defpath`` fallback and never the CWD (which holds a ``tool``).""" + monkeypatch.setenv("PATH", "") + assert find_executable("tool") is None + monkeypatch.delenv("PATH", raising=False) + assert find_executable("tool") is None + assert find_executable("tool", path="") is None + # Sanity: the environment PATH is what is consulted by default. + monkeypatch.setenv("PATH", str(layout.bin)) + assert find_executable("tool") == str(layout.bin / "tool") + + +# --------------------------------------------------------------------------- # +# W1–W3: Windows flavour, exercised on any host via the pure helpers # +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + ("entry", "searchable"), + [ + ("C:\\bin", True), + ("c:/bin", True), + ("\\\\srv\\share\\bin", True), + ("//srv/share/bin", True), + ('"C:\\Program Files\\X"', True), + ("\\bin", False), + ("/bin", False), + ("C:bin", False), + ("C:", False), + (".", False), + ("", False), + ('""', False), + ("bin", False), + ("..\\x", False), + ("%SystemRoot%\\system32", False), + ("~\\bin", False), + ], +) +def test_w1_windows_entry_check(entry: str, searchable: bool) -> None: + assert _is_searchable_path_entry(entry, windows=True) is searchable + + +@pytest.mark.parametrize( + ("entry", "searchable"), + [ + ("/usr/bin", True), + ("/", True), + ("bin", False), + (".", False), + ("", False), + ("~/bin", False), + ("$HOME/bin", False), + # A Windows-style absolute entry is *relative* on POSIX. + ("C:\\bin", False), + ], +) +def test_w1_posix_entry_check(entry: str, searchable: bool) -> None: + assert _is_searchable_path_entry(entry, windows=False) is searchable + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("rg", ["rg.exe", "rg.com"]), + ("rg.exe", ["rg.exe"]), + ("RG.EXE", ["RG.EXE"]), + ("claude.cmd", []), + ("tool.js", []), + ], +) +def test_w2_windows_candidate_names_default_allow_list(name: str, expected: list[str]) -> None: + assert _candidate_names(name, windows=True) == expected + + +def test_w2_windows_candidate_names_wider_allow_list_detects_shims() -> None: + assert _candidate_names("claude.cmd", windows=True, windows_extensions=(".exe", ".com", ".cmd", ".bat")) == [ + "claude.cmd" + ] + assert _candidate_names("claude", windows=True, windows_extensions=(".cmd", ".bat")) == ["claude.cmd", "claude.bat"] + assert _candidate_names("tool.js", windows=True, windows_extensions=(".exe", ".com", ".cmd", ".bat")) == [] + + +def test_w2_posix_candidate_is_the_name_itself() -> None: + assert _candidate_names("rg", windows=False) == ["rg"] + assert _candidate_names("tool.js", windows=False) == ["tool.js"] + + +def _unc_spelling(p: Path) -> str: + """Spell an absolute POSIX path so it passes the *Windows* entry check. + + ``//tmp/x/bin`` matches the UNC rule, and a POSIX kernel treats the leading + ``//`` as ``/`` — so the Windows flavour can be run end-to-end against real + files on a POSIX host. + """ + return "/" + str(p) + + +def _find_windows_flavour(name: str, path: str) -> str | None: + return _find_executable(name, path=path, windows=True, windows_extensions=WINDOWS_NATIVE_EXTENSIONS) + + +@posix_only +def test_w3_windows_flavour_end_to_end_never_uses_cwd(layout: Layout) -> None: + unc_bin = _unc_spelling(layout.bin) + assert _is_searchable_path_entry(unc_bin, windows=True) + + # ``.`` first and the CWD (= plant) holds ``tool.exe``: the PATH one wins. + found = _find_windows_flavour("tool", ".;" + unc_bin) + assert found is not None + assert Path(found).resolve() == (layout.bin / "tool.exe").resolve() + assert Path(found).resolve() != (layout.plant / "tool.exe").resolve() + # Native images only: the extensionless ``bin/tool`` is never a candidate. + assert found.endswith("tool.exe") + # An explicit allowed extension is tried as-is. + found_exe = _find_windows_flavour("tool.exe", unc_bin) + assert found_exe is not None and Path(found_exe).resolve() == (layout.bin / "tool.exe").resolve() + # Only CWD-ish / non-absolute entries — including a POSIX-absolute one, which + # is rooted-but-driveless under the Windows rules: nothing. + assert _find_windows_flavour("tool", ".;;rel\\sub;" + str(layout.bin)) is None + # ``.cmd`` shims are never run, but can be *detected* with a wider allow-list. + _make_executable(layout.bin / "shim.cmd") + assert _find_windows_flavour("shim", unc_bin) is None + detected = _find_executable("shim", path=unc_bin, windows=True, windows_extensions=(".cmd", ".bat")) + assert detected is not None and Path(detected).resolve() == (layout.bin / "shim.cmd").resolve() + + +# --------------------------------------------------------------------------- # +# P1: POSIX flavour — backslash is not a separator # +# --------------------------------------------------------------------------- # + + +@posix_only +def test_p1_backslash_is_a_plain_character_on_posix(layout: Layout) -> None: + weird = _make_executable(layout.bin / "a\\b") + # Searched for as a bare name (not treated as the explicit path ``a/b``)… + assert _find_executable("a\\b", path=str(layout.bin), windows=False, windows_extensions=()) == str(weird) + # …whereas the Windows flavour treats it as an explicit, CWD-relative path. + assert _find_executable("a\\b", path=str(layout.bin), windows=True, windows_extensions=("",)) is None + + +# --------------------------------------------------------------------------- # +# require_executable / resolve_argv / run # +# --------------------------------------------------------------------------- # + + +@posix_only +def test_require_executable_raises_a_file_not_found_error(layout: Layout) -> None: + with pytest.raises(ExecutableNotFoundError) as exc_info: + require_executable("tool", path="." + SEP + "") + assert exc_info.value.name == "tool" + assert isinstance(exc_info.value, FileNotFoundError) + assert require_executable("tool", path=str(layout.bin)) == str(layout.bin / "tool") + + +@posix_only +def test_resolve_argv_replaces_only_argv0(layout: Layout) -> None: + argv = resolve_argv([Path("tool"), "--flag", Path("rel/ative")], path=str(layout.bin)) + assert argv == [str(layout.bin / "tool"), "--flag", "rel/ative"] + with pytest.raises(ValueError): + resolve_argv([], path=str(layout.bin)) + with pytest.raises(ExecutableNotFoundError): + resolve_argv(["tool"], path=".") + + +@posix_only +def test_run_spawns_the_resolved_absolute_path(layout: Layout, monkeypatch: pytest.MonkeyPatch) -> None: + _make_executable(layout.bin / "hello", '#!/bin/sh\necho "from-bin $0"\n') + _make_executable(layout.plant / "hello", "#!/bin/sh\necho from-plant\n") + monkeypatch.setenv("PATH", "." + SEP + "" + SEP + str(layout.bin)) + result = run(["hello"], capture_output=True, text=True, check=True) + # ``$0`` is the path the OS was handed: the absolute PATH hit, not ``./hello``. + assert result.stdout == f"from-bin {layout.bin / 'hello'}\n" + monkeypatch.setenv("PATH", ".") + with pytest.raises(ExecutableNotFoundError): + run(["hello"], capture_output=True) From 0d81a3e03ebcc04dbdf84d613ecfefbe0cff99f9 Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 21:37:14 +0000 Subject: [PATCH 5/9] fix(agent_toolset): resolve ripgrep via the safe resolver instead of shutil.which MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grep tool located ripgrep with `shutil.which("rg")`, which on Windows searches the current directory first (and on every platform honours "."/ empty/relative PATH entries), so an `rg`/`rg.exe` planted in the workspace the runner was launched from could be executed with the host environment — while the file tools are documented as safe without a sandbox (HackerOne #3901184). It now uses `find_executable("rg")` (absolute PATH entries only, native images only on Windows) and spawns the absolute result; the pure-Python fallback still applies when no ripgrep is found. Adds regression tests: a marker-writing `rg` planted in the CWD with ".::rel" on PATH is never run (fallback used), and a real `rg` on an absolute PATH entry is used and still beats the planted one. --- src/anthropic/lib/tools/agent_toolset.py | 12 ++++- tests/lib/tools/test_agent_toolset.py | 67 +++++++++++++++++++++++- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/anthropic/lib/tools/agent_toolset.py b/src/anthropic/lib/tools/agent_toolset.py index 9efbc050..825a5802 100644 --- a/src/anthropic/lib/tools/agent_toolset.py +++ b/src/anthropic/lib/tools/agent_toolset.py @@ -28,7 +28,10 @@ Trust model: the file tools confine to ``workdir`` (symlink-aware) and are safe without a sandbox; ``bash`` is unrestricted and should run inside one. See -:class:`AgentToolContext`. +:class:`AgentToolContext`. Helper binaries (``rg`` for ``grep``) are resolved +via :func:`anthropic.lib._executable.find_executable` — absolute ``PATH`` +entries only, never the working directory — so a look-alike planted in the +tree being searched is never executed. """ from __future__ import annotations @@ -64,6 +67,7 @@ BetaManagedAgentsAgentToolset20260401ReadInput, BetaManagedAgentsAgentToolset20260401WriteInput, ) +from .._executable import find_executable from ._beta_functions import ( ToolError, BetaContent, @@ -739,7 +743,11 @@ async def grep(pattern: str, path: Optional[str] = None) -> str: except ValueError as e: raise ToolError(f"grep: {e}") from e - if rg := shutil.which("rg"): + # ``find_executable`` (not ``shutil.which``): absolute PATH entries only, + # never the CWD, native ``.exe`` only on Windows — so an ``rg`` planted + # in the directory the runner was launched from is never picked up. It + # returns an absolute path (or ``None`` → pure-Python fallback below). + if rg := find_executable("rg"): # ``check=False`` because ripgrep exits 1 on "no matches", which # isn't an error for us — we surface it as a friendly string. result = await anyio.run_process( diff --git a/tests/lib/tools/test_agent_toolset.py b/tests/lib/tools/test_agent_toolset.py index 8ca5889d..56004c7f 100644 --- a/tests/lib/tools/test_agent_toolset.py +++ b/tests/lib/tools/test_agent_toolset.py @@ -450,13 +450,76 @@ async def test_grep_single_file_path(tmp_path: Path, monkeypatch: pytest.MonkeyP """Fallback walker must handle a file path, not just directories.""" (tmp_path / "x.txt").write_text("alpha\nbeta\n") env = AgentToolContext(workdir=str(tmp_path)) - monkeypatch.setattr("shutil.which", lambda _name: None) # type: ignore[arg-type] + # An empty PATH resolves no ``rg`` (there is no fallback search location), + # forcing the pure-Python walker. + monkeypatch.setenv("PATH", "") res = await beta_grep_tool(env).call({"pattern": "beta", "path": "x.txt"}) assert isinstance(res, str) assert "beta" in res assert res != "no matches" +def _plant_fake_rg(directory: Path, marker: Path) -> None: + """Drop executable ``rg`` / ``rg.exe`` scripts that only record they ran.""" + directory.mkdir(parents=True, exist_ok=True) + for name in ("rg", "rg.exe"): + script = directory / name + script.write_text(f"#!/bin/sh\necho planted >> '{marker}'\n") + script.chmod(0o755) + + +@needs_pydantic_v2 +@pytest.mark.skipif(sys.platform == "win32", reason="uses #!/bin/sh stand-ins for rg") +async def test_grep_never_runs_rg_planted_in_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Regression (HackerOne #3901184): an ``rg`` sitting in the directory the + runner was launched from must never be executed — not via an implicit CWD + search, nor via ``.``/empty/relative PATH entries. With no real ``rg`` on an + absolute PATH entry, grep falls back to the pure-Python walker.""" + marker = tmp_path / "marker" + plant = tmp_path / "plant" + _plant_fake_rg(plant, marker) + _plant_fake_rg(plant / "rel", marker) + monkeypatch.chdir(plant) + monkeypatch.setenv("PATH", os.pathsep.join([".", "", "rel", str(plant.relative_to(tmp_path))])) + + work = tmp_path / "work" + work.mkdir() + (work / "x.txt").write_text("alpha\nneedle\n") + env = AgentToolContext(workdir=str(work)) + res = await beta_grep_tool(env).call({"pattern": "needle"}) + + assert isinstance(res, str) + assert "x.txt:2:needle" in res + assert not marker.exists(), "planted rg was executed" + + +@needs_pydantic_v2 +@pytest.mark.skipif(sys.platform == "win32", reason="uses #!/bin/sh stand-ins for rg") +async def test_grep_uses_rg_from_an_absolute_path_entry(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The counterpart: an ``rg`` on a real (absolute) PATH entry *is* used — + and still wins over one planted in the CWD listed earlier via ``.``.""" + marker = tmp_path / "marker" + plant = tmp_path / "plant" + _plant_fake_rg(plant, marker) + monkeypatch.chdir(plant) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + real = bin_dir / "rg" + real.write_text('#!/bin/sh\necho "REAL_RG:$0:$*"\n') + real.chmod(0o755) + monkeypatch.setenv("PATH", os.pathsep.join([".", "", str(bin_dir)])) + + work = tmp_path / "work" + work.mkdir() + env = AgentToolContext(workdir=str(work)) + res = await beta_grep_tool(env).call({"pattern": "needle"}) + + assert isinstance(res, str) + # Spawned by absolute path (``$0``), with the tool's usual argv. + assert res.startswith(f"REAL_RG:{real}:-n --no-heading -e needle -- {work}") + assert not marker.exists(), "planted rg was executed" + + @pytest.mark.skipif(sys.platform == "win32", reason="bash session requires /bin/bash") async def test_bash_session_persistence(tmp_path: Path) -> None: s = await BashSession.start(str(tmp_path)) @@ -595,6 +658,6 @@ async def test_grep_skips_symlinked_files(tmp_path: Path, monkeypatch: pytest.Mo (work / "leak").symlink_to(secret) (work / "real.txt").write_text("ordinary\n") env = AgentToolContext(workdir=str(work)) - monkeypatch.setattr("shutil.which", lambda _name: None) # type: ignore[arg-type] + monkeypatch.setenv("PATH", "") # no rg → pure-Python fallback walker res = await beta_grep_tool(env).call({"pattern": "TOPSECRET"}) assert res == "no matches" From a998037de3ac3d596392947d5847a116cb5637e0 Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 21:44:48 +0000 Subject: [PATCH 6/9] chore(lint): ban ambient executable search and bare-name spawns outside lib/_executable - ruff `flake8-tidy-imports.banned-api`: `shutil.which` and `distutils.spawn.find_executable` now fail lint repo-wide with a pointer to `anthropic.lib._executable.find_executable`. - `tests/lib/test_executable_policy.py`: AST scan of the installed `anthropic` package that fails if any subprocess / anyio / asyncio / os.exec*/spawn*/system/popen call names its program with a string literal that is not an absolute path (import aliases resolved). Proven non-vacuous against an inline snippet covering each spelling, and shown to accept absolute literals (`/bin/bash`) and runtime-resolved values. --- pyproject.toml | 2 + tests/lib/test_executable_policy.py | 230 ++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 tests/lib/test_executable_policy.py diff --git a/pyproject.toml b/pyproject.toml index 30cee067..17114e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -273,6 +273,8 @@ extend-safe-fixes = ["FA102"] [tool.ruff.lint.flake8-tidy-imports.banned-api] "functools.lru_cache".msg = "This function does not retain type information for the wrapped function's arguments; The `lru_cache` function from `_utils` should be used instead" +"shutil.which".msg = "Searches the current directory on Windows (and via '.'/empty/relative PATH entries everywhere); use `find_executable` from `anthropic.lib._executable` instead" +"distutils.spawn.find_executable".msg = "Searches the current directory; use `find_executable` from `anthropic.lib._executable` instead" [tool.ruff.lint.isort] length-sort = true diff --git a/tests/lib/test_executable_policy.py b/tests/lib/test_executable_policy.py new file mode 100644 index 00000000..65942392 --- /dev/null +++ b/tests/lib/test_executable_policy.py @@ -0,0 +1,230 @@ +"""G5 enforcement: no process-spawning call under ``src/anthropic`` may pass a +program *name* — only an absolute path literal, or a value computed at runtime +(which must come from :mod:`anthropic.lib._executable`; ``shutil.which`` and +``distutils.spawn.find_executable`` are banned separately by ruff's +``flake8-tidy-imports.banned-api`` table in ``pyproject.toml``). + +A bare name handed to ``subprocess``/``anyio``/``os.exec*`` is resolved by the +OS or the C runtime, which on Windows searches the current working directory +first — the bug class behind HackerOne #3901184. See ``anthropic.lib._executable``. +""" + +from __future__ import annotations + +import re +import ast +from typing import Iterator, NamedTuple +from pathlib import Path +from typing_extensions import override + +import anthropic + +# (module, function) → index of the positional argument that names the program. +_SPAWN_APIS: dict[tuple[str, str], int] = { + ("subprocess", "run"): 0, + ("subprocess", "Popen"): 0, + ("subprocess", "call"): 0, + ("subprocess", "check_call"): 0, + ("subprocess", "check_output"): 0, + ("subprocess", "getoutput"): 0, + ("subprocess", "getstatusoutput"): 0, + ("anyio", "open_process"): 0, + ("anyio", "run_process"): 0, + ("asyncio", "create_subprocess_exec"): 0, + ("asyncio", "create_subprocess_shell"): 0, + ("os", "system"): 0, + ("os", "popen"): 0, + ("os", "startfile"): 0, + ("os", "posix_spawn"): 0, + ("os", "posix_spawnp"): 0, + **{("os", name): 0 for name in ("execl", "execle", "execlp", "execlpe", "execv", "execve", "execvp", "execvpe")}, + # os.spawn*(mode, file, ...): the program is the *second* argument. + **{ + ("os", name): 1 + for name in ("spawnl", "spawnle", "spawnlp", "spawnlpe", "spawnv", "spawnve", "spawnvp", "spawnvpe") + }, +} +# Keyword spellings of the program argument across those APIs. +_PROGRAM_KEYWORDS = ("args", "command", "cmd", "program", "file", "path", "executable") + +_WINDOWS_ABSOLUTE_RE = re.compile(r"^(?:[A-Za-z]:[\\/]|[\\/]{2}[^\\/]+[\\/]+[^\\/]+)") + +SRC_ROOT = Path(anthropic.__file__).resolve().parent + + +class Violation(NamedTuple): + path: str + line: int + call: str + program: str + + @override + def __str__(self) -> str: + return f"{self.path}:{self.line}: {self.call}(...) launches non-absolute program {self.program!r}" + + +def _is_absolute_program(program: str) -> bool: + return program.startswith("/") or bool(_WINDOWS_ABSOLUTE_RE.match(program)) + + +def _import_aliases(tree: ast.AST) -> tuple[dict[str, str], dict[str, tuple[str, str]]]: + """Map local names to what they import: ``import subprocess as sp`` → + ``{"sp": "subprocess"}``; ``from anyio import run_process as rp`` → + ``{"rp": ("anyio", "run_process")}``.""" + modules: dict[str, str] = {} + names: dict[str, tuple[str, str]] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules[alias.asname or alias.name.partition(".")[0]] = ( + alias.name if alias.asname else alias.name.partition(".")[0] + ) + elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0: + for alias in node.names: + names[alias.asname or alias.name] = (node.module, alias.name) + # ``from os import path`` style sub-module imports double as modules. + modules.setdefault(alias.asname or alias.name, f"{node.module}.{alias.name}") + return modules, names + + +def _resolve_call(func: ast.expr, modules: dict[str, str], names: dict[str, tuple[str, str]]) -> tuple[str, str] | None: + if isinstance(func, ast.Name): + return names.get(func.id) + if isinstance(func, ast.Attribute): + parts: list[str] = [func.attr] + value = func.value + while isinstance(value, ast.Attribute): + parts.append(value.attr) + value = value.value + if not isinstance(value, ast.Name): + return None + module = modules.get(value.id) + if module is None: + return None + dotted = ".".join([module, *reversed(parts[1:])]) + return dotted, parts[0] + return None + + +def _program_literal(node: ast.expr) -> str | None: + """The program named by a spawn argument iff it is spelled as a literal: + ``"rg -n"`` → ``"rg"``; ``["rg", "-n"]`` → ``"rg"``; ``[rg, "-n"]`` → None.""" + if isinstance(node, (ast.List, ast.Tuple)): + if not node.elts: + return None + node = node.elts[0] + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + if isinstance(node, ast.Constant) and isinstance(node.value, str): + # Shell-style string: the program is the first word. + return node.value.split(maxsplit=1)[0] if node.value.strip() else "" + return None + + +def scan_source(source: str, path: str = "") -> Iterator[Violation]: + tree = ast.parse(source, filename=path) + modules, names = _import_aliases(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + resolved = _resolve_call(node.func, modules, names) + if resolved is None or resolved not in _SPAWN_APIS: + continue + index = _SPAWN_APIS[resolved] + arg: ast.expr | None = None + if len(node.args) > index and not any(isinstance(a, ast.Starred) for a in node.args[: index + 1]): + arg = node.args[index] + else: + arg = next((kw.value for kw in node.keywords if kw.arg in _PROGRAM_KEYWORDS), None) + if arg is None: + continue + program = _program_literal(arg) + if program is not None and not _is_absolute_program(program): + yield Violation(path, node.lineno, ".".join(resolved), program) + + +def test_no_bare_program_names_reach_a_spawn_api() -> None: + sources = sorted(SRC_ROOT.rglob("*.py")) + assert len(sources) > 100, f"expected to scan the installed `anthropic` package, found {len(sources)} files" + violations = [ + str(v) + for source in sources + for v in scan_source(source.read_text(encoding="utf-8"), str(source.relative_to(SRC_ROOT.parent))) + ] + assert not violations, ( + "Never pass a bare program name to a process API — resolve it with " + "anthropic.lib._executable (find_executable / resolve_argv / run) and spawn the absolute path:\n" + + "\n".join(violations) + ) + + +# The snippets below are only ever *parsed* by the scanner, never executed. +_BAD_SNIPPET = """ +import os +import os as operating_system +import anyio +import asyncio +import subprocess +import subprocess as sp +from subprocess import Popen, check_output as co +from anyio import open_process + +subprocess.run(["rg", "-n", pattern]) +sp.call(("git", "status")) +Popen("tar xzf archive.tgz", shell=True) +co(args=["unzip", "-l"]) +anyio.run_process(["rg.exe", "--version"]) +open_process(command="bash --noprofile") +asyncio.create_subprocess_exec("security", "find-generic-password") +os.execvp("claude", ["claude", "--version"]) +operating_system.spawnv(os.P_WAIT, "node", ["node"]) +os.system("where rg") +subprocess.run([r"..\\tools\\rg.exe"]) +subprocess.run(["bin/rg"]) +subprocess.run(["C:rg.exe"]) +""" + +_GOOD_SNIPPET = """ +import os +import sys +import anyio +import subprocess +from anthropic.lib._executable import find_executable, resolve_argv, run + +anyio.open_process(["/bin/bash", "--noprofile", "--norc"], cwd=workdir) +rg = find_executable("rg") +anyio.run_process([rg, "-n", pattern], check=False) +subprocess.run(resolve_argv(["git", "status"])) +subprocess.run([sys.executable, "-c", "print(1)"]) +subprocess.run([r"C:\\Windows\\System32\\where.exe", "rg"]) +subprocess.run([r"\\\\server\\share\\bin\\tool.exe"]) +run(["git", "status"]) # the safe wrapper itself takes bare names by design +os.execv("/usr/bin/security", ["security"]) +os.spawnv(os.P_WAIT, "/usr/bin/env", ["env"]) +subprocess.run() # malformed, but not this test's concern +""" + + +def test_scanner_flags_every_bare_name_form() -> None: + """The scan above must not be vacuous: prove it catches each spelling.""" + found = [(v.call, v.program) for v in scan_source(_BAD_SNIPPET)] + assert found == [ + ("subprocess.run", "rg"), + ("subprocess.call", "git"), + ("subprocess.Popen", "tar"), + ("subprocess.check_output", "unzip"), + ("anyio.run_process", "rg.exe"), + ("anyio.open_process", "bash"), + ("asyncio.create_subprocess_exec", "security"), + ("os.execvp", "claude"), + ("os.spawnv", "node"), + ("os.system", "where"), + ("subprocess.run", "..\\tools\\rg.exe"), + ("subprocess.run", "bin/rg"), + ("subprocess.run", "C:rg.exe"), + ] + + +def test_scanner_accepts_absolute_paths_and_resolved_values() -> None: + assert list(scan_source(_GOOD_SNIPPET)) == [] From e0dc5e3a2bcdc12c17a6898f8eb21aaad73e69e1 Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 22:13:03 +0000 Subject: [PATCH 7/9] fix(lib): harden the resolver's `run`/`resolve_argv` edges found in review - `run()` resolves argv[0] against the PATH the child will actually see (an explicit `env={"PATH": ...}`), matching `subprocess.run` on POSIX, instead of always this process's PATH; still absolute entries only and no `os.defpath` fallback. - `resolve_argv()` rejects a plain string (a shell command line type-checks as `Sequence[str]` and would otherwise "resolve" its first character). - `ExecutableNotFoundError` is picklable/copyable (`__reduce__`), so it survives a `ProcessPoolExecutor` boundary as the FileNotFoundError it is. - Document that a quoted Windows PATH entry containing `;` is split like CPython's `shutil.which` does (false negative only, never a CWD hit). - Test module uses `collections.abc.Iterator`. --- src/anthropic/lib/_executable.py | 28 ++++++++++++++++++++--- tests/lib/test_executable.py | 35 +++++++++++++++++++++++++++++ tests/lib/test_executable_policy.py | 3 ++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/anthropic/lib/_executable.py b/src/anthropic/lib/_executable.py index d7056203..18140976 100644 --- a/src/anthropic/lib/_executable.py +++ b/src/anthropic/lib/_executable.py @@ -76,7 +76,8 @@ import ntpath import subprocess from typing import Any, Final, cast -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from typing_extensions import override __all__ = [ "WINDOWS_NATIVE_EXTENSIONS", @@ -125,6 +126,13 @@ def __init__(self, name: str) -> None: ) self.name = name + @override + def __reduce__(self) -> tuple[type[ExecutableNotFoundError], tuple[str]]: + # ``OSError.__reduce__`` would replay ``(errno, strerror, filename)`` into + # our one-argument ``__init__``; keep the exception picklable/copyable + # (e.g. when raised inside a ``ProcessPoolExecutor`` worker). + return (type(self), (self.name,)) + def _strip_enclosing_quotes(entry: str) -> str: """``"C:\\Program Files\\X"`` → ``C:\\Program Files\\X`` (legal in a Windows ``PATH``).""" @@ -199,6 +207,8 @@ def _find_executable(name: str, *, path: str, windows: bool, windows_extensions: candidates = _candidate_names(name, windows=windows, windows_extensions=windows_extensions) if not candidates: return None + # Plain split, like CPython's ``shutil.which``: a quoted Windows entry that + # itself contains ``;`` is not reassembled (a false negative, never a CWD hit). for raw_entry in path.split(";" if windows else ":"): entry = _strip_enclosing_quotes(raw_entry) if windows else raw_entry if not _is_searchable_path_entry(entry, windows=windows): @@ -266,6 +276,10 @@ def resolve_argv( Use this in front of async spawn APIs (``anyio.open_process`` / ``anyio.run_process``), which are deliberately not wrapped here. """ + if isinstance(argv, str): + # A ``str`` is a ``Sequence[str]`` to the type checker; iterating it would + # "resolve" its first character. Shell command lines are not supported. + raise TypeError("argv must be a sequence of program arguments, not a string") args = [os.fspath(arg) for arg in argv] if not args: raise ValueError("argv must contain at least the program to run") @@ -276,7 +290,15 @@ def resolve_argv( def run(argv: Sequence[str | os.PathLike[str]], /, **kwargs: Any) -> subprocess.CompletedProcess[Any]: """``subprocess.run(resolve_argv(argv), **kwargs)`` — the blessed synchronous way to run a helper program by name. Raises :class:`ExecutableNotFoundError` - (a :class:`FileNotFoundError`) when ``argv[0]`` cannot be resolved.""" + (a :class:`FileNotFoundError`) when ``argv[0]`` cannot be resolved. + + ``argv[0]`` is resolved against the ``PATH`` the child will see: the one in + ``kwargs["env"]`` when the caller passes an environment that has one, else + this process's. + """ + env: Any = kwargs.get("env") + child_path: Any = cast("Mapping[Any, Any]", env).get("PATH") if isinstance(env, Mapping) else None + resolved = resolve_argv(argv, path=child_path if isinstance(child_path, str) else None) # ``cast``: with ``**kwargs`` pyright cannot pick a single ``subprocess.run`` # overload (they differ only in the ``CompletedProcess`` type parameter). - return cast("subprocess.CompletedProcess[Any]", subprocess.run(resolve_argv(argv), **kwargs)) + return cast("subprocess.CompletedProcess[Any]", subprocess.run(resolved, **kwargs)) diff --git a/tests/lib/test_executable.py b/tests/lib/test_executable.py index 56fa369c..666cf930 100644 --- a/tests/lib/test_executable.py +++ b/tests/lib/test_executable.py @@ -296,6 +296,21 @@ def test_require_executable_raises_a_file_not_found_error(layout: Layout) -> Non assert require_executable("tool", path=str(layout.bin)) == str(layout.bin / "tool") +def test_executable_not_found_error_survives_pickling() -> None: + """Raised inside a ``ProcessPoolExecutor`` worker it must cross the process + boundary intact (``OSError.__reduce__`` alone would replay three arguments + into the one-argument constructor).""" + import copy + import pickle + + err = ExecutableNotFoundError("rg") + # Round-trips an object created right here — no untrusted pickle data involved. + for clone in (pickle.loads(pickle.dumps(err)), copy.copy(err)): + assert isinstance(clone, ExecutableNotFoundError) + assert clone.name == "rg" and clone.filename == "rg" and clone.errno == err.errno + assert str(clone) == str(err) + + @posix_only def test_resolve_argv_replaces_only_argv0(layout: Layout) -> None: argv = resolve_argv([Path("tool"), "--flag", Path("rel/ative")], path=str(layout.bin)) @@ -304,6 +319,9 @@ def test_resolve_argv_replaces_only_argv0(layout: Layout) -> None: resolve_argv([], path=str(layout.bin)) with pytest.raises(ExecutableNotFoundError): resolve_argv(["tool"], path=".") + # A shell-style string is a ``Sequence[str]`` to the type checker, but never an argv. + with pytest.raises(TypeError): + resolve_argv("tool --flag", path=str(layout.bin)) @posix_only @@ -317,3 +335,20 @@ def test_run_spawns_the_resolved_absolute_path(layout: Layout, monkeypatch: pyte monkeypatch.setenv("PATH", ".") with pytest.raises(ExecutableNotFoundError): run(["hello"], capture_output=True) + + +@posix_only +def test_run_resolves_against_the_path_the_child_will_see(layout: Layout, monkeypatch: pytest.MonkeyPatch) -> None: + """Like ``subprocess.run`` on POSIX, an explicit ``env`` carrying a ``PATH`` + is the search path — still subject to the absolute-entries-only rule.""" + _make_executable(layout.bin / "hello", '#!/bin/sh\necho "from-bin $0"\n') + monkeypatch.setenv("PATH", "") + child_env = {"PATH": "." + SEP + str(layout.bin)} + assert run(["hello"], env=child_env, capture_output=True, text=True).stdout == f"from-bin {layout.bin / 'hello'}\n" + with pytest.raises(ExecutableNotFoundError): + run(["hello"], env={"PATH": "."}, capture_output=True) + # No PATH in the child env: fall back to this process's (empty here) — never os.defpath. + with pytest.raises(ExecutableNotFoundError): + run(["hello"], env={"UNRELATED": "1"}, capture_output=True) + monkeypatch.setenv("PATH", str(layout.bin)) + assert run(["hello"], env={"UNRELATED": "1"}, capture_output=True, text=True).returncode == 0 diff --git a/tests/lib/test_executable_policy.py b/tests/lib/test_executable_policy.py index 65942392..ea8920ec 100644 --- a/tests/lib/test_executable_policy.py +++ b/tests/lib/test_executable_policy.py @@ -13,8 +13,9 @@ import re import ast -from typing import Iterator, NamedTuple +from typing import NamedTuple from pathlib import Path +from collections.abc import Iterator from typing_extensions import override import anthropic From fc819935aed886918c428826900a3a468f266aea Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 22:17:25 +0000 Subject: [PATCH 8/9] fix(lib): set NoDefaultCurrentDirectoryInExePath=1 for helper children on Windows (defense in depth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `hardened_child_env()`: on Windows it returns the child environment with `NoDefaultCurrentDirectoryInExePath=1` added unless the caller already set it (any value/case); on other platforms — and for `os.environ` itself — it changes nothing (a general-purpose SDK must not mutate its host process's environment at import or call time). `run()` applies it to the environment it hands `subprocess.run`, and the grep tool passes it as `env=` to the ripgrep child, so anything those helpers spawn by bare name also skips the current directory. This mirrors what the Claude Code CLI sets at its entrypoints. It is NOT the fix for the CWD-search class — the absolute-PATH-only resolver is (Python < 3.12 `shutil.which` and libuv < 1.45 ignore the variable) — and is kept in its own commit so it can be dropped independently. (`run()`'s child-PATH lookup moves into a small `_search_path_in` helper so the env value is not type-narrowed twice.) --- src/anthropic/lib/_executable.py | 53 ++++++++++++++++++++-- src/anthropic/lib/tools/agent_toolset.py | 6 ++- tests/lib/test_executable.py | 56 +++++++++++++++++++++++- tests/lib/tools/test_agent_toolset.py | 26 +++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/anthropic/lib/_executable.py b/src/anthropic/lib/_executable.py index 18140976..e592f97b 100644 --- a/src/anthropic/lib/_executable.py +++ b/src/anthropic/lib/_executable.py @@ -48,6 +48,14 @@ and ``tests/lib/test_executable_policy.py`` fails if any process-spawning call under ``src/anthropic`` passes a program name literal that is not an absolute path. +- **D1 — defense in depth (Windows only).** :func:`hardened_child_env` adds + ``NoDefaultCurrentDirectoryInExePath=1`` to the environment handed to a + helper *child* (:func:`run` applies it; async spawn sites pass it as + ``env=``) so anything the helper spawns by bare name in turn also skips the + CWD. It is never applied to this process's own ``os.environ`` — a + general-purpose SDK does not mutate its host's environment — and it is not + the fix (G1–G4 are): Python < 3.12 ``shutil.which`` and libuv < 1.45 ignore + the variable. A match is a regular file (symlinks followed, so a *directory* named ``rg`` is skipped) that is executable (``os.access(X_OK)``) on POSIX; on Windows @@ -86,12 +94,17 @@ "require_executable", "resolve_argv", "run", + "hardened_child_env", ] WINDOWS_NATIVE_EXTENSIONS: Final = (".exe", ".com") """Default Windows candidate extensions (G3): native images only — never ``.bat``/``.cmd`` and never extensionless.""" +# D1: honoured by ``CreateProcess``/``cmd.exe``/CPython >= 3.12 ``shutil.which`` +# (presence, any value): do not search the current directory for bare names. +_NO_CWD_SEARCH_ENV_VAR: Final = "NoDefaultCurrentDirectoryInExePath" + # The public functions look this up at call time; the pure helpers below take an # explicit ``windows`` flag instead so the Windows rules are testable on any host. _IS_WINDOWS = os.name == "nt" @@ -294,11 +307,43 @@ def run(argv: Sequence[str | os.PathLike[str]], /, **kwargs: Any) -> subprocess. ``argv[0]`` is resolved against the ``PATH`` the child will see: the one in ``kwargs["env"]`` when the caller passes an environment that has one, else - this process's. + this process's. On Windows the child's environment additionally gets D1 + (:func:`hardened_child_env`). """ - env: Any = kwargs.get("env") - child_path: Any = cast("Mapping[Any, Any]", env).get("PATH") if isinstance(env, Mapping) else None - resolved = resolve_argv(argv, path=child_path if isinstance(child_path, str) else None) + resolved = resolve_argv(argv, path=_search_path_in(kwargs.get("env"))) + # D1 — defense in depth for the *child*, not the fix (that is ``resolve_argv`` above). + hardened = hardened_child_env(kwargs.get("env")) + if hardened is not None: + kwargs["env"] = hardened # ``cast``: with ``**kwargs`` pyright cannot pick a single ``subprocess.run`` # overload (they differ only in the ``CompletedProcess`` type parameter). return cast("subprocess.CompletedProcess[Any]", subprocess.run(resolved, **kwargs)) + + +def _search_path_in(env: Any) -> str | None: + """The ``PATH`` inside a caller-supplied child environment, if it carries one.""" + if not isinstance(env, Mapping): + return None + value: Any = cast("Mapping[Any, Any]", env).get("PATH") + return value if isinstance(value, str) else None + + +def hardened_child_env(env: Mapping[str, str] | None = None) -> Mapping[str, str] | None: + """D1 — the environment to hand to a helper child process. + + Returns ``env`` unchanged (``None`` meaning "inherit ``os.environ``"), except + on Windows where the result additionally carries + ``NoDefaultCurrentDirectoryInExePath=1`` unless the caller already set it + (any value, any case). With it, ``CreateProcess``/``cmd.exe``/CPython >= 3.12 + ``shutil.which`` *inside the child* skip the current directory when they + resolve bare names — defense in depth for whatever the helper spawns in + turn. Only ever applied to a child's environment: this SDK never mutates + its host process's ``os.environ``. Not the fix for the CWD-search class + (G1–G4 are) — Python < 3.12 and libuv < 1.45 ignore the variable. + """ + if not _IS_WINDOWS: + return env + base: Mapping[str, str] = os.environ if env is None else env + if any(key.upper() == _NO_CWD_SEARCH_ENV_VAR.upper() for key in base): + return env + return {**base, _NO_CWD_SEARCH_ENV_VAR: "1"} diff --git a/src/anthropic/lib/tools/agent_toolset.py b/src/anthropic/lib/tools/agent_toolset.py index 825a5802..6b5350ed 100644 --- a/src/anthropic/lib/tools/agent_toolset.py +++ b/src/anthropic/lib/tools/agent_toolset.py @@ -67,7 +67,7 @@ BetaManagedAgentsAgentToolset20260401ReadInput, BetaManagedAgentsAgentToolset20260401WriteInput, ) -from .._executable import find_executable +from .._executable import find_executable, hardened_child_env from ._beta_functions import ( ToolError, BetaContent, @@ -753,6 +753,10 @@ async def grep(pattern: str, path: Optional[str] = None) -> str: result = await anyio.run_process( [rg, "-n", "--no-heading", "-e", pattern, "--", str(search)], check=False, + # Defense in depth (Windows only), not the fix: the child env + # gains NoDefaultCurrentDirectoryInExePath=1 so anything rg + # spawns skips the CWD too. ``None`` (inherit) elsewhere. + env=hardened_child_env(), ) if result.returncode == 1: return "no matches" diff --git a/tests/lib/test_executable.py b/tests/lib/test_executable.py index 666cf930..b66dabe1 100644 --- a/tests/lib/test_executable.py +++ b/tests/lib/test_executable.py @@ -1,7 +1,7 @@ """Tests for :mod:`anthropic.lib._executable` — safe helper-executable resolution. -The vector IDs (V1–V12, W1–W3, P1) and guarantee IDs (G1–G5) are shared with the -sibling Anthropic SDKs (claude-agent-sdk-python, anthropic-sdk-typescript, +The vector IDs (V1–V12, W1–W3, P1) and guarantee IDs (G1–G5, D1) are shared with +the sibling Anthropic SDKs (claude-agent-sdk-python, anthropic-sdk-typescript, anthropic-sdk-go); keep them in sync when changing behaviour. """ @@ -14,6 +14,7 @@ import pytest +from anthropic.lib import _executable from anthropic.lib._executable import ( WINDOWS_NATIVE_EXTENSIONS, ExecutableNotFoundError, @@ -22,10 +23,13 @@ find_executable, _candidate_names, _find_executable, + hardened_child_env, require_executable, _is_searchable_path_entry, ) +NO_CWD_VAR = "NoDefaultCurrentDirectoryInExePath" + posix_only = pytest.mark.skipif(sys.platform == "win32", reason="exercises the POSIX flavour on the real filesystem") SEP = os.pathsep @@ -352,3 +356,51 @@ def test_run_resolves_against_the_path_the_child_will_see(layout: Layout, monkey run(["hello"], env={"UNRELATED": "1"}, capture_output=True) monkeypatch.setenv("PATH", str(layout.bin)) assert run(["hello"], env={"UNRELATED": "1"}, capture_output=True, text=True).returncode == 0 + + +# --------------------------------------------------------------------------- # +# D1: NoDefaultCurrentDirectoryInExePath for helper *children* (Windows only) # +# --------------------------------------------------------------------------- # + + +def test_d1_child_env_is_untouched_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_executable, "_IS_WINDOWS", False) + assert hardened_child_env() is None # ``None`` = plain inheritance + explicit = {"A": "1"} + assert hardened_child_env(explicit) is explicit + + +def test_d1_child_env_gains_the_variable_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(_executable, "_IS_WINDOWS", True) + monkeypatch.delenv(NO_CWD_VAR, raising=False) + monkeypatch.setenv("SOME_INHERITED_VAR", "x") + + inherited = hardened_child_env() + assert inherited is not None + assert inherited[NO_CWD_VAR] == "1" + assert inherited["SOME_INHERITED_VAR"] == "x" # still the full inherited environment + assert NO_CWD_VAR not in os.environ # the host process's environment is never mutated + + explicit = {"A": "1"} + assert hardened_child_env(explicit) == {"A": "1", NO_CWD_VAR: "1"} + assert explicit == {"A": "1"} # the caller's mapping is not mutated either + + # The caller's own choice wins — any value, any case (Windows env is case-insensitive). + already = {NO_CWD_VAR.upper(): "0"} + assert hardened_child_env(already) is already + monkeypatch.setenv(NO_CWD_VAR, "1") + assert hardened_child_env() is None + + +@posix_only +def test_d1_run_hands_the_variable_to_the_child(monkeypatch: pytest.MonkeyPatch) -> None: + """``run`` wires D1 into ``subprocess.run``'s ``env`` — shown on a POSIX host by + flipping the flavour and spawning an explicit absolute path (G4).""" + monkeypatch.delenv(NO_CWD_VAR, raising=False) + print_var = ["/bin/sh", "-c", f'echo "${NO_CWD_VAR}-${{EXTRA:-none}}"'] + + assert run(print_var, capture_output=True, text=True).stdout == "-none\n" + monkeypatch.setattr(_executable, "_IS_WINDOWS", True) + assert run(print_var, capture_output=True, text=True).stdout == "1-none\n" + assert run(print_var, capture_output=True, text=True, env={"EXTRA": "kept"}).stdout == "1-kept\n" + assert NO_CWD_VAR not in os.environ diff --git a/tests/lib/tools/test_agent_toolset.py b/tests/lib/tools/test_agent_toolset.py index 56004c7f..0dc47532 100644 --- a/tests/lib/tools/test_agent_toolset.py +++ b/tests/lib/tools/test_agent_toolset.py @@ -10,6 +10,7 @@ import anyio import pytest +from anthropic.lib import _executable from anthropic._compat import PYDANTIC_V1 from anthropic.lib.tools import ToolError from anthropic.lib.tools.agent_toolset import ( @@ -520,6 +521,31 @@ async def test_grep_uses_rg_from_an_absolute_path_entry(tmp_path: Path, monkeypa assert not marker.exists(), "planted rg was executed" +@needs_pydantic_v2 +@pytest.mark.skipif(sys.platform == "win32", reason="simulates the Windows flavour with #!/bin/sh stand-ins") +async def test_grep_hands_rg_the_no_cwd_search_variable_on_windows( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """D1 (defense in depth): under the Windows rules the ``rg`` child's + environment carries ``NoDefaultCurrentDirectoryInExePath=1`` while the + runner's own environment is left alone. Simulated on POSIX by flipping the + resolver's flavour; ``//tmp/…`` passes the Windows (UNC) absolute-entry + check and the kernel reads it as ``/tmp/…``.""" + monkeypatch.setattr(_executable, "_IS_WINDOWS", True) + monkeypatch.delenv("NoDefaultCurrentDirectoryInExePath", raising=False) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + (bin_dir / "rg.exe").write_text('#!/bin/sh\necho "RG_ENV:${NoDefaultCurrentDirectoryInExePath:-unset}"\n') + (bin_dir / "rg.exe").chmod(0o755) + monkeypatch.setenv("PATH", "/" + str(bin_dir)) + + env = AgentToolContext(workdir=str(tmp_path)) + res = await beta_grep_tool(env).call({"pattern": "needle"}) + + assert res == "RG_ENV:1\n" + assert "NoDefaultCurrentDirectoryInExePath" not in os.environ + + @pytest.mark.skipif(sys.platform == "win32", reason="bash session requires /bin/bash") async def test_bash_session_persistence(tmp_path: Path) -> None: s = await BashSession.start(str(tmp_path)) From 070bd0b460e4e8d5965c326bcaa25a2b1d7444ce Mon Sep 17 00:00:00 2001 From: seanyeoh Date: Wed, 5 Aug 2026 22:18:30 +0000 Subject: [PATCH 9/9] docs: document safe spawning of external programs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONTRIBUTING.md: new "Spawning external programs" section — never hand a bare program name to a process API; resolve with `anthropic.lib._executable` (`find_executable` / `resolve_argv` / `run`) and spawn the absolute path; names the ruff ban and the policy test that enforce it in CI. - helpers.md: next to the agent-toolset trust model ("file tools are safe without a sandbox"), note that helper binaries such as ripgrep are looked up on absolute PATH entries only, never the working directory, with a pure-Python fallback when ripgrep is absent. CHANGELOG.md is generated by release-please and is intentionally untouched. No-Verification-Needed: documentation-only change --- CONTRIBUTING.md | 22 ++++++++++++++++++++++ helpers.md | 5 ++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19815a85..879688de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,28 @@ Most of the SDK is generated code. Modifications to code will be persisted betwe result in merge conflicts between manual patches and changes from the generator. The generator will never modify the contents of the `src/anthropic/lib/` and `examples/` directories. +### Spawning external programs + +Never pass a bare program name (`"rg"`, `"git"`, `"tar"`) to a process API (`subprocess.*`, +`anyio.open_process` / `anyio.run_process`, `asyncio.create_subprocess_exec`, `os.exec*` / `os.spawn*`). +The OS-level lookup those trigger searches the current working directory on Windows (and `.`/empty/relative +`PATH` entries everywhere), so a look-alike binary planted in whatever directory the user happens to run from +would be executed. Instead, resolve the program with +[`anthropic.lib._executable`](src/anthropic/lib/_executable.py) and spawn the absolute path it returns: + +```py +from anthropic.lib._executable import find_executable, resolve_argv, run + +rg = find_executable("rg") # absolute path or None — only absolute PATH entries, never the CWD +proc = await anyio.run_process(resolve_argv(["git", "status"])) # async: resolve first, then spawn +result = run(["git", "status"], capture_output=True) # sync: resolves and runs in one call +``` + +Absolute paths you construct yourself (e.g. `/bin/bash`) are fine as-is. CI enforces this: ruff bans +`shutil.which` / `distutils.spawn.find_executable` (`flake8-tidy-imports.banned-api` in `pyproject.toml`), and +`tests/lib/test_executable_policy.py` fails on any spawn call whose program is a non-absolute string literal. +The module docstring lists the exact guarantees (G1–G5, D1), which are shared with the other Anthropic SDKs. + ## Adding and running examples All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. diff --git a/helpers.md b/helpers.md index a9a7b2ba..3faa9bb5 100644 --- a/helpers.md +++ b/helpers.md @@ -349,7 +349,10 @@ tools = [t for t in beta_agent_toolset_20260401(env) if t.name != "bash"] The `bash` tool runs an unrestricted `/bin/bash` and executes file operations and shell commands directly on the host. Run the worker inside a container or other isolation boundary you control. (The file tools — `read`/`write`/`edit`/`glob`/`grep` — confine to the workdir with a symlink-aware -check, so they are safe without a sandbox; `bash` is not.) `bash` does not inherit the runner's +check, so they are safe without a sandbox; `bash` is not.) Helper binaries the file tools shell out +to (`rg` for `grep`) are looked up on absolute `PATH` entries only — never in the working directory +or the tree being searched — so a look-alike `rg`/`rg.exe` planted in the workspace is never executed; +without ripgrep installed, `grep` falls back to a pure-Python search. `bash` does not inherit the runner's `ANTHROPIC_*` credentials; pass `AgentToolContext(env=...)` to control the subprocess environment. See [`examples/managed-agents-self-hosted-sandbox-worker.py`](examples/managed-agents-self-hosted-sandbox-worker.py) for a complete example.