Skip to content

Resolve helper executables via a safe PATH lookup (never the working directory) - #1805

Draft
seanyeoh-ant wants to merge 9 commits into
nextfrom
seanyeoh/safe-executable-resolution
Draft

Resolve helper executables via a safe PATH lookup (never the working directory)#1805
seanyeoh-ant wants to merge 9 commits into
nextfrom
seanyeoh/safe-executable-resolution

Conversation

@seanyeoh-ant

Copy link
Copy Markdown

What this is

The agent toolset's grep tool found ripgrep with shutil.which("rg") and ran whatever came back. On Windows that lookup checks the current working directory first (and on every platform it honours ./empty/relative PATH entries), so an rg.exe dropped into the workspace the operator launches the agent from was executed with the host's environment — while the file tools are documented as "safe without a sandbox" (HackerOne #3901184; uncontrolled search path). The Claude Code CLI already solved this class for itself with a safe resolver, a lint ban and NoDefaultCurrentDirectoryInExePath; this PR brings the same guarantees to this SDK, matching sibling PRs in claude-agent-sdk-python, anthropic-sdk-typescript and anthropic-sdk-go (same rules, same shared test vectors).

Code changes

  • New src/anthropic/lib/_executable.py (find_executable, require_executable, resolve_argv, run, ExecutableNotFoundError, WINDOWS_NATIVE_EXTENSIONS): a small hand-written PATH walk that only looks in fully-absolute PATH entries, never the working directory, returns only real .exe/.com programs on Windows, and always hands the OS an absolute path. Prevents "a look-alike rg/git/tar sitting in the folder you happen to run from gets picked over the installed one". (shutil.which(name, path=...) can't be reused: on Windows CPython prepends the current directory even when an explicit path= is given.)
  • grep tool now uses it (src/anthropic/lib/tools/agent_toolset.py): shutil.which("rg")find_executable("rg"), and the absolute result is what gets spawned. No ripgrep found → the existing pure-Python search, exactly as before. The bash tool's /bin/bash is already an absolute path and is unchanged.
  • run() / resolve_argv() edge hardening (from review): run() resolves against the PATH the child will actually see when the caller passes env=; a shell-style string is rejected instead of "resolving" its first character; ExecutableNotFoundError (a FileNotFoundError) survives pickling across a process pool.
  • Lint ban + policy test: shutil.which and distutils.spawn.find_executable are added to the existing ruff flake8-tidy-imports.banned-api table, and tests/lib/test_executable_policy.py scans the package's AST and fails if any subprocess / anyio / asyncio / os.exec*/os.spawn*/os.system/os.popen call names its program with a string literal that is not an absolute path (proven to bite on an inline bad snippet, and to accept /bin/bash and runtime-resolved values). Stops the next hand-written helper from quietly reintroducing the bug.
  • Defense in depth, Windows only, separate commit (fc81993) so it can be dropped on its own: hardened_child_env() adds NoDefaultCurrentDirectoryInExePath=1 to the environment handed to helper children (run() and the ripgrep call use it) unless the caller already set it. It is never applied to this process's own os.environ — a general-purpose SDK shouldn't touch its host's environment. This is not the fix (older Pythons and libuv ignore the variable); the resolver is.

Guarantees

  • G1 — the program path handed to the OS is always absolute; bare names never reach subprocess/anyio.
  • G2 — the working directory is never searched: not implicitly, not via ., empty or relative PATH entries, and on Windows not via C:bin / \bin style entries; quoted Windows entries are unquoted first; no ~/$VAR expansion; empty/unset PATH finds nothing (no os.defpath fallback).
  • G3 — Windows returns native images only (.exe/.com by default, never .bat/.cmd, never extensionless); the allow-list is a parameter so callers can detect a shim without running it.
  • G4 — a name containing a path separator is the caller's explicit choice: no search, returned (absolute, normalised) only if it is an existing executable file; ./tool deliberately means "in the CWD".
  • G5 — one implementation per repo, enforced by the ruff ban and the AST policy test above.
  • D1NoDefaultCurrentDirectoryInExePath=1 for helper children on Windows, as defense in depth only.

Tests

  • tests/lib/test_executable.py: the shared cross-SDK vectors — V1–V12 (absolute entry found; leading empty entry, ., relative entries and an all-relative PATH never reach a tool planted in the CWD; non-executable file and a directory named like the tool are skipped; ./tool and absolute names honoured; result is absolute + normalised; ""/./.. and empty/unset PATH find nothing), W1–W3 (Windows entry rules, candidate names incl. shim detection, and an end-to-end Windows-flavour run simulated on Linux), P1 (\ is not a separator on POSIX), plus require_executable/resolve_argv/run behaviour, pickling, and the D1 helper.
  • tests/lib/tools/test_agent_toolset.py: regression for the report — with marker-writing rg and rg.exe planted in the CWD and PATH=".:<empty>:rel", grep uses the Python fallback and the plant never runs; with a real rg on an absolute PATH entry, that one is used (spawned by absolute path) and still beats the plant; and the ripgrep child receives the D1 variable under Windows rules while os.environ stays untouched. The two existing fallback-walker tests now force the fallback with an empty PATH instead of monkeypatching shutil.which.
  • tests/lib/test_executable_policy.py: the G5 scan over the installed package, plus its own bad/good fixtures so it can't go vacuous.
  • Run: ./scripts/test tests/lib/test_executable.py tests/lib/test_executable_policy.py tests/lib/tools/test_agent_toolset.py (or uv run pytest …); ./scripts/lint for ruff + pyright + mypy.

Docs

  • Module docstring of anthropic/lib/_executable.py: the invariant paragraph, G1–G5/D1, and a keep-in-sync note pointing at the sibling SDKs.
  • CONTRIBUTING.md: new "Spawning external programs" section (what to use, what CI enforces).
  • helpers.md: one sentence next to the agent-toolset trust model noting helper binaries are looked up on absolute PATH entries only, never the working directory.
  • CHANGELOG.md untouched (release-please generates it).

Not in this PR

  • Scrubbing ANTHROPIC_* variables from the ripgrep child's environment — a separate concern (sibling reports on it were closed as by-design); the bash tool already scrubs them for model-issued commands.
  • Quote-aware splitting of Windows PATH entries that themselves contain ; — split plainly like CPython's shutil.which; worst case is a miss (fallback search), never a working-directory hit.
  • No behaviour change for the bash tool (/bin/bash is absolute; its environment is caller-controlled and documented as used verbatim).

stainless-app Bot and others added 9 commits August 3, 2026 17:11
…he working directory

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.
…shutil.which

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
".:<empty>: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.
…de 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.
…eview

- `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`.
…n on Windows (defense in depth)

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.)
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants