diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index de524a6e..1e33ce36 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -2387,7 +2387,7 @@
{
"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.",
- "version": "2.0.4",
+ "version": "2.0.5",
"author": {
"name": "Anthropic",
"email": "support@anthropic.com"
diff --git a/plugins/security-guidance/.claude-plugin/plugin.json b/plugins/security-guidance/.claude-plugin/plugin.json
index fa96245d..83813a4a 100644
--- a/plugins/security-guidance/.claude-plugin/plugin.json
+++ b/plugins/security-guidance/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"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.",
"author": {
"name": "David Dworken",
diff --git a/plugins/security-guidance/hooks/ensure_agent_sdk.py b/plugins/security-guidance/hooks/ensure_agent_sdk.py
index 13ca4650..69df6a22 100644
--- a/plugins/security-guidance/hooks/ensure_agent_sdk.py
+++ b/plugins/security-guidance/hooks/ensure_agent_sdk.py
@@ -40,6 +40,15 @@ BUILD_FAILED = 3 # venv create or pip install raised/timed out
SKIP_SENTINEL = 5 # another SessionStart is currently building
HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
# 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
` 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.
@@ -63,6 +72,7 @@ SDK_BOOTSTRAP_PHASE_CODES = {
"venv": 2, # python -m venv --clear
"pip": 3, # pip install
"main": 4, # uncaught exception above main()
+ "pip_target": 5, # `pip install --target` fallback (venv ensurepip failed)
}
SDK_BOOTSTRAP_ERR_CODES = {
"pip_no_match": 1,
@@ -242,6 +252,96 @@ def _probe_has_pip() -> bool:
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 ` 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:
# find_spec is ~10ms; actually importing the SDK pulls in
# transitive deps and costs ~800ms — too heavy for a
@@ -330,6 +430,12 @@ def main() -> tuple[int, str, str]:
except Exception:
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_kind = ""
we_own_sentinel = False
@@ -444,6 +550,16 @@ def main() -> tuple[int, str, str]:
"",
)[:60]
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
except subprocess.TimeoutExpired:
return BUILD_FAILED, err_phase, "subprocess_timeout"
diff --git a/plugins/security-guidance/hooks/llm.py b/plugins/security-guidance/hooks/llm.py
index e2ac37db..8b7feef8 100644
--- a/plugins/security-guidance/hooks/llm.py
+++ b/plugins/security-guidance/hooks/llm.py
@@ -55,6 +55,12 @@ def _inject_agent_sdk_venv_into_syspath(state_dir):
candidates = (
glob.glob(os.path.join(venv_root, "lib", "python*", "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
for sp in candidates: