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
225 lines · 9.6 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# Outcome codes for the sdk_bootstrap metric. Values are stable for telemetry.
27NOOP_SYSTEM = 0 # claude_agent_sdk already importable in system python
28NOOP_VENV = 1 # venv already built and SDK imports from it
29BUILT = 2 # venv created + SDK pip-installed this run
30BUILD_FAILED = 3 # venv create or pip install raised/timed out
31SKIP_WIN32 = 4 # Windows; consumer glob doesn't handle Lib/ layout
32SKIP_SENTINEL = 5 # another SessionStart is currently building
33
34
35def _sdk_on_syspath() -> bool:
36 # find_spec is ~10ms; actually importing the SDK pulls in
37 # transitive deps and costs ~800ms — too heavy for a
38 # per-SessionStart no-op check that most sessions hit.
39 try:
40 return importlib.util.find_spec("claude_agent_sdk") is not None
41 except Exception:
42 return False
43
44
45def _plugin_version_int() -> int:
46 # Same encoding as security_reminder_hook._read_plugin_version_int so
47 # metrics rows from both hooks join on pv.
48 try:
49 p = Path(__file__).parent.parent / ".claude-plugin" / "plugin.json"
50 v = json.loads(p.read_text())["version"]
51 major, minor, patch = (int(x) for x in v.split(".")[:3])
52 return major * 10000 + minor * 100 + patch
53 except Exception:
54 return 0
55
56
57def main() -> tuple[int, str, str]:
58 """Run the bootstrap. Returns (outcome, err_phase, err_kind).
59
60 err_phase / err_kind are non-empty only on BUILD_FAILED — they let
61 telemetry split bootstrap failures by root cause.
62 """
63 # Windows venv layout (Lib/site-packages, no python* subdir) isn't
64 # handled by the consumer's glob in security_reminder_hook.py; skip the
65 # bootstrap entirely rather than build a venv that's never read.
66 if sys.platform == "win32":
67 return SKIP_WIN32, "", ""
68
69
70 if _sdk_on_syspath():
71 return NOOP_SYSTEM, "", ""
72
73 state_dir = Path(
74 os.environ.get("SECURITY_WARNINGS_STATE_DIR")
75 or os.path.expanduser("~/.claude/security")
76 )
77 venv = state_dir / "agent-sdk-venv"
78 venv_py = venv / "bin" / "python"
79
80 # Another SessionStart (concurrent CC instance, same plugin) may already
81 # be building. The sentinel lives NEXT TO the venv, not inside it —
82 # `python -m venv --clear` wipes the target dir's contents, so an
83 # in-venv sentinel would be deleted the instant we create the venv.
84 # Stale sentinels (>5min) from a SIGKILL'd build are ignored.
85 sentinel = state_dir / "agent-sdk-venv.building"
86 if sentinel.exists():
87 try:
88 if time.time() - sentinel.stat().st_mtime < 300:
89 return SKIP_SENTINEL, "", ""
90 sentinel.unlink(missing_ok=True)
91 except OSError:
92 return SKIP_SENTINEL, "", ""
93
94 # If a venv already exists and its python can import the SDK, done.
95 if venv_py.exists():
96 try:
97 r = subprocess.run(
98 [str(venv_py), "-c", "import claude_agent_sdk"],
99 capture_output=True, timeout=10,
100 )
101 if r.returncode == 0:
102 return NOOP_VENV, "", ""
103 except Exception:
104 pass # broken venv; rebuild below
105
106 err_phase = ""
107 err_kind = ""
108 we_own_sentinel = False
109 try:
110 state_dir.mkdir(parents=True, exist_ok=True)
111 # O_EXCL makes the sentinel an atomic lock — if two SessionStarts
112 # race past the exists() check above, only one creates it.
113 try:
114 os.close(os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
115 except FileExistsError:
116 return SKIP_SENTINEL, "", ""
117 we_own_sentinel = True
118 err_phase = "venv"
119 subprocess.run(
120 [sys.executable, "-m", "venv", "--clear", str(venv)],
121 capture_output=True, timeout=60, check=True,
122 )
123 # Some machines route pip through a private registry; we
124 # don't pass --index-url here so we inherit that default. Outside
125 # the user's machine, pip's own default registry applies — that's the same
126 # exposure the user would have running `pip install` themselves, so
127 # we're not widening the supply-chain surface.
128 err_phase = "pip"
129 subprocess.run(
130 [str(venv_py), "-m", "pip", "install", "--quiet",
131 "--disable-pip-version-check", "claude-agent-sdk"],
132 capture_output=True, timeout=120, check=True,
133 )
134 return BUILT, "", ""
135 except subprocess.CalledProcessError as e:
136 # Capture a stderr fingerprint so telemetry can split BUILD_FAILED by
137 # root cause (no-network, package-not-found, dns-fail, etc.).
138 # Categorize first, then keep a short raw tail for the long tail of
139 # unexpected modes.
140 stderr_b = e.stderr or b""
141 if isinstance(stderr_b, bytes):
142 stderr_str = stderr_b.decode("utf-8", errors="replace")
143 else:
144 stderr_str = str(stderr_b)
145 s = stderr_str.lower()
146 if "no matching distribution" in s or "could not find a version" in s:
147 err_kind = "pip_no_match"
148 elif "name or service not known" in s or "name resolution" in s \
149 or "nodename nor servname" in s or "temporary failure in name" in s:
150 err_kind = "dns_fail"
151 elif "connection refused" in s or "connection reset" in s:
152 err_kind = "conn_refused"
153 elif "ssl" in s and ("verify" in s or "certificate" in s):
154 err_kind = "ssl_verify"
155 elif "permission denied" in s or "read-only file system" in s:
156 err_kind = "perm_denied"
157 elif "no module named pip" in s or "no module named ensurepip" in s:
158 err_kind = "no_pip"
159 elif "no space left" in s or "disk quota" in s:
160 err_kind = "disk_full"
161 elif "proxy" in s and ("authent" in s or "tunnel" in s or "407" in s):
162 err_kind = "proxy_auth"
163 elif "timeout" in s or "timed out" in s:
164 err_kind = "stderr_timeout"
165 else:
166 # First 60 chars of the last non-empty stderr line — bounded to
167 # stay inside CC's metric value-length budget. Real failure modes
168 # we haven't categorized show up here as a low-cardinality bucket.
169 tail = next(
170 (ln.strip() for ln in reversed(stderr_str.splitlines()) if ln.strip()),
171 "",
172 )[:60]
173 err_kind = f"other:{tail}" if tail else "other"
174 return BUILD_FAILED, err_phase, err_kind
175 except subprocess.TimeoutExpired:
176 return BUILD_FAILED, err_phase, "subprocess_timeout"
177 except Exception as e:
178 return BUILD_FAILED, err_phase, f"exc:{type(e).__name__}"
179 finally:
180 # Only remove the sentinel if THIS process created it. The
181 # FileExistsError path above means another process owns the lock;
182 # unconditionally unlinking here would delete its sentinel and let
183 # a third concurrent SessionStart `venv --clear` over the in-flight
184 # build.
185 if we_own_sentinel:
186 sentinel.unlink(missing_ok=True)
187
188
189if __name__ == "__main__":
190 # Tell the harness this is async — venv create + pip install can take
191 # 30-60s on a cold cache, well past the default sync hook timeout.
192 # SessionStart runs before the user's first prompt; doing this in the
193 # background means the first commit-review of the session usually finds
194 # the venv ready.
195 print(json.dumps({"async": True, "asyncTimeout": 180000}), flush=True)
196 t0 = time.perf_counter()
197 try:
198 outcome, err_phase, err_kind = main()
199 except Exception as exc:
200 outcome, err_phase, err_kind = (
201 BUILD_FAILED, "main", f"exc:{type(exc).__name__}"
202 )
203 # CC's async-hook registry scans stdout line-by-line after process exit
204 # and takes the FIRST non-{"async":...} JSON line as the hook response;
205 # its `metrics` key is forwarded to the hook metrics event on the
206 # next attachments pass. Must be a single line — the registry splits on
207 # \n and json-parses each independently. Values must be bool|number OR
208 # short strings (CC accepts string metric values if they're not
209 # null). Stay inside the 10-key emit cap.
210 metrics: dict[str, object] = {
211 "sdk_bootstrap": outcome,
212 "sdk_bootstrap_ms": round((time.perf_counter() - t0) * 1000),
213 }
214 if err_kind:
215 # Truncate defensively; categorized values are <40 chars but the
216 # `other:<tail>` mode could be longer. err_phase may be empty for
217 # pre-venv failures (state_dir.mkdir perm-denied, sentinel O_EXCL
218 # raising a non-FileExistsError OSError) — emit as "pre" so the
219 # err_kind isn't silently dropped.
220 metrics["sdk_bootstrap_phase"] = (err_phase or "pre")[:16]
221 metrics["sdk_bootstrap_err"] = err_kind[:96]
222 pv = _plugin_version_int()
223 if pv:
224 metrics["pv"] = pv
225 print(json.dumps({"metrics": metrics}), flush=True)