hooks.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. #!/usr/bin/env python3
  2. """The Claude Security plugin's hooks.
  3. A usage error exits 2. Python 3.9-compatible, stdlib only.
  4. """
  5. from __future__ import annotations
  6. import functools
  7. import hashlib
  8. import itertools
  9. import json
  10. import os
  11. import re
  12. import shlex
  13. import subprocess
  14. import sys
  15. import tempfile
  16. import time
  17. from contextlib import suppress
  18. from dataclasses import dataclass
  19. from pathlib import Path
  20. from typing import cast
  21. PLUGIN_ROOT = Path(os.path.abspath(__file__)).parents[1]
  22. SCRIPTS = PLUGIN_ROOT / "scripts"
  23. # Telemetry codes are append-only: a reader keys on them, so none is ever renumbered.
  24. EVENTS = {
  25. "scan_started": 1,
  26. "scan_finished": 2,
  27. "patches_written": 3,
  28. "step_failed": 4,
  29. "tip_shown": 5,
  30. }
  31. STEPS = {
  32. "write_scan_meta.py": 1,
  33. "save_result.py": 2,
  34. "render_report.py": 3,
  35. "patch_artifacts.py": 4,
  36. }
  37. MODES = {"scan": 1, "changes": 2, "commit": 3}
  38. # `max` is accepted and runs as `high`, so it reports as 3; 4 is reserved.
  39. EFFORTS = {"low": 1, "medium": 2, "high": 3, "max": 3}
  40. REASONS = {
  41. "no-vote-record": 1,
  42. "no-candidate-count": 2,
  43. "nothing-examined": 3,
  44. "finding-panel-incomplete": 4,
  45. "finding-below-quorum": 5,
  46. "candidates-not-paneled": 6,
  47. "no-panel-completed": 7,
  48. "candidate-panel-incomplete": 8,
  49. "continuation-incomplete": 9,
  50. "findings-refused": 10,
  51. }
  52. UNKNOWN_REASON = 99
  53. COLLAPSED = "small-scope"
  54. STAMP_PREFIX = "CLAUDE-SECURITY-REVISION-"
  55. OPERATORS = frozenset("();<>|&")
  56. PY_LAUNCHER = "py"
  57. INTERPRETERS = frozenset({"python3", "python", PY_LAUNCHER})
  58. PY_VERSION_OPTION = "-3"
  59. # The scan recipes' start confirmation, word for word: the one question that is timed.
  60. START_CONFIRMATION = (
  61. "This scan may take a while and may use a significant number of tokens. "
  62. "You will need to leave Claude Code open while the scan completes. "
  63. "Are you sure you want to continue?"
  64. )
  65. UNANSWERED_AFTER_S = 60
  66. TIP = (
  67. "Claude Security: this branch changes {size} against {base}. For a security review,"
  68. ' ask: "scan this branch\'s changes against {base} with Claude Security".'
  69. )
  70. MAX_TIPS = 5
  71. TIP_STATE = "scan-tip.json"
  72. BASES = ("origin/HEAD", "origin/main", "origin/master", "main", "master")
  73. # No repository hook and no fsmonitor runs; "core.fsmonitor=" (empty, not false) is off on any git.
  74. GIT = ("git", "-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=")
  75. GIT_TIMEOUT_S = 2
  76. TIP_BUDGET_S = 3
  77. DIFF = ("diff", "--no-ext-diff", "--no-textconv", "--no-renames", "--name-only", "-z")
  78. # Past this many files a change review no longer reads the change closely: nothing to suggest.
  79. MAX_FILES = 300
  80. def obj(value: object) -> dict[str, object]:
  81. """value when it is a JSON object, else an empty one."""
  82. return cast("dict[str, object]", value) if isinstance(value, dict) else {}
  83. def arr(value: object) -> list[object]:
  84. """value when it is a JSON array, else an empty one."""
  85. return cast("list[object]", value) if isinstance(value, list) else []
  86. def parse(text: str | bytes) -> dict[str, object]:
  87. """The JSON object in text; an empty dict when text holds anything else."""
  88. try:
  89. return obj(cast("object", json.loads(text)))
  90. except (ValueError, RecursionError):
  91. return {}
  92. def count(value: object) -> int:
  93. """value when it is a non-negative int (a bool is not one), else 0."""
  94. return value if type(value) is int and value >= 0 else 0
  95. def code(table: dict[str, int], value: object) -> int:
  96. """The table's code for a word; 0 for anything it does not name."""
  97. return table.get(value, 0) if isinstance(value, str) else 0
  98. def read(path: Path) -> bytes | None:
  99. """The file's bytes; None when it cannot be read."""
  100. try:
  101. return path.read_bytes()
  102. except (OSError, ValueError):
  103. return None
  104. def manifest_version() -> str:
  105. """The version in the plugin's manifest; "" when there is not one."""
  106. manifest = parse(read(PLUGIN_ROOT / ".claude-plugin" / "plugin.json") or b"")
  107. version = manifest.get("version")
  108. return version if isinstance(version, str) else ""
  109. def banner(state: Path | None) -> None:
  110. """Print the menu banner as a systemMessage and record in state that the menu was opened."""
  111. width = 53
  112. version = f" v{manifest_version() or 'unknown'} "
  113. box = [
  114. " ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗",
  115. " ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝",
  116. " ██║ ██║ ███████║██║ ██║██║ ██║█████╗",
  117. " ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝",
  118. " ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗",
  119. " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝",
  120. " ──────── S · E · C · U · R · I · T · Y ────────",
  121. " ┌" + "─" * width + "┐",
  122. " │" + "Find and fix vulnerabilities in source code".center(width) + "│",
  123. " └" + version.rjust(width - 3, "─") + "───┘",
  124. ]
  125. message = "\nLaunching Claude Security...\n\n\n" + "\n".join(box) + "\n"
  126. sys.stdout.write(json.dumps({"systemMessage": message}))
  127. if state and not (record := TipState.load(state)).opened:
  128. record.opened = True
  129. with suppress(OSError):
  130. record.save()
  131. def helper_words(command: str) -> list[str] | None:
  132. """The interpreter, script and argument words of a command that runs one of the plugin's
  133. helper scripts on its own; else None."""
  134. if any(mark in command for mark in ("\n", "\0", "`", "$(")):
  135. return None
  136. lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
  137. lexer.whitespace_split = True
  138. # A "#" begins a comment only at the start of a word, as in sh; shlex would break a word on one.
  139. lexer.commenters = ""
  140. try:
  141. lexed = list(lexer)
  142. except ValueError:
  143. return None
  144. if any(word and set(word) <= OPERATORS for word in lexed):
  145. return None
  146. words = list(itertools.takewhile(lambda word: not word.startswith("#"), lexed))
  147. if words[:2] == [PY_LAUNCHER, PY_VERSION_OPTION]:
  148. del words[1]
  149. if len(words) < 2 or words[0] not in INTERPRETERS:
  150. return None
  151. name = os.path.basename(words[1])
  152. own = os.path.realpath(SCRIPTS / name)
  153. return words if name in STEPS and os.path.realpath(words[1]) == own else None
  154. def arguments(args: list[str]) -> tuple[list[str], dict[str, str | None]]:
  155. """A helper's positional arguments and its --options, each of which takes a value."""
  156. positionals: list[str] = []
  157. options: dict[str, str | None] = {}
  158. rest = iter(args)
  159. for arg in rest:
  160. if arg.startswith("--"):
  161. name, equals, value = arg.partition("=")
  162. options[name] = value if equals else next(rest, None)
  163. else:
  164. positionals.append(arg)
  165. return positionals, options
  166. def scan_started(scan_root: str, options: dict[str, str | None]) -> dict[str, int | bool] | None:
  167. """The event for a write_scan_meta.py run; None unless it names a mode and an effort."""
  168. asked = (options.get("--effort") or "").strip().lower()
  169. mode, effort = code(MODES, options.get("--mode")), code(EFFORTS, asked)
  170. if not (mode and effort):
  171. return None
  172. root = os.path.normpath(scan_root)
  173. scope = (options.get("--scope") or "").split(",")
  174. scoped = any(os.path.normpath(os.path.join(root, entry.strip())) != root for entry in scope)
  175. return {"mode": mode, "effort": effort, "scoped": scoped, "py_minor": sys.version_info.minor}
  176. def scan_finished(products: Path) -> dict[str, int | bool] | None:
  177. """The event for a render_report.py run, from the one revision stamp it wrote; else None."""
  178. try:
  179. (path,) = (
  180. p for p in products.iterdir() if p.name.startswith(STAMP_PREFIX) and p.suffix == ".json"
  181. )
  182. except (OSError, ValueError):
  183. return None
  184. stamp = parse(read(path) or b"")
  185. if not stamp:
  186. return None
  187. findings = obj(stamp.get("findings"))
  188. verification = obj(stamp.get("verification"))
  189. shape = obj(stamp.get("run_shape"))
  190. reason = code(REASONS, verification.get("reason_kind")) or UNKNOWN_REASON
  191. dispatched = count(verification.get("researchers_dispatched"))
  192. return {
  193. "mode": code(MODES, stamp.get("mode")),
  194. "effort": code(EFFORTS, stamp.get("effort")),
  195. "sev_critical": count(findings.get("critical")),
  196. "sev_high": count(findings.get("high")),
  197. "sev_medium": count(findings.get("medium")),
  198. "sev_low": count(findings.get("low")),
  199. "candidates": count(verification.get("candidates")),
  200. "candidates_deduped": count(verification.get("candidates_deduped")),
  201. "unverified_reason": 0 if verification.get("status") == "verified" else reason,
  202. "researchers_dispatched": dispatched,
  203. "researchers_lost": count(dispatched - count(verification.get("researchers_returned"))),
  204. "panels_short": count(verification.get("incomplete_panel_candidates")),
  205. "findings_refused": len(arr(verification.get("refused_findings"))),
  206. "verify_runs": count(shape.get("verification_runs")),
  207. "collapsed": shape.get("collapsed") == COLLAPSED,
  208. "duration_s": count(stamp.get("duration_s")),
  209. }
  210. def patches_written(patches_dir: Path) -> dict[str, int | bool] | None:
  211. """The event for a patch_artifacts.py run, from the patches.jsonl it wrote; else None."""
  212. data = read(patches_dir / "patches.jsonl")
  213. if data is None:
  214. return None
  215. rows = [row for row in map(parse, data.splitlines()) if row]
  216. statuses = [row.get("status") for row in rows]
  217. checks = [str(row.get("apply_check")) for row in rows]
  218. return {
  219. "units": len(rows),
  220. "patches_written": statuses.count("patch_written"),
  221. "declined": statuses.count("declined"),
  222. "skipped_stale": statuses.count("skipped_stale"),
  223. "untested": sum(row.get("untested") is True for row in rows),
  224. "apply_clean": checks.count("clean"),
  225. "apply_conflicts": sum(check.startswith("conflicts") for check in checks),
  226. }
  227. def step_failed(script: str, data: dict[str, object]) -> dict[str, int | bool]:
  228. """The event for a helper run that failed, from Claude Code's error text."""
  229. status = re.match(r"Exit code (\d+)", str(data.get("error", "")))
  230. return {
  231. "step": STEPS[script],
  232. "exit_code": min(int(status[1]), 255) if status else -1,
  233. "interrupted": data.get("is_interrupt") is True,
  234. }
  235. def metrics(failed: frozenset[str]) -> None:
  236. """Print the metrics object for the hook input on stdin, when it is a helper run.
  237. failed names the interpreters hooks.sh itself could not run; a failure under one is not sent.
  238. """
  239. data = parse(sys.stdin.buffer.read())
  240. cwd, event = data.get("cwd"), data.get("hook_event_name")
  241. words = helper_words(str(obj(data.get("tool_input")).get("command", "")))
  242. if words is None or not isinstance(cwd, str):
  243. return
  244. script = os.path.basename(words[1])
  245. positionals, options = arguments(words[2:])
  246. if "--remove-scratch" in options or "--prepare" in options:
  247. return
  248. if event == "PostToolUseFailure":
  249. if words[0] in failed:
  250. return
  251. name, body = "step_failed", step_failed(script, data)
  252. elif event != "PostToolUse":
  253. return
  254. elif script == "write_scan_meta.py" and len(positionals) >= 2:
  255. name, body = "scan_started", scan_started(os.path.join(cwd, positionals[1]), options)
  256. elif script == "render_report.py" and positionals:
  257. products = Path(cwd, options.get("--products-dir") or positionals[0])
  258. name, body = "scan_finished", scan_finished(products)
  259. elif script == "patch_artifacts.py" and len(positionals) >= 2:
  260. name, body = "patches_written", patches_written(Path(cwd, positionals[1]))
  261. else:
  262. return
  263. if body is not None:
  264. sys.stdout.write(json.dumps({"metrics": {"ev": EVENTS[name], **body}}))
  265. def unanswered() -> None:
  266. """Print a deny decision, after the wait, if the hook input asks a scan's start confirmation."""
  267. asked = arr(obj(parse(sys.stdin.buffer.read()).get("tool_input")).get("questions"))
  268. if START_CONFIRMATION not in [obj(question).get("question") for question in asked]:
  269. return
  270. time.sleep(UNANSWERED_AFTER_S)
  271. message = f"No answer after {UNANSWERED_AFTER_S} seconds, so nothing was run."
  272. decision = {"behavior": "deny", "message": message}
  273. output = {"hookEventName": "PermissionRequest", "decision": decision}
  274. sys.stdout.write(json.dumps({"hookSpecificOutput": output}))
  275. def git(cwd: str, *args: str, deadline: float) -> str:
  276. """The stdout of `git -C <cwd> <args>` less its newline, or "" when git fails.
  277. Raises OSError when git cannot start, TimeoutExpired when it is slow or the deadline is past."""
  278. left = deadline - time.monotonic()
  279. if left <= 0:
  280. raise subprocess.TimeoutExpired(args, 0)
  281. out = subprocess.run(
  282. [*GIT, "-C", cwd, *args],
  283. stdout=subprocess.PIPE,
  284. stderr=subprocess.DEVNULL,
  285. timeout=min(GIT_TIMEOUT_S, left),
  286. check=False,
  287. )
  288. text = out.stdout.decode("utf-8", "surrogateescape") if out.returncode == 0 else ""
  289. return text.rstrip("\r\n")
  290. @dataclass
  291. class TipState:
  292. """The tip's memory, a JSON file: whether the menu was opened, branches tipped, the last tip."""
  293. path: Path
  294. opened: bool
  295. shown: list[str]
  296. last: str
  297. @classmethod
  298. def load(cls, path: Path) -> TipState:
  299. """The record in the file at path; a missing or unreadable file is a fresh record."""
  300. raw = parse(read(path) or b"")
  301. shown, last = raw.get("shown"), raw.get("last")
  302. keys = cast("list[object]", shown) if isinstance(shown, list) else []
  303. kept = [key for key in keys if isinstance(key, str)]
  304. return cls(path, raw.get("opened") is True, kept, last if isinstance(last, str) else "")
  305. def save(self) -> None:
  306. """Write the record whole, so that a reader never meets a partial file."""
  307. fd, part = tempfile.mkstemp(prefix=self.path.name, dir=self.path.parent)
  308. try:
  309. with os.fdopen(fd, "w", encoding="utf-8") as out:
  310. out.write(
  311. json.dumps({"opened": self.opened, "shown": self.shown, "last": self.last})
  312. )
  313. Path(part).replace(self.path)
  314. except OSError:
  315. Path(part).unlink(missing_ok=True)
  316. raise
  317. @property
  318. def spent(self) -> bool:
  319. """Whether no branch is owed a tip any more: the menu was opened, or enough were shown."""
  320. return self.opened or len(self.shown) >= MAX_TIPS
  321. def seen(self, key: str) -> bool:
  322. """Whether the branch with this key was tipped, or was the last one noted."""
  323. return key == self.last or key in self.shown
  324. def note(self, key: str, *, counted: bool) -> None:
  325. """Record a tip for the branch with this key, counting it toward the limit or not."""
  326. self.last = key
  327. if counted:
  328. self.shown.append(key)
  329. self.save()
  330. @dataclass(frozen=True)
  331. class Branch:
  332. """The branch checked out in a directory: its work tree, repository, name and commit."""
  333. top: str
  334. repo: str
  335. name: str
  336. head: str
  337. @classmethod
  338. def at(cls, cwd: str, deadline: float) -> Branch | None:
  339. """The branch checked out at cwd; None outside a repository or off any branch."""
  340. run = functools.partial(git, cwd, deadline=deadline)
  341. place = run("rev-parse", "--show-toplevel", "--git-common-dir", "HEAD")
  342. try:
  343. top, common, head = place.split("\n")
  344. except ValueError:
  345. return None
  346. name = run("symbolic-ref", "-q", "--short", "HEAD")
  347. if not name:
  348. return None
  349. # The common git directory, so that every worktree of one repository is that repository.
  350. repo = os.path.realpath(os.path.join(cwd, common))
  351. return cls(top=top, repo=repo, name=name, head=head)
  352. def key(self, *, per_commit: bool) -> str:
  353. """An opaque key for this branch, or for this branch at this commit; it names neither."""
  354. parts = (self.repo, self.name, self.head if per_commit else "")
  355. return hashlib.sha256("\0".join(parts).encode("utf-8", "surrogateescape")).hexdigest()[:16]
  356. def change(self, deadline: float) -> tuple[int, str] | None:
  357. """(files changed, base) for this branch; None with no base, nothing ahead, or too much."""
  358. run = functools.partial(git, self.top, deadline=deadline)
  359. upstream = run("rev-parse", "--abbrev-ref", "@{upstream}")
  360. # The branch's own pushed copy (<remote>/<same name>, which `git push -u` sets) is no base.
  361. pushed_copy = upstream.partition("/")[2] == self.name
  362. for ref in (upstream, *BASES) if upstream and not pushed_copy else BASES:
  363. base = run("rev-parse", "--verify", "-q", "--abbrev-ref", ref)
  364. if base:
  365. break
  366. else:
  367. return None
  368. fork = run("merge-base", base, "HEAD")
  369. if not fork:
  370. return None
  371. files = run(*DIFF, fork, "HEAD").count("\0")
  372. return (files, base) if 0 < files <= MAX_FILES else None
  373. def tip(state: Path | None, *, after_pr: bool, always: bool) -> None:
  374. """Print the tip when the hook input on stdin is a push, or a new pull request, of changes."""
  375. data = parse(sys.stdin.buffer.read())
  376. operation = obj(obj(data.get("tool_response")).get("gitOperation"))
  377. pushed = "push" in operation
  378. pr_opened = obj(operation.get("pr")).get("action") == "created"
  379. # One command can start both hook entries; only the push entry speaks for a command that pushed.
  380. published = (pr_opened and not pushed) if after_pr else pushed
  381. cwd = data.get("cwd")
  382. if not published or "agent_id" in data or not isinstance(cwd, str):
  383. return
  384. record = TipState.load(state) if state else None
  385. if record is None or (record.spent and not always):
  386. return
  387. started = time.monotonic()
  388. deadline = started + TIP_BUDGET_S
  389. with suppress(OSError, subprocess.TimeoutExpired):
  390. branch = Branch.at(cwd, deadline)
  391. if branch is None or record.seen(key := branch.key(per_commit=always)):
  392. return
  393. try:
  394. change = branch.change(deadline)
  395. except subprocess.TimeoutExpired:
  396. change = None
  397. # A branch slow to measure is noted even with nothing to show, and not measured again.
  398. if change is None and time.monotonic() - started < GIT_TIMEOUT_S:
  399. return
  400. record.note(key, counted=not always)
  401. if change is None:
  402. return
  403. files, base = change
  404. text = TIP.format(size="1 file" if files == 1 else f"{files} files", base=base)
  405. event = {"ev": EVENTS["tip_shown"]}
  406. sys.stdout.write(json.dumps({"systemMessage": text, "metrics": event}))
  407. def tip_state_path(data_dir: str) -> Path | None:
  408. """The tip's state file in the plugin's data directory; None when Claude Code names none."""
  409. return Path(data_dir, TIP_STATE) if data_dir else None
  410. def main(argv: list[str]) -> int:
  411. verb, args = (argv[0], argv[1:]) if argv else ("", [])
  412. if verb == "metrics" and set(args) <= INTERPRETERS:
  413. metrics(frozenset(args))
  414. elif verb == "unanswered" and not args:
  415. unanswered()
  416. elif verb == "banner" and len(args) == 1:
  417. banner(tip_state_path(args[0]))
  418. elif verb == "tip" and len(args) == 3 and args[0] in {"push", "pr"}:
  419. tip(tip_state_path(args[1]), after_pr=args[0] == "pr", always=args[2].lower() == "always")
  420. else:
  421. usage = (
  422. "usage: hooks.py metrics [failed-interpreter ...] | unanswered | banner <data> | "
  423. "tip push|pr <data> <mode>\n"
  424. )
  425. sys.stderr.write(usage)
  426. return 2
  427. return 0
  428. if __name__ == "__main__":
  429. sys.exit(main(sys.argv[1:]))