diff --git a/.stats.yml b/.stats.yml index 9f6a91167..f75e19740 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_hash: d2deb0fef6a15bf53cc6c53f07973a54 -config_hash: 447f76a00c2affe40a8b4c43129e17a3 +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/CONTRIBUTING.md b/CONTRIBUTING.md index 19815a85b..879688de6 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 a9a7b2bab..3faa9bb5d 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. diff --git a/pyproject.toml b/pyproject.toml index 1d9596249..17114e73f 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] @@ -270,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/scripts/lint b/scripts/lint index f5eb832bd..0532dc7c4 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 000000000..1a29aa502 --- /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/src/anthropic/lib/_executable.py b/src/anthropic/lib/_executable.py new file mode 100644 index 000000000..e592f97bf --- /dev/null +++ b/src/anthropic/lib/_executable.py @@ -0,0 +1,349 @@ +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. +- **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 +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 Mapping, Sequence +from typing_extensions import override + +__all__ = [ + "WINDOWS_NATIVE_EXTENSIONS", + "ExecutableNotFoundError", + "find_executable", + "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" + +# ``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 + + @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``).""" + 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 + # 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): + 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. + """ + 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") + 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. + + ``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. On Windows the child's environment additionally gets D1 + (:func:`hardened_child_env`). + """ + 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 9efbc0501..6b5350edb 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, hardened_child_env from ._beta_functions import ( ToolError, BetaContent, @@ -739,12 +743,20 @@ 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( [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/src/anthropic/types/anthropic_beta_param.py b/src/anthropic/types/anthropic_beta_param.py index 5f88f3d2a..c505e560f 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", ], ] diff --git a/src/anthropic/types/beta/beta_redacted_thinking_block.py b/src/anthropic/types/beta/beta_redacted_thinking_block.py index b27bd9332..623e32e53 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 cc7d870f0..8c2262828 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 a35868262..5c5b7c27a 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 9a9c1df84..afdb1d5f8 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 5bd431800..20651987a 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 7edba71e6..1042ced4a 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 4850b335a..5d76e981d 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 0933188c4..2c92714ad 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 55d151898..ce8142db0 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 7f98b500a..7cdc9b188 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 d310c7f6e..668d11f8f 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 fb79933c1..b8f8d425a 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"] diff --git a/tests/lib/test_executable.py b/tests/lib/test_executable.py new file mode 100644 index 000000000..b66dabe10 --- /dev/null +++ b/tests/lib/test_executable.py @@ -0,0 +1,406 @@ +"""Tests for :mod:`anthropic.lib._executable` — safe helper-executable resolution. + +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. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path +from dataclasses import dataclass + +import pytest + +from anthropic.lib import _executable +from anthropic.lib._executable import ( + WINDOWS_NATIVE_EXTENSIONS, + ExecutableNotFoundError, + run, + resolve_argv, + 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 + + +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") + + +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)) + 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=".") + # 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 +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) + + +@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 + + +# --------------------------------------------------------------------------- # +# 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/test_executable_policy.py b/tests/lib/test_executable_policy.py new file mode 100644 index 000000000..ea8920ecd --- /dev/null +++ b/tests/lib/test_executable_policy.py @@ -0,0 +1,231 @@ +"""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 NamedTuple +from pathlib import Path +from collections.abc import Iterator +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)) == [] diff --git a/tests/lib/tools/test_agent_toolset.py b/tests/lib/tools/test_agent_toolset.py index 8ca5889d1..0dc475328 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 ( @@ -450,13 +451,101 @@ 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" + + +@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)) @@ -595,6 +684,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" diff --git a/uv.lock b/uv.lock index 2584b5e32..9164877aa 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 = [