Resolve helper executables via a safe PATH lookup (never the working directory) - #1805
Draft
seanyeoh-ant wants to merge 9 commits into
Draft
Resolve helper executables via a safe PATH lookup (never the working directory)#1805seanyeoh-ant wants to merge 9 commits into
seanyeoh-ant wants to merge 9 commits into
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The agent toolset's
greptool found ripgrep withshutil.which("rg")and ran whatever came back. On Windows that lookup checks the current working directory first (and on every platform it honours./empty/relativePATHentries), so anrg.exedropped 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 andNoDefaultCurrentDirectoryInExePath; 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
src/anthropic/lib/_executable.py(find_executable,require_executable,resolve_argv,run,ExecutableNotFoundError,WINDOWS_NATIVE_EXTENSIONS): a small hand-writtenPATHwalk that only looks in fully-absolutePATHentries, never the working directory, returns only real.exe/.comprograms on Windows, and always hands the OS an absolute path. Prevents "a look-alikerg/git/tarsitting 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 explicitpath=is given.)greptool 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. Thebashtool's/bin/bashis already an absolute path and is unchanged.run()/resolve_argv()edge hardening (from review):run()resolves against thePATHthe child will actually see when the caller passesenv=; a shell-style string is rejected instead of "resolving" its first character;ExecutableNotFoundError(aFileNotFoundError) survives pickling across a process pool.shutil.whichanddistutils.spawn.find_executableare added to the existing ruffflake8-tidy-imports.banned-apitable, andtests/lib/test_executable_policy.pyscans the package's AST and fails if anysubprocess/anyio/asyncio/os.exec*/os.spawn*/os.system/os.popencall 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/bashand runtime-resolved values). Stops the next hand-written helper from quietly reintroducing the bug.fc81993) so it can be dropped on its own:hardened_child_env()addsNoDefaultCurrentDirectoryInExePath=1to 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 ownos.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
subprocess/anyio.., empty or relativePATHentries, and on Windows not viaC:bin/\binstyle entries; quoted Windows entries are unquoted first; no~/$VARexpansion; empty/unsetPATHfinds nothing (noos.defpathfallback)..exe/.comby default, never.bat/.cmd, never extensionless); the allow-list is a parameter so callers can detect a shim without running it../tooldeliberately means "in the CWD".NoDefaultCurrentDirectoryInExePath=1for 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-relativePATHnever reach a tool planted in the CWD; non-executable file and a directory named like the tool are skipped;./tooland absolute names honoured; result is absolute + normalised;""/./..and empty/unsetPATHfind 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), plusrequire_executable/resolve_argv/runbehaviour, pickling, and the D1 helper.tests/lib/tools/test_agent_toolset.py: regression for the report — with marker-writingrgandrg.exeplanted in the CWD andPATH=".:<empty>:rel",grepuses the Python fallback and the plant never runs; with a realrgon an absolutePATHentry, that one is used (spawned by absolute path) and still beats the plant; and the ripgrep child receives the D1 variable under Windows rules whileos.environstays untouched. The two existing fallback-walker tests now force the fallback with an emptyPATHinstead of monkeypatchingshutil.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../scripts/test tests/lib/test_executable.py tests/lib/test_executable_policy.py tests/lib/tools/test_agent_toolset.py(oruv run pytest …);./scripts/lintfor ruff + pyright + mypy.Docs
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 absolutePATHentries only, never the working directory.CHANGELOG.mduntouched (release-please generates it).Not in this PR
ANTHROPIC_*variables from the ripgrep child's environment — a separate concern (sibling reports on it were closed as by-design); thebashtool already scrubs them for model-issued commands.PATHentries that themselves contain;— split plainly like CPython'sshutil.which; worst case is a miss (fallback search), never a working-directory hit.bashtool (/bin/bashis absolute; its environment is caller-controlled and documented as used verbatim).