spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / security-guidance / hooks / ensure_agent_sdk.py
862 lines · 40.9 KBRaw
1#!/usr/bin/env python3
2"""SessionStart bootstrap: ensure claude_agent_sdk is importable for the
3agentic commit reviewer.
4
5If claude_agent_sdk already imports in the current python3, this is a no-op.
6Otherwise it creates a venv at ~/.claude/security/agent-sdk-venv and installs
7the SDK there. security_reminder_hook.py prepends that venv's site-packages to
8sys.path before attempting the SDK import, so the venv is used as a
9fallback only when the system install is missing.
10
11The venv lives under ~/.claude/security/ (same dir the plugin already uses
12for per-session state) so it persists across plugin updates — rebuilding
13on every update is 30-60s of wasted work for a package that changes far
14less often than the plugin does.
15"""
16from __future__ import annotations
17
18import importlib.util
19import json
20import os
21import subprocess
22import sys
23import time
24from pathlib import Path
25
26# Shared state-dir resolver: SECURITY_WARNINGS_STATE_DIR → CLAUDE_CONFIG_DIR/security
27# → ~/.claude/security. See _base.state_dir for resolution precedence. Re-aliased
28# here to match the existing local name (state_dir was already a local var in
29# main() and _maybe_emit_user_notice).
30from _base import state_dir as _resolve_state_dir
31
32# Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry.
33NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python
34NOOP_VENV = 1 # venv already built and SDK imports from it
35BUILT = 2 # venv created + SDK pip-installed this run
36BUILD_FAILED = 3 # venv create or pip install raised/timed out
37# Outcome 4 was previously SKIP_WIN32; retired now that the consumer glob in
38# llm.py also matches Windows venv layout (Lib/site-packages). Don't reuse the
39# value — telemetry rows from older plugin builds still emit 4.
40SKIP_SENTINEL = 5 # another SessionStart is currently building
41HOOK_PY_INCOMPATIBLE = 6 # hook interpreter is <3.10 — SDK syntax can't load
42 # here no matter how the venv was built. See #2071.
43# --target fallback: when `python -m venv` can't bootstrap pip (ensurepip
44# missing — Debian python3-venv not installed, or a python.org/pyenv build
45# without ensurepip), fall back to `pip install --target <dir>` which needs
46# only the system pip, not venv/ensurepip. Telemetry (v2.0.4 sdk_has_pip
47# probe) confirmed ~95% of venv_ensurepip_fail users HAVE pip, so this
48# recovers the agentic reviewer for them instead of degrading to pattern +
49# single-shot review. See #2154 follow-up.
50BUILT_TARGET = 7 # venv ensurepip failed → SDK pip-installed via --target
51NOOP_TARGET = 8 # --target libs already present and importable
52SKIP_COOLDOWN = 9 # a recent build was signal-killed (memory pressure) — not
53 # retrying this session to avoid burning the user's
54 # memory/CPU on a build that keeps getting killed. CCR
55 # repro confirmed the dominant Linux BUILD_FAILED is a
56 # SIGKILL/SIGSEGV of the memory-heavy venv+pip subprocess
57 # (rc<0, empty streams). See #2154 follow-up.
58
59# How long to skip rebuilds after a signal kill. Retries at most once per
60# window so a machine whose memory frees up still recovers (just not every
61# session). Keyed by marker mtime.
62SIGNAL_KILL_COOLDOWN_SEC = 24 * 3600
63
64
65# Phase + err-kind integer encoding for sdk_bootstrap_phase / sdk_bootstrap_err.
66#
67# Earlier versions emitted these as STRINGS (e.g. "pip", "dns_fail"). CC's
68# plugin-metrics pipeline silently drops plugin-emitted string values —
69# only `bool|finite-number` plugin metrics reach BigQuery. (CC-core
70# metrics like `subscription_type` are exempt because they're injected
71# downstream of plugin validation.) Confirmed empirically: 185K
72# BUILD_FAILED rows in BQ had `sdk_bootstrap_phase`/`sdk_bootstrap_err`
73# = NULL despite the Python code emitting them. This left ~28K
74# BUILD_FAILED sessions/day with no diagnostic split — flying blind on
75# the real failure modes (pip-no-match vs dns-fail vs ssl-verify etc.).
76#
77# Fix: encode as small integers per the maps below. Values are
78# APPEND-ONLY for telemetry stability. Reserve 99 as the "unknown /
79# uncategorized" bucket so an unmapped err_kind (e.g., a new exception
80# type) still emits a non-zero signal.
81SDK_BOOTSTRAP_PHASE_CODES = {
82 "pre": 1, # pre-venv (state_dir.mkdir, sentinel open)
83 "venv": 2, # python -m venv --clear
84 "pip": 3, # pip install
85 "main": 4, # uncaught exception above main()
86 "pip_target": 5, # `pip install --target` fallback (venv ensurepip failed)
87}
88SDK_BOOTSTRAP_ERR_CODES = {
89 "pip_no_match": 1,
90 "dns_fail": 2,
91 "conn_refused": 3,
92 "ssl_verify": 4,
93 "perm_denied": 5,
94 "no_pip": 6,
95 "disk_full": 7,
96 "proxy_auth": 8,
97 "stderr_timeout": 9, # pip stderr containing "timeout"/"timed out"
98 "subprocess_timeout": 10, # subprocess.TimeoutExpired (>120s)
99 "signal_killed": 16, # venv/pip subprocess killed by a signal
100 # (rc<0 or 128+sig) — OOM-killer SIGKILL /
101 # RLIMIT_AS SIGSEGV, empty streams. The
102 # actual rc rides in sdk_bootstrap_rc. This
103 # is the dominant Linux failure (CCR repro).
104 # Venv-stage specific categories added after PR #2112 telemetry surfaced
105 # 2,406 phase=2/err=99 sessions in the first 3h of v2.0.1 — venv phase
106 # failing in ways the original pip-flavored patterns didn't catch. These
107 # all split out of what was previously collapsing to _uncategorized.
108 "venv_ensurepip_fail": 11, # Debian/Ubuntu missing python3-venv;
109 # stderr mentions ensurepip non-zero exit
110 # or "ensurepip is not available"
111 "venv_path_too_long": 12, # Windows MAX_PATH (260) or POSIX
112 # ENAMETOOLONG — venv writes deep paths
113 # under state_dir/agent-sdk-venv/Lib/...
114 "venv_no_module": 13, # `python3 -m venv` itself missing — "No
115 # module named 'venv'" / "No module named venv"
116 "venv_already_exists": 14, # Errno 17 / "file exists" — sentinel race
117 # past O_EXCL or stale dir survived --clear
118 "venv_setup_failed": 15, # Generic "virtual environment was not
119 # created successfully" — catches the long
120 # tail of venv setup failures that don't
121 # match a more specific category above
122 # 16–98 reserved for future categories; APPEND-ONLY.
123 # 99 catches everything else (including "exc:<TypeName>" and "other:<tail>"
124 # — the original string is debug-loggable but the integer is what makes
125 # it to telemetry). For the "other:" tail, `sdk_bootstrap_stderr_sig`
126 # carries a bounded integer hash so we can still distinguish patterns
127 # in BQ aggregation.
128 "_uncategorized": 99,
129}
130
131# Exception-type encoding for the "exc:<TypeName>" err_kinds (the generic
132# `except Exception` path — venv/pip raised a Python exception rather than
133# a CalledProcessError with categorizable stderr).
134#
135# #2154 telemetry surfaced that the dominant remaining venv BUILD_FAILED
136# bucket (phase=venv, err=99) is ~99% `exc:` with stderr_sig=NULL — i.e.
137# exceptions, not stderr-bearing subprocess failures — so the stderr_sig
138# hash couldn't distinguish them. This maps the exception TYPE to a stable
139# code so BQ can tell FileNotFoundError (python/venv binary missing) from
140# PermissionError (read-only home) from a bare OSError, etc.
141#
142# All the FileNotFoundError/PermissionError/etc. entries are OSError
143# subclasses, so they ALSO carry an errno (see _encode_errno) — the type
144# code gives the Python class, errno gives the OS-level cause. APPEND-ONLY.
145SDK_BOOTSTRAP_EXC_CODES = {
146 "FileNotFoundError": 1, # interpreter/venv path component missing
147 "PermissionError": 2, # read-only home, sandboxed FS
148 "NotADirectoryError": 3,
149 "IsADirectoryError": 4,
150 "FileExistsError": 5, # (sentinel race is handled separately; this
151 # is FileExistsError from elsewhere in venv)
152 "OSError": 6, # bare OSError — errno carries the real cause
153 "BlockingIOError": 7,
154 "BrokenPipeError": 8,
155 "ConnectionError": 9,
156 "TimeoutError": 10, # distinct from subprocess.TimeoutExpired
157 "InterruptedError": 11,
158 "MemoryError": 12,
159 "UnicodeDecodeError": 13,
160 "ValueError": 14,
161 "RuntimeError": 15,
162 # 16–98 reserved; APPEND-ONLY.
163 "_other_exc": 99, # an exception type not in this map
164}
165
166
167def _encode_phase(s):
168 """Map err_phase string to its telemetry integer code, or 0 if unset.
169 Empty/None → 0 lets `if encoded:` cleanly skip emission. Per
170 SDK_BOOTSTRAP_PHASE_CODES, valid codes are 1-4."""
171 return SDK_BOOTSTRAP_PHASE_CODES.get((s or "").strip(), 0)
172
173
174def _encode_err_kind(s):
175 """Map err_kind string to its telemetry integer code, or 0 if unset.
176 Direct hits use the static map; "exc:<X>" and "other:<tail>" both
177 collapse to _uncategorized (99) — the raw string survives in debug
178 logs, only the integer reaches BQ."""
179 s = (s or "").strip()
180 if not s:
181 return 0
182 if s in SDK_BOOTSTRAP_ERR_CODES:
183 return SDK_BOOTSTRAP_ERR_CODES[s]
184 # "signal_killed:<rc>" carries the returncode in sdk_bootstrap_rc; the
185 # category maps to the signal_killed code.
186 if s.startswith("signal_killed"):
187 return SDK_BOOTSTRAP_ERR_CODES["signal_killed"]
188 # Prefix matches for the catch-all categories
189 if s.startswith("exc:") or s.startswith("other:") or s == "other":
190 return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
191 # Unknown string — still emit as uncategorized rather than dropping
192 return SDK_BOOTSTRAP_ERR_CODES["_uncategorized"]
193
194
195def _encode_rc(err_kind):
196 """Extract the subprocess returncode embedded in a 'signal_killed:<rc>'
197 err_kind (e.g. -11 SIGSEGV / -9 SIGKILL / 139 shell-wrapped). Emitted as
198 sdk_bootstrap_rc so BQ can tell OOM-killer (-9) from RLIMIT_AS (-11).
199 Returns 0 when absent/non-numeric."""
200 if not err_kind or not err_kind.startswith("signal_killed:"):
201 return 0
202 try:
203 return int(err_kind.split(":", 1)[1])
204 except (ValueError, IndexError):
205 return 0
206
207
208def _is_signal_kill(returncode) -> bool:
209 """A subprocess killed by a signal rather than a clean non-zero exit.
210 subprocess.run (no shell, as used here) reports negative rc = -signum
211 (SIGKILL→-9 OOM-killer, SIGSEGV→-11 RLIMIT_AS, SIGABRT→-6). The 128+sig
212 forms (134/137/139) are defensive for any shell-wrapped path. Paired with
213 empty stdout+stderr this is the memory-kill signature (CCR repro)."""
214 if returncode is None:
215 return False
216 return returncode < 0 or returncode in (134, 137, 139)
217
218
219def _cooldown_remaining(state_dir) -> float:
220 """Seconds left in the signal-kill cooldown (0 if none/expired). Reads the
221 marker's mtime; a missing/unreadable marker means not in cooldown."""
222 marker = Path(state_dir) / "agent-sdk-venv.cooldown"
223 try:
224 age = time.time() - marker.stat().st_mtime
225 except OSError:
226 return 0.0
227 return max(0.0, SIGNAL_KILL_COOLDOWN_SEC - age)
228
229
230def _write_cooldown(state_dir) -> None:
231 """Start/refresh the signal-kill cooldown so we stop re-attempting a build
232 that keeps getting killed every session. Best-effort."""
233 try:
234 Path(state_dir).mkdir(parents=True, exist_ok=True)
235 (Path(state_dir) / "agent-sdk-venv.cooldown").write_text(
236 time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
237 except OSError:
238 pass
239
240
241def _encode_stderr_sig(err_kind):
242 """Bounded integer hash of the stderr tail captured in "other:<tail>"
243 err_kinds. Lets us distinguish patterns INSIDE the _uncategorized
244 (code 99) bucket without unbounded cardinality.
245
246 Returns 0 for non-"other:" err_kinds (so the field auto-omits from
247 emit_metrics on categorized failures — see the emit block in main()).
248
249 Strategy: take the tail's first ~30 chars (post-lowercase, post-trim),
250 SHA-1, fold the first 2 bytes to 0–999. Different stderr messages
251 cluster into different buckets; same stderr always maps to the same
252 bucket. Cardinality is bounded at 1000, well below any "high
253 cardinality" alarm — and a real failure mode typically produces
254 near-identical stderr across thousands of machines, so 1000 buckets
255 is comfortably wide.
256
257 Why first ~30 chars: stderr like "ERROR: Command failed: <full
258 path>" varies the tail wildly (paths) but the categorization signal
259 is in the leading words. Dropping the suffix focuses the hash on
260 the discriminative part.
261 """
262 if not err_kind or not err_kind.startswith("other:"):
263 return 0
264 import hashlib
265 tail = err_kind[len("other:"):].strip().lower()[:30]
266 if not tail:
267 return 0
268 h = hashlib.sha1(tail.encode("utf-8", errors="replace")).digest()
269 return int.from_bytes(h[:2], "big") % 1000
270
271
272def _encode_exc_kind(err_kind):
273 """Map an "exc:<TypeName>[:errno]" err_kind to its exception-type code
274 (SDK_BOOTSTRAP_EXC_CODES). Returns 0 for non-exc err_kinds (so the
275 sdk_bootstrap_exc field auto-omits on stderr/categorized failures).
276 Unmapped exception types → 99 (_other_exc)."""
277 if not err_kind or not err_kind.startswith("exc:"):
278 return 0
279 # "exc:OSError:28" → "OSError"; "exc:RuntimeError" → "RuntimeError"
280 name = err_kind[len("exc:"):].split(":", 1)[0].strip()
281 if not name:
282 return 0
283 return SDK_BOOTSTRAP_EXC_CODES.get(name, SDK_BOOTSTRAP_EXC_CODES["_other_exc"])
284
285
286def _encode_errno(err_kind):
287 """Extract the OS errno from an "exc:<TypeName>:<errno>" err_kind.
288 OSError-family exceptions embed their errno (ENOENT=2, EACCES=13,
289 ENOSPC=28, …) — the OS-level cause is far more actionable than the
290 Python class alone. Returns 0 when absent/non-numeric (field omitted)."""
291 if not err_kind or not err_kind.startswith("exc:"):
292 return 0
293 parts = err_kind.split(":")
294 if len(parts) < 3:
295 return 0
296 try:
297 return int(parts[2])
298 except (ValueError, IndexError):
299 return 0
300
301
302def _probe_has_pip() -> bool:
303 """True iff the current interpreter can run pip (`-m pip --version`).
304
305 Probed only on the venv_ensurepip_fail path (see __main__), NOT on the
306 happy path — it's an extra subprocess we only want when diagnosing a
307 failure. The result decides whether a `pip install --target` fallback
308 (Option A) is even viable for this machine: ensurepip/venv missing but
309 pip present → --target would work; pip also missing → it wouldn't, and
310 the user needs a system package (python3-venv / a complete Python)."""
311 try:
312 r = subprocess.run(
313 [sys.executable, "-m", "pip", "--version"],
314 capture_output=True, timeout=10,
315 )
316 return r.returncode == 0
317 except Exception:
318 return False
319
320
321def _probe_alt_python() -> int:
322 """When the hook interpreter is <3.10 (HOOK_PY_INCOMPATIBLE), look for a
323 3.10+ interpreter at well-known install locations that aren't necessarily
324 on the hook's PATH — Homebrew (/opt/homebrew, /usr/local), python.org
325 framework builds, and the `py`/distro layouts. Returns the HIGHEST version
326 found encoded as major*100+minor (e.g. 312), or 0 if none.
327
328 Purpose (telemetry only, for now): size how many of the macOS Python-3.9
329 cohort actually HAVE a newer interpreter that sg-python.sh's PATH probe
330 missed — i.e. how many are RECOVERABLE by an explicit-path search vs.
331 genuinely 3.9-only. Emitted as sdk_alt_py. Existence-checks the versioned
332 binaries (cheap); a later explicit-path search would version-verify before
333 exec'ing. Probed only on the incompatible path, so healthy sessions never
334 pay for it."""
335 candidates = []
336 for minor in (14, 13, 12, 11, 10):
337 candidates += [
338 f"/opt/homebrew/bin/python3.{minor}", # Apple-Silicon Homebrew
339 f"/usr/local/bin/python3.{minor}", # Intel Homebrew / python.org shim
340 f"/Library/Frameworks/Python.framework/Versions/3.{minor}/bin/python3", # python.org
341 f"/usr/bin/python3.{minor}", # distro-managed (Linux)
342 ]
343 best = 0
344 for path in candidates:
345 try:
346 if os.access(path, os.X_OK):
347 # path name encodes the minor; parse it back to a code
348 base = os.path.basename(path)
349 minor = None
350 if base.startswith("python3."):
351 minor = int(base.split(".")[1])
352 elif "/Versions/3." in path:
353 minor = int(path.split("/Versions/3.")[1].split("/")[0])
354 if minor is not None:
355 best = max(best, 300 + minor)
356 except (OSError, ValueError, IndexError):
357 continue
358 return best
359
360
361def _pip_err_from_stderr(stderr_b):
362 """Categorize a pip-install stderr into a known err_kind (the pip subset
363 of SDK_BOOTSTRAP_ERR_CODES). Used by the --target fallback; mirrors the
364 pip branches of main()'s inline categorizer. Kept as a sibling rather
365 than extracting main()'s chain (which also has venv-phase branches) to
366 avoid disturbing the working venv categorization."""
367 if isinstance(stderr_b, bytes):
368 s = stderr_b.decode("utf-8", errors="replace")
369 else:
370 s = str(stderr_b or "")
371 low = s.lower()
372 if "no matching distribution" in low or "could not find a version" in low:
373 return "pip_no_match"
374 if ("name or service not known" in low or "name resolution" in low
375 or "nodename nor servname" in low or "temporary failure in name" in low):
376 return "dns_fail"
377 if "connection refused" in low or "connection reset" in low:
378 return "conn_refused"
379 if "ssl" in low and ("verify" in low or "certificate" in low):
380 return "ssl_verify"
381 if "permission denied" in low or "read-only file system" in low:
382 return "perm_denied"
383 if "no module named pip" in low or "no module named ensurepip" in low:
384 return "no_pip"
385 if "no space left" in low or "disk quota" in low:
386 return "disk_full"
387 if "proxy" in low and ("authent" in low or "tunnel" in low or "407" in low):
388 return "proxy_auth"
389 if "timeout" in low or "timed out" in low:
390 return "stderr_timeout"
391 tail = next((ln.strip() for ln in reversed(s.splitlines()) if ln.strip()), "")[:60]
392 return f"other:{tail}" if tail else "other"
393
394
395def _target_dir(state_dir) -> Path:
396 return Path(state_dir) / "agent-sdk-libs"
397
398
399def _target_sdk_importable(state_dir) -> bool:
400 """True iff the --target libs dir has an importable claude_agent_sdk,
401 probed with THIS interpreter (the one llm.py will import it from) and the
402 target dir prepended to sys.path. Cheap dir-check first to avoid a
403 subprocess on the common no-target path."""
404 target = _target_dir(state_dir)
405 if not (target / "claude_agent_sdk").is_dir():
406 return False
407 try:
408 r = subprocess.run(
409 [sys.executable, "-c",
410 "import sys; sys.path.insert(0, sys.argv[1]); import claude_agent_sdk",
411 str(target)],
412 capture_output=True, timeout=10,
413 )
414 return r.returncode == 0
415 except Exception:
416 return False
417
418
419def _build_via_target(state_dir) -> tuple[int, str, str]:
420 """Fallback install when `python -m venv` can't bootstrap pip (ensurepip
421 missing — Debian python3-venv absent, or a python.org/pyenv build without
422 ensurepip). `pip install --target <dir>` needs only the system pip, not
423 venv/ensurepip. v2.0.4 telemetry (sdk_has_pip) confirmed ~95% of
424 venv_ensurepip_fail users have pip. The consumer (llm.py) adds this flat
425 dir to sys.path. Returns (outcome, err_phase, err_kind).
426
427 --upgrade so a stale/partial target dir from a prior failed attempt
428 doesn't make pip refuse; --prefer-binary mirrors the venv path's wheel
429 preference (ARM64 Windows cryptography)."""
430 target = _target_dir(state_dir)
431 try:
432 subprocess.run(
433 [sys.executable, "-m", "pip", "install",
434 "--target", str(target), "--upgrade",
435 "--disable-pip-version-check", "--prefer-binary", "--no-cache-dir",
436 "claude-agent-sdk"],
437 capture_output=True, timeout=120, check=True,
438 )
439 return BUILT_TARGET, "", ""
440 except subprocess.CalledProcessError as e:
441 # A --target pip install is also memory-heavy, so it too can be
442 # signal-killed under memory pressure — cool down, same as the venv path.
443 if _is_signal_kill(e.returncode):
444 _write_cooldown(state_dir)
445 return BUILD_FAILED, "pip_target", f"signal_killed:{e.returncode}"
446 return BUILD_FAILED, "pip_target", _pip_err_from_stderr(e.stderr)
447 except subprocess.TimeoutExpired:
448 return BUILD_FAILED, "pip_target", "subprocess_timeout"
449 except Exception as e:
450 errno = getattr(e, "errno", None)
451 if isinstance(errno, int):
452 return BUILD_FAILED, "pip_target", f"exc:{type(e).__name__}:{errno}"
453 return BUILD_FAILED, "pip_target", f"exc:{type(e).__name__}"
454
455
456def _sdk_on_syspath() -> bool:
457 # find_spec is ~10ms; actually importing the SDK pulls in
458 # transitive deps and costs ~800ms — too heavy for a
459 # per-SessionStart no-op check that most sessions hit.
460 try:
461 return importlib.util.find_spec("claude_agent_sdk") is not None
462 except Exception:
463 return False
464
465
466def _plugin_version_int() -> int:
467 # Same encoding as security_reminder_hook._read_plugin_version_int so
468 # metrics rows from both hooks join on pv.
469 try:
470 p = Path(__file__).parent.parent / ".claude-plugin" / "plugin.json"
471 v = json.loads(p.read_text())["version"]
472 major, minor, patch = (int(x) for x in v.split(".")[:3])
473 return major * 10000 + minor * 100 + patch
474 except Exception:
475 return 0
476
477
478def main() -> tuple[int, str, str]:
479 """Run the bootstrap. Returns (outcome, err_phase, err_kind).
480
481 err_phase / err_kind are non-empty only on BUILD_FAILED — they let
482 telemetry split bootstrap failures by root cause.
483 """
484 # Honesty check (fixes the misleading NOOP_VENV in #2071): the SDK
485 # requires Python >=3.10 and uses 3.10+ syntax (match statements,
486 # PEP 604 unions). On a 3.9 hook interpreter we CANNOT import it no
487 # matter how the venv was built — llm.py runs in this same interpreter
488 # and the syntax-level import will SyntaxError. macOS ships 3.9.6 as
489 # the default `python3` and `/usr/bin` precedes Homebrew in PATH, so
490 # this case is the default state for a large share of macOS users.
491 #
492 # sg-python.sh now prefers python3.10+ binaries so most users won't
493 # reach this branch; the fallback to 3.9 is preserved for the
494 # pattern-warning hooks that don't need the SDK. Reporting
495 # HOOK_PY_INCOMPATIBLE here:
496 # (a) avoids 30-60s of wasted pip install,
497 # (b) avoids the lie where the venv_py probe says NOOP_VENV but the
498 # consumer import fails, and
499 # (c) gives telemetry a clean bucket to size the affected fleet.
500 if sys.version_info < (3, 10):
501 return (
502 HOOK_PY_INCOMPATIBLE,
503 "hook_py",
504 f"py_{sys.version_info[0]}.{sys.version_info[1]}",
505 )
506
507 if _sdk_on_syspath():
508 return NOOP_SYSTEM, "", ""
509
510 state_dir = Path(_resolve_state_dir())
511 venv = state_dir / "agent-sdk-venv"
512 # Windows venvs put the interpreter at Scripts\python.exe; POSIX uses bin/python.
513 if sys.platform == "win32":
514 venv_py = venv / "Scripts" / "python.exe"
515 else:
516 venv_py = venv / "bin" / "python"
517
518 # Another SessionStart (concurrent CC instance, same plugin) may already
519 # be building. The sentinel lives NEXT TO the venv, not inside it —
520 # `python -m venv --clear` wipes the target dir's contents, so an
521 # in-venv sentinel would be deleted the instant we create the venv.
522 # Stale sentinels (>5min) from a SIGKILL'd build are ignored.
523 sentinel = state_dir / "agent-sdk-venv.building"
524 if sentinel.exists():
525 try:
526 if time.time() - sentinel.stat().st_mtime < 300:
527 return SKIP_SENTINEL, "", ""
528 sentinel.unlink(missing_ok=True)
529 except OSError:
530 return SKIP_SENTINEL, "", ""
531
532 # If a venv already exists and its python can import the SDK, done.
533 if venv_py.exists():
534 try:
535 r = subprocess.run(
536 [str(venv_py), "-c", "import claude_agent_sdk"],
537 capture_output=True, timeout=10,
538 )
539 if r.returncode == 0:
540 return NOOP_VENV, "", ""
541 except Exception:
542 pass # broken venv; rebuild below
543
544 # If a prior run installed the SDK via the --target fallback (ensurepip
545 # path), reuse it. Only reached when there's no working venv, so healthy
546 # NOOP_VENV users never pay for this probe.
547 if _target_sdk_importable(state_dir):
548 return NOOP_TARGET, "", ""
549
550 # If a recent build was signal-killed (memory pressure), don't re-attempt
551 # this session — the memory-heavy venv+pip just gets killed again, burning
552 # the user's resources. Retry at most once per cooldown window. Reached
553 # only after all no-op probes, so a machine that later gets the SDK via
554 # system/venv/target still short-circuits above.
555 if _cooldown_remaining(state_dir) > 0:
556 return SKIP_COOLDOWN, "", ""
557
558 err_phase = ""
559 err_kind = ""
560 we_own_sentinel = False
561 try:
562 state_dir.mkdir(parents=True, exist_ok=True)
563 # O_EXCL makes the sentinel an atomic lock — if two SessionStarts
564 # race past the exists() check above, only one creates it.
565 try:
566 os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
567 except FileExistsError:
568 return SKIP_SENTINEL, "", ""
569 we_own_sentinel = True
570 err_phase = "venv"
571 subprocess.run(
572 [sys.executable, "-m", "venv", "--clear", str(venv)],
573 capture_output=True, timeout=60, check=True,
574 )
575 # Some machines route pip through a private registry; we
576 # don't pass --index-url here so we inherit that default. Outside
577 # the user's machine, pip's own default registry applies — that's the same
578 # exposure the user would have running `pip install` themselves, so
579 # we're not widening the supply-chain surface.
580 #
581 # --prefer-binary: on ARM64 Windows, pip's default resolver picks a
582 # `cryptography` version with no published binary wheel and tries to
583 # build from source, which needs Rust/Cargo (almost never present
584 # on user machines). The build fails and the whole bootstrap returns
585 # BUILD_FAILED. A binary wheel exists on PyPI for an adjacent
586 # version (`cryptography-46.0.3-cp311-abi3-win_arm64.whl`);
587 # --prefer-binary tells pip to pick it. Cross-platform safe: no-op
588 # on platforms where the latest version already has a wheel.
589 err_phase = "pip"
590 # --no-cache-dir trims pip's peak memory (no cache read/write/unpack
591 # buffering) — helps marginal low-memory machines get under the OOM
592 # threshold that kills the dominant Linux builds (CCR repro).
593 subprocess.run(
594 [str(venv_py), "-m", "pip", "install", "--quiet",
595 "--disable-pip-version-check", "--prefer-binary", "--no-cache-dir",
596 "claude-agent-sdk"],
597 capture_output=True, timeout=120, check=True,
598 )
599 return BUILT, "", ""
600 except subprocess.CalledProcessError as e:
601 # Signal kill (OOM-killer SIGKILL / RLIMIT_AS SIGSEGV) — rc<0, empty
602 # streams. The dominant Linux failure. Record the rc, start a cooldown
603 # so we stop retry-storming a build that keeps getting killed, and
604 # skip the stderr categorization (there's nothing in stderr). err_phase
605 # says whether it died creating the venv or installing via pip.
606 if _is_signal_kill(e.returncode):
607 _write_cooldown(state_dir)
608 return BUILD_FAILED, err_phase, f"signal_killed:{e.returncode}"
609 # Capture a stderr fingerprint so telemetry can split BUILD_FAILED by
610 # root cause (no-network, package-not-found, dns-fail, etc.).
611 # Categorize first, then keep a short raw tail for the long tail of
612 # unexpected modes.
613 stderr_b = e.stderr or b""
614 if isinstance(stderr_b, bytes):
615 stderr_str = stderr_b.decode("utf-8", errors="replace")
616 else:
617 stderr_str = str(stderr_b)
618 s = stderr_str.lower()
619 # Venv-specific patterns checked FIRST — they overlap with some pip
620 # patterns (e.g. "no module named ensurepip" could match no_pip OR
621 # venv_ensurepip_fail; the venv-stage interpretation is the right
622 # one when err_phase=="venv"). Order is venv-most-specific →
623 # pip-historical → generic.
624 if err_phase == "venv" and (
625 "ensurepip is not available" in s
626 or ("ensurepip" in s and "returned non-zero" in s)
627 or "the virtual environment was not created" in s and "ensurepip" in s
628 ):
629 err_kind = "venv_ensurepip_fail"
630 elif err_phase == "venv" and (
631 "[errno 36]" in s
632 or "file name too long" in s
633 or "path too long" in s
634 ):
635 err_kind = "venv_path_too_long"
636 elif err_phase == "venv" and (
637 "no module named venv" in s
638 or "no module named 'venv'" in s
639 ):
640 err_kind = "venv_no_module"
641 elif err_phase == "venv" and (
642 "[errno 17]" in s
643 or ("file exists" in s and "venv" in s)
644 ):
645 err_kind = "venv_already_exists"
646 elif "no matching distribution" in s or "could not find a version" in s:
647 err_kind = "pip_no_match"
648 elif "name or service not known" in s or "name resolution" in s \
649 or "nodename nor servname" in s or "temporary failure in name" in s:
650 err_kind = "dns_fail"
651 elif "connection refused" in s or "connection reset" in s:
652 err_kind = "conn_refused"
653 elif "ssl" in s and ("verify" in s or "certificate" in s):
654 err_kind = "ssl_verify"
655 elif "permission denied" in s or "read-only file system" in s:
656 err_kind = "perm_denied"
657 elif "no module named pip" in s or "no module named ensurepip" in s:
658 err_kind = "no_pip"
659 elif "no space left" in s or "disk quota" in s:
660 err_kind = "disk_full"
661 elif "proxy" in s and ("authent" in s or "tunnel" in s or "407" in s):
662 err_kind = "proxy_auth"
663 elif "timeout" in s or "timed out" in s:
664 err_kind = "stderr_timeout"
665 elif err_phase == "venv" and (
666 "virtual environment was not created" in s
667 or "error: command" in s and "venv" in s
668 ):
669 # Generic venv-setup catch-all — matched AFTER the more specific
670 # venv patterns above so we don't shadow them, but BEFORE the
671 # other: fallback so generic venv setup failures get their own
672 # bucket instead of polluting the long-tail signature space.
673 err_kind = "venv_setup_failed"
674 else:
675 # First 60 chars of the last non-empty stderr line — bounded to
676 # stay inside CC's metric value-length budget. Real failure modes
677 # we haven't categorized show up here as a low-cardinality bucket.
678 tail = next(
679 (ln.strip() for ln in reversed(stderr_str.splitlines()) if ln.strip()),
680 "",
681 )[:60]
682 err_kind = f"other:{tail}" if tail else "other"
683 # venv couldn't bootstrap pip (ensurepip missing) but pip itself may
684 # work — fall back to a flat `pip install --target`. Only this one
685 # category falls through; every other venv/pip failure is terminal.
686 # The finally block unlinks our sentinel first (so the target build
687 # isn't blocked by it); _build_via_target does the target install.
688 if err_kind == "venv_ensurepip_fail":
689 if we_own_sentinel:
690 sentinel.unlink(missing_ok=True)
691 we_own_sentinel = False
692 return _build_via_target(state_dir)
693 return BUILD_FAILED, err_phase, err_kind
694 except subprocess.TimeoutExpired:
695 return BUILD_FAILED, err_phase, "subprocess_timeout"
696 except Exception as e:
697 # Embed errno for OSError-family exceptions ("exc:OSError:28") so
698 # telemetry can decode the OS-level cause (ENOENT/EACCES/ENOSPC/…),
699 # not just the Python class. #2154 follow-up: this is the dominant
700 # remaining venv BUILD_FAILED bucket. See _encode_exc_kind/_encode_errno.
701 errno = getattr(e, "errno", None)
702 if isinstance(errno, int):
703 return BUILD_FAILED, err_phase, f"exc:{type(e).__name__}:{errno}"
704 return BUILD_FAILED, err_phase, f"exc:{type(e).__name__}"
705 finally:
706 # Only remove the sentinel if THIS process created it. The
707 # FileExistsError path above means another process owns the lock;
708 # unconditionally unlinking here would delete its sentinel and let
709 # a third concurrent SessionStart `venv --clear` over the in-flight
710 # build.
711 if we_own_sentinel:
712 sentinel.unlink(missing_ok=True)
713
714
715def _maybe_emit_user_notice(outcome: int, pv: int) -> str | None:
716 """Return a one-time user-visible notice when the agentic reviewer is
717 in a persistent broken state on this machine, or None if we've already
718 shown the notice for this plugin version (or shouldn't show one).
719
720 The marker file is plugin-version-keyed: a future plugin update can
721 re-notify if behavior changes (e.g. we ship out-of-process SDK in v3
722 and want to tell affected users it's fixed). Failures to write the
723 marker degrade to "skip the notice this session" so we don't spam
724 every SessionStart on a read-only home dir.
725
726 Currently only HOOK_PY_INCOMPATIBLE qualifies. BUILD_FAILED is
727 intentionally excluded — it covers transient causes (network failure,
728 pip registry hiccup, in-flight rebuild) where the next session may
729 succeed and a permanent notice would mislead.
730 """
731 if outcome != HOOK_PY_INCOMPATIBLE:
732 return None
733 try:
734 state_dir = Path(_resolve_state_dir())
735 marker = state_dir / f".agentic_unavailable_notice_v{pv or 0}"
736 if marker.exists():
737 return None
738 state_dir.mkdir(parents=True, exist_ok=True)
739 # Write timestamp + Python version so the marker is self-documenting
740 # if a user goes looking. O_EXCL would be racier with no real win
741 # (two concurrent SessionStarts both showing the notice once is fine).
742 marker.write_text(
743 f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} "
744 f"py={sys.version_info[0]}.{sys.version_info[1]}\n"
745 )
746 except OSError:
747 return None
748 return (
749 f"⚠ security-guidance plugin: the cross-file commit reviewer "
750 f"(layer 3 of 3 — catches IDOR, auth-bypass, cross-file SSRF) "
751 f"is unavailable in this environment. It requires Python ≥3.10, "
752 f"but the hook is running on "
753 f"{sys.version_info[0]}.{sys.version_info[1]}.\n\n"
754 f"Pattern checks and the single-shot LLM diff review are still "
755 f"active. To enable the deeper reviewer, install Python 3.10+ "
756 f"(e.g. `brew install python` on macOS) and restart Claude Code.\n\n"
757 f"This notice is shown once per plugin version. "
758 f"See: github.com/anthropics/claude-plugins-official/issues/2071"
759 )
760
761
762if __name__ == "__main__":
763 # Tell the harness this is async — venv create + pip install can take
764 # 30-60s on a cold cache, well past the default sync hook timeout.
765 # SessionStart runs before the user's first prompt; doing this in the
766 # background means the first commit-review of the session usually finds
767 # the venv ready.
768 print(json.dumps({"async": True, "asyncTimeout": 180000}), flush=True)
769 t0 = time.perf_counter()
770 try:
771 outcome, err_phase, err_kind = main()
772 except Exception as exc:
773 outcome, err_phase, err_kind = (
774 BUILD_FAILED, "main", f"exc:{type(exc).__name__}"
775 )
776 # CC's async-hook registry scans stdout line-by-line after process exit
777 # and takes the FIRST non-{"async":...} JSON line as the hook response;
778 # its `metrics` key is forwarded to the hook metrics event on the
779 # next attachments pass. Must be a single line — the registry splits on
780 # \n and json-parses each independently.
781 #
782 # IMPORTANT — values must be bool|finite-number. The validation comment
783 # has historically said "or short strings" but that was wrong: CC's
784 # plugin-metrics pipeline silently drops plugin-emitted string values.
785 # Stay inside the 10-key emit cap.
786 metrics: dict[str, object] = {
787 "sdk_bootstrap": outcome,
788 "sdk_bootstrap_ms": round((time.perf_counter() - t0) * 1000),
789 }
790 if err_kind:
791 # Encode phase + err_kind as integer codes (see
792 # SDK_BOOTSTRAP_PHASE_CODES / SDK_BOOTSTRAP_ERR_CODES). Earlier
793 # versions emitted these as strings and CC dropped them — restoring
794 # the diagnostic split that 28K BUILD_FAILED/day need to triage by
795 # root cause. err_phase defaults to "pre" when empty (pre-venv
796 # failure path, e.g. state_dir.mkdir perm-denied).
797 metrics["sdk_bootstrap_phase"] = _encode_phase(err_phase or "pre")
798 metrics["sdk_bootstrap_err"] = _encode_err_kind(err_kind)
799 # For "other:<tail>" (encoded err==99), emit a bounded integer
800 # hash of the stderr tail so BQ can distinguish patterns inside
801 # the _uncategorized bucket without unbounded cardinality. Zero
802 # when err_kind is categorized — the schema reader treats 0 as
803 # "no signal", matching the absence convention.
804 sig = _encode_stderr_sig(err_kind)
805 if sig:
806 metrics["sdk_bootstrap_stderr_sig"] = sig
807 # Exception-type + errno for the "exc:" bucket (the dominant
808 # remaining venv BUILD_FAILED mode per #2154 telemetry). Both
809 # auto-omit (0) on stderr/categorized failures.
810 exc = _encode_exc_kind(err_kind)
811 if exc:
812 metrics["sdk_bootstrap_exc"] = exc
813 exc_errno = _encode_errno(err_kind)
814 if exc_errno:
815 metrics["sdk_bootstrap_errno"] = exc_errno
816 # Subprocess returncode for signal kills (-9 OOM-killer / -11
817 # RLIMIT_AS / -6 abort). Confirms in prod which signal dominates the
818 # Linux memory-kill bucket. 0 (omitted) for non-signal failures.
819 rc = _encode_rc(err_kind)
820 if rc:
821 metrics["sdk_bootstrap_rc"] = rc
822 # venv_ensurepip_fail (code 11) is the top categorizable venv
823 # failure, and telemetry shows it's NOT just Debian — macOS has the
824 # most distinct affected users. Probe whether this interpreter has
825 # pip so we know if a `pip install --target` fallback (Option A)
826 # would actually help, vs the user needing a system package. Probed
827 # only here (not on the happy path) to avoid an extra subprocess
828 # per healthy session.
829 if _encode_err_kind(err_kind) == 11:
830 metrics["sdk_has_pip"] = _probe_has_pip()
831 # When the hook interpreter is <3.10 (HOOK_PY_INCOMPATIBLE), probe for a
832 # 3.10+ interpreter at known non-PATH locations. Non-zero sdk_alt_py =
833 # this user is RECOVERABLE by an explicit-path search in sg-python.sh; 0 =
834 # genuinely 3.9-only (needs a user install). Sizes the macOS Py-3.9 cohort
835 # (~13.6% of macOS sessions) before we build the search. Incompatible path
836 # only — healthy sessions never run it.
837 if outcome == HOOK_PY_INCOMPATIBLE:
838 metrics["sdk_alt_py"] = _probe_alt_python()
839 # Interpreter version (major*100 + minor, e.g. 309 / 312), emitted on
840 # every bootstrap. Disambiguates the macOS cohort (Apple 3.9 vs a 3.10+
841 # with broken ensurepip) for both venv_ensurepip_fail AND
842 # HOOK_PY_INCOMPATIBLE (whose "py_3.9" err_kind otherwise collapses to
843 # err=99, losing the version). Cheap — no subprocess, just sys.version_info.
844 metrics["sdk_hook_py"] = sys.version_info[0] * 100 + sys.version_info[1]
845 pv = _plugin_version_int()
846 if pv:
847 metrics["pv"] = pv
848 response: dict[str, object] = {"metrics": metrics}
849 # One-time user-visible notice when the agentic reviewer is dead on
850 # arrival. Uses hookSpecificOutput.additionalContext (SessionStart's
851 # supported channel for surfacing text to both the model and the user)
852 # plus systemMessage as a belt-and-suspenders. Marker-file-gated so
853 # this fires exactly once per plugin version per install — see
854 # _maybe_emit_user_notice.
855 notice = _maybe_emit_user_notice(outcome, pv)
856 if notice:
857 response["hookSpecificOutput"] = {
858 "hookEventName": "SessionStart",
859 "additionalContext": notice,
860 }
861 response["systemMessage"] = notice
862 print(json.dumps(response), flush=True)