security-guidance: pip --target fallback when venv can't bootstrap pip (2.0.4 → 2.0.5)

Option A, the data-gated fix for venv_ensurepip_fail (#2154 follow-up).

v2.0.4 telemetry made the call: of the venv_ensurepip_fail cohort, ~95%
HAVE pip (sdk_has_pip=true) and run Python 3.11–3.14 — so it's not the
Apple-3.9 problem; it's modern interpreters where `python -m venv` can't
bootstrap pip (Debian python3-venv absent, or python.org/pyenv builds
without ensurepip) but pip itself works. `pip install --target` needs only
pip, so it recovers the agentic reviewer for them instead of degrading to
pattern + single-shot review.

Producer (ensure_agent_sdk.py):
  - New outcomes BUILT_TARGET=7, NOOP_TARGET=8; new phase pip_target=5.
  - _build_via_target(): `pip install --target <state>/agent-sdk-libs
    --upgrade --prefer-binary claude-agent-sdk`. Failures categorized via
    _pip_err_from_stderr (sibling of main()'s pip chain — kept separate to
    avoid disturbing the working venv categorizer); errno embedded for
    OSError-family exceptions.
  - _target_sdk_importable(): probes a prior target install → NOOP_TARGET.
    Dir-check short-circuits before any subprocess, and it's only reached
    when there's no working venv, so the 81% NOOP_VENV cohort never pays.
  - main() falls through to the target build ONLY on venv_ensurepip_fail;
    every other venv/pip failure stays terminal BUILD_FAILED. The sentinel
    is released before the target build so a retry isn't seen as SKIP_SENTINEL.

Consumer (llm.py):
  - _inject_agent_sdk_venv_into_syspath() adds the flat agent-sdk-libs dir
    (packages sit directly in it, not under site-packages). The existing
    pywin32 .pth bootstrap applies (target installs don't run .pth either).

No change to the happy path — the new branch is taken only on the
ensurepip failure, and the extra candidate dir is a no-op when absent.

Verified locally on macOS Python 3.13:
  - py_compile clean.
  - 30 new tests (test_venv_target_fallback.py): outcome/phase codes
    (append-only, 4 stays retired), _pip_err_from_stderr categories,
    _build_via_target success/CalledProcessError/timeout/exc+errno (mocked
    subprocess), _target_sdk_importable dir-short-circuit, main() wiring
    (ensurepip→target fallthrough + NOOP_TARGET probe + sentinel release),
    consumer adds the flat dir. Full suite 533/533 pass + 2 skipped.
  - END-TO-END harness (real install, simulated ensurepip failure):
    main() → BUILT_TARGET, target dir has claude_agent_sdk; 2nd run →
    NOOP_TARGET; consumer _inject → `import claude_agent_sdk` resolves
    FROM the --target dir. Full chain proven without needing a
    broken-ensurepip box.
  - Real `pip install --target` + import confirmed independently (exit 0,
    SDK imports from the flat layout).

NOT validated in tmux: the ensurepip failure can't be reproduced on macOS
(working ensurepip), so the fallback was proven via the real-install
harness above instead. The happy path (NOOP_VENV / normal agentic review)
is unchanged and covered by the existing hook-smoke suite.

Version 2.0.4 -> 2.0.5 per the per-PR-bump policy (#2114).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mohamed Hegazy 2026-06-11 23:31:55 -07:00
parent e97f9a40b9
commit e7fe15d9ba
No known key found for this signature in database
4 changed files with 124 additions and 2 deletions

View File

@ -2387,7 +2387,7 @@
{ {
"name": "security-guidance", "name": "security-guidance",
"description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.", "description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.",
"version": "2.0.4", "version": "2.0.5",
"author": { "author": {
"name": "Anthropic", "name": "Anthropic",
"email": "support@anthropic.com" "email": "support@anthropic.com"

View File

@ -1,6 +1,6 @@
{ {
"name": "security-guidance", "name": "security-guidance",
"version": "2.0.4", "version": "2.0.5",
"description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.", "description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.",
"author": { "author": {
"name": "David Dworken", "name": "David Dworken",

View File

@ -40,6 +40,15 @@ BUILD_FAILED = 3 # venv create or pip install raised/timed out
SKIP_SENTINEL = 5 # another SessionStart is currently building SKIP_SENTINEL = 5 # another SessionStart is currently building
HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
# here no matter how the venv was built. See #2071. # here no matter how the venv was built. See #2071.
# --target fallback: when `python -m venv` can't bootstrap pip (ensurepip
# missing — Debian python3-venv not installed, or a python.org/pyenv build
# without ensurepip), fall back to `pip install --target <dir>` which needs
# only the system pip, not venv/ensurepip. Telemetry (v2.0.4 sdk_has_pip
# probe) confirmed ~95% of venv_ensurepip_fail users HAVE pip, so this
# recovers the agentic reviewer for them instead of degrading to pattern +
# single-shot review. See #2154 follow-up.
BUILT_TARGET = 7 # venv ensurepip failed → SDK pip-installed via --target
NOOP_TARGET = 8 # --target libs already present and importable
# Phase + err-kind integer encoding for sdk_bootstrap_phase / sdk_bootstrap_err. # Phase + err-kind integer encoding for sdk_bootstrap_phase / sdk_bootstrap_err.
@ -63,6 +72,7 @@ SDK_BOOTSTRAP_PHASE_CODES = {
"venv": 2, # python -m venv --clear "venv": 2, # python -m venv --clear
"pip": 3, # pip install "pip": 3, # pip install
"main": 4, # uncaught exception above main() "main": 4, # uncaught exception above main()
"pip_target": 5, # `pip install --target` fallback (venv ensurepip failed)
} }
SDK_BOOTSTRAP_ERR_CODES = { SDK_BOOTSTRAP_ERR_CODES = {
"pip_no_match": 1, "pip_no_match": 1,
@ -242,6 +252,96 @@ def _probe_has_pip() -> bool:
return False return False
def _pip_err_from_stderr(stderr_b):
"""Categorize a pip-install stderr into a known err_kind (the pip subset
of SDK_BOOTSTRAP_ERR_CODES). Used by the --target fallback; mirrors the
pip branches of main()'s inline categorizer. Kept as a sibling rather
than extracting main()'s chain (which also has venv-phase branches) to
avoid disturbing the working venv categorization."""
if isinstance(stderr_b, bytes):
s = stderr_b.decode("utf-8", errors="replace")
else:
s = str(stderr_b or "")
low = s.lower()
if "no matching distribution" in low or "could not find a version" in low:
return "pip_no_match"
if ("name or service not known" in low or "name resolution" in low
or "nodename nor servname" in low or "temporary failure in name" in low):
return "dns_fail"
if "connection refused" in low or "connection reset" in low:
return "conn_refused"
if "ssl" in low and ("verify" in low or "certificate" in low):
return "ssl_verify"
if "permission denied" in low or "read-only file system" in low:
return "perm_denied"
if "no module named pip" in low or "no module named ensurepip" in low:
return "no_pip"
if "no space left" in low or "disk quota" in low:
return "disk_full"
if "proxy" in low and ("authent" in low or "tunnel" in low or "407" in low):
return "proxy_auth"
if "timeout" in low or "timed out" in low:
return "stderr_timeout"
tail = next((ln.strip() for ln in reversed(s.splitlines()) if ln.strip()), "")[:60]
return f"other:{tail}" if tail else "other"
def _target_dir(state_dir) -> Path:
return Path(state_dir) / "agent-sdk-libs"
def _target_sdk_importable(state_dir) -> bool:
"""True iff the --target libs dir has an importable claude_agent_sdk,
probed with THIS interpreter (the one llm.py will import it from) and the
target dir prepended to sys.path. Cheap dir-check first to avoid a
subprocess on the common no-target path."""
target = _target_dir(state_dir)
if not (target / "claude_agent_sdk").is_dir():
return False
try:
r = subprocess.run(
[sys.executable, "-c",
"import sys; sys.path.insert(0, sys.argv[1]); import claude_agent_sdk",
str(target)],
capture_output=True, timeout=10,
)
return r.returncode == 0
except Exception:
return False
def _build_via_target(state_dir) -> tuple[int, str, str]:
"""Fallback install when `python -m venv` can't bootstrap pip (ensurepip
missing Debian python3-venv absent, or a python.org/pyenv build without
ensurepip). `pip install --target <dir>` needs only the system pip, not
venv/ensurepip. v2.0.4 telemetry (sdk_has_pip) confirmed ~95% of
venv_ensurepip_fail users have pip. The consumer (llm.py) adds this flat
dir to sys.path. Returns (outcome, err_phase, err_kind).
--upgrade so a stale/partial target dir from a prior failed attempt
doesn't make pip refuse; --prefer-binary mirrors the venv path's wheel
preference (ARM64 Windows cryptography)."""
target = _target_dir(state_dir)
try:
subprocess.run(
[sys.executable, "-m", "pip", "install",
"--target", str(target), "--upgrade",
"--disable-pip-version-check", "--prefer-binary",
"claude-agent-sdk"],
capture_output=True, timeout=120, check=True,
)
return BUILT_TARGET, "", ""
except subprocess.CalledProcessError as e:
return BUILD_FAILED, "pip_target", _pip_err_from_stderr(e.stderr)
except subprocess.TimeoutExpired:
return BUILD_FAILED, "pip_target", "subprocess_timeout"
except Exception as e:
errno = getattr(e, "errno", None)
if isinstance(errno, int):
return BUILD_FAILED, "pip_target", f"exc:{type(e).__name__}:{errno}"
return BUILD_FAILED, "pip_target", f"exc:{type(e).__name__}"
def _sdk_on_syspath() -> bool: def _sdk_on_syspath() -> bool:
# find_spec is ~10ms; actually importing the SDK pulls in # find_spec is ~10ms; actually importing the SDK pulls in
# transitive deps and costs ~800ms — too heavy for a # transitive deps and costs ~800ms — too heavy for a
@ -330,6 +430,12 @@ def main() -> tuple[int, str, str]:
except Exception: except Exception:
pass # broken venv; rebuild below pass # broken venv; rebuild below
# If a prior run installed the SDK via the --target fallback (ensurepip
# path), reuse it. Only reached when there's no working venv, so healthy
# NOOP_VENV users never pay for this probe.
if _target_sdk_importable(state_dir):
return NOOP_TARGET, "", ""
err_phase = "" err_phase = ""
err_kind = "" err_kind = ""
we_own_sentinel = False we_own_sentinel = False
@ -444,6 +550,16 @@ def main() -> tuple[int, str, str]:
"", "",
)[:60] )[:60]
err_kind = f"other:{tail}" if tail else "other" err_kind = f"other:{tail}" if tail else "other"
# venv couldn't bootstrap pip (ensurepip missing) but pip itself may
# work — fall back to a flat `pip install --target`. Only this one
# category falls through; every other venv/pip failure is terminal.
# The finally block unlinks our sentinel first (so the target build
# isn't blocked by it); _build_via_target does the target install.
if err_kind == "venv_ensurepip_fail":
if we_own_sentinel:
sentinel.unlink(missing_ok=True)
we_own_sentinel = False
return _build_via_target(state_dir)
return BUILD_FAILED, err_phase, err_kind return BUILD_FAILED, err_phase, err_kind
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return BUILD_FAILED, err_phase, "subprocess_timeout" return BUILD_FAILED, err_phase, "subprocess_timeout"

View File

@ -55,6 +55,12 @@ def _inject_agent_sdk_venv_into_syspath(state_dir):
candidates = ( candidates = (
glob.glob(os.path.join(venv_root, "lib", "python*", "site-packages")) glob.glob(os.path.join(venv_root, "lib", "python*", "site-packages"))
+ glob.glob(os.path.join(venv_root, "Lib", "site-packages")) + glob.glob(os.path.join(venv_root, "Lib", "site-packages"))
# `pip install --target` fallback (ensure_agent_sdk BUILT_TARGET, used
# when venv can't bootstrap pip): a FLAT layout — packages sit directly
# in agent-sdk-libs/, not under a site-packages subdir. See #2154
# follow-up. The pywin32 .pth bootstrap below applies here too (target
# installs don't process .pth at runtime, same as a manual venv insert).
+ [os.path.join(state_dir, "agent-sdk-libs")]
) )
added = False added = False
for sp in candidates: for sp in candidates: