ensure_agent_sdk.py 41 KB

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