hooks.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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 itertools
  7. import json
  8. import os
  9. import re
  10. import shlex
  11. import sys
  12. from pathlib import Path
  13. from typing import cast
  14. PLUGIN_ROOT = Path(os.path.abspath(__file__)).parents[1]
  15. SCRIPTS = PLUGIN_ROOT / "scripts"
  16. # Telemetry codes are append-only: a reader keys on them, so none is ever renumbered.
  17. EVENTS = {"scan_started": 1, "scan_finished": 2, "patches_written": 3, "step_failed": 4}
  18. STEPS = {
  19. "write_scan_meta.py": 1,
  20. "save_result.py": 2,
  21. "render_report.py": 3,
  22. "patch_artifacts.py": 4,
  23. }
  24. MODES = {"scan": 1, "changes": 2, "commit": 3}
  25. EFFORTS = {"low": 1, "medium": 2, "high": 3, "max": 4}
  26. REASONS = {
  27. "no-vote-record": 1,
  28. "no-candidate-count": 2,
  29. "nothing-examined": 3,
  30. "finding-panel-incomplete": 4,
  31. "finding-below-quorum": 5,
  32. "candidates-not-paneled": 6,
  33. "no-panel-completed": 7,
  34. "candidate-panel-incomplete": 8,
  35. "continuation-incomplete": 9,
  36. "findings-refused": 10,
  37. }
  38. UNKNOWN_REASON = 99
  39. COLLAPSED = ("small-diff", "small-scope")
  40. STAMP_PREFIX = "CLAUDE-SECURITY-REVISION-"
  41. OPERATORS = frozenset("();<>|&")
  42. def obj(value: object) -> dict[str, object]:
  43. """value when it is a JSON object, else an empty one."""
  44. return cast("dict[str, object]", value) if isinstance(value, dict) else {}
  45. def parse(text: str | bytes) -> dict[str, object]:
  46. """The JSON object in text; an empty dict when text holds anything else."""
  47. try:
  48. return obj(cast("object", json.loads(text)))
  49. except (ValueError, RecursionError):
  50. return {}
  51. def count(value: object) -> int:
  52. """value when it is a non-negative int (a bool is not one), else 0."""
  53. return value if type(value) is int and value >= 0 else 0
  54. def code(table: dict[str, int], value: object) -> int:
  55. """The table's code for a word; 0 for anything it does not name."""
  56. return table.get(value, 0) if isinstance(value, str) else 0
  57. def read(path: Path) -> bytes | None:
  58. """The file's bytes; None when it cannot be read."""
  59. try:
  60. return path.read_bytes()
  61. except (OSError, ValueError):
  62. return None
  63. def manifest_version() -> str:
  64. """The version in the plugin's manifest; "" when there is not one."""
  65. manifest = parse(read(PLUGIN_ROOT / ".claude-plugin" / "plugin.json") or b"")
  66. version = manifest.get("version")
  67. return version if isinstance(version, str) else ""
  68. def banner() -> None:
  69. """Print the menu banner as a systemMessage."""
  70. width = 53
  71. version = f" v{manifest_version() or 'unknown'} "
  72. box = [
  73. " ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗",
  74. " ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝",
  75. " ██║ ██║ ███████║██║ ██║██║ ██║█████╗",
  76. " ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝",
  77. " ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗",
  78. " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝",
  79. " ──────── S · E · C · U · R · I · T · Y ────────",
  80. " ┌" + "─" * width + "┐",
  81. " │" + "Find and fix vulnerabilities in source code".center(width) + "│",
  82. " └" + version.rjust(width - 3, "─") + "───┘",
  83. ]
  84. message = "\nLaunching Claude Security...\n\n\n" + "\n".join(box) + "\n"
  85. sys.stdout.write(json.dumps({"systemMessage": message}))
  86. def helper_words(command: str) -> list[str] | None:
  87. """The words of a command that runs one of the plugin's helper scripts on its own; else None."""
  88. if any(mark in command for mark in ("\n", "\0", "`", "$(")):
  89. return None
  90. lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
  91. lexer.whitespace_split = True
  92. # A "#" begins a comment only at the start of a word, as in sh; shlex would break a word on one.
  93. lexer.commenters = ""
  94. try:
  95. lexed = list(lexer)
  96. except ValueError:
  97. return None
  98. if any(word and set(word) <= OPERATORS for word in lexed):
  99. return None
  100. words = list(itertools.takewhile(lambda word: not word.startswith("#"), lexed))
  101. if len(words) < 2 or words[0] != "python3":
  102. return None
  103. name = os.path.basename(words[1])
  104. own = os.path.realpath(SCRIPTS / name)
  105. return words if name in STEPS and os.path.realpath(words[1]) == own else None
  106. def arguments(args: list[str]) -> tuple[list[str], dict[str, str | None]]:
  107. """A helper's positional arguments and its --options, each of which takes a value."""
  108. positionals: list[str] = []
  109. options: dict[str, str | None] = {}
  110. rest = iter(args)
  111. for arg in rest:
  112. if arg.startswith("--"):
  113. name, equals, value = arg.partition("=")
  114. options[name] = value if equals else next(rest, None)
  115. else:
  116. positionals.append(arg)
  117. return positionals, options
  118. def scan_started(scan_root: str, options: dict[str, str | None]) -> dict[str, int | bool] | None:
  119. """The event for a write_scan_meta.py run; None unless it names a mode and an effort."""
  120. mode, effort = code(MODES, options.get("--mode")), code(EFFORTS, options.get("--effort"))
  121. root = os.path.normpath(scan_root)
  122. scope = (options.get("--scope") or "").split(",")
  123. scoped = any(os.path.normpath(os.path.join(root, entry.strip())) != root for entry in scope)
  124. return {"mode": mode, "effort": effort, "scoped": scoped} if mode and effort else None
  125. def scan_finished(products: Path) -> dict[str, int | bool] | None:
  126. """The event for a render_report.py run, from the one revision stamp it wrote; else None."""
  127. try:
  128. (path,) = (
  129. p for p in products.iterdir() if p.name.startswith(STAMP_PREFIX) and p.suffix == ".json"
  130. )
  131. except (OSError, ValueError):
  132. return None
  133. stamp = parse(read(path) or b"")
  134. if not stamp:
  135. return None
  136. findings = obj(stamp.get("findings"))
  137. verification = obj(stamp.get("verification"))
  138. shape = obj(stamp.get("run_shape"))
  139. reason = code(REASONS, verification.get("reason_kind")) or UNKNOWN_REASON
  140. dispatched = count(verification.get("researchers_dispatched"))
  141. refused = verification.get("refused_findings")
  142. refusals = len(cast("list[object]", refused)) if isinstance(refused, list) else 0
  143. return {
  144. "mode": code(MODES, stamp.get("mode")),
  145. "effort": code(EFFORTS, stamp.get("effort")),
  146. "sev_critical": count(findings.get("critical")),
  147. "sev_high": count(findings.get("high")),
  148. "sev_medium": count(findings.get("medium")),
  149. "sev_low": count(findings.get("low")),
  150. "candidates": count(verification.get("candidates")),
  151. "candidates_deduped": count(verification.get("candidates_deduped")),
  152. "unverified_reason": 0 if verification.get("status") == "verified" else reason,
  153. "researchers_dispatched": dispatched,
  154. "researchers_lost": count(dispatched - count(verification.get("researchers_returned"))),
  155. "panels_short": count(verification.get("incomplete_panel_candidates")),
  156. "findings_refused": refusals,
  157. "verify_runs": count(shape.get("verification_runs")),
  158. "collapsed": shape.get("collapsed") in COLLAPSED,
  159. "duration_s": count(stamp.get("duration_s")),
  160. }
  161. def patches_written(patches_dir: Path) -> dict[str, int | bool] | None:
  162. """The event for a patch_artifacts.py run, from the patches.jsonl it wrote; else None."""
  163. data = read(patches_dir / "patches.jsonl")
  164. if data is None:
  165. return None
  166. rows = [row for row in map(parse, data.splitlines()) if row]
  167. statuses = [row.get("status") for row in rows]
  168. checks = [str(row.get("apply_check")) for row in rows]
  169. return {
  170. "units": len(rows),
  171. "patches_written": statuses.count("patch_written"),
  172. "declined": statuses.count("declined"),
  173. "skipped_stale": statuses.count("skipped_stale"),
  174. "untested": sum(row.get("untested") is True for row in rows),
  175. "apply_clean": checks.count("clean"),
  176. "apply_conflicts": sum(check.startswith("conflicts") for check in checks),
  177. }
  178. def step_failed(script: str, data: dict[str, object]) -> dict[str, int | bool]:
  179. """The event for a helper run that failed, from Claude Code's error text."""
  180. status = re.match(r"Exit code (\d+)", str(data.get("error", "")))
  181. return {
  182. "step": STEPS[script],
  183. "exit_code": min(int(status[1]), 255) if status else -1,
  184. "interrupted": data.get("is_interrupt") is True,
  185. }
  186. def metrics() -> None:
  187. """Print the metrics object for the hook input on stdin, when it is a helper run."""
  188. data = parse(sys.stdin.buffer.read())
  189. cwd, event = data.get("cwd"), data.get("hook_event_name")
  190. words = helper_words(str(obj(data.get("tool_input")).get("command", "")))
  191. if words is None or not isinstance(cwd, str):
  192. return
  193. script = os.path.basename(words[1])
  194. positionals, options = arguments(words[2:])
  195. if "--remove-scratch" in options:
  196. return
  197. if event == "PostToolUseFailure":
  198. name, body = "step_failed", step_failed(script, data)
  199. elif event != "PostToolUse":
  200. return
  201. elif script == "write_scan_meta.py" and len(positionals) >= 2:
  202. name, body = "scan_started", scan_started(os.path.join(cwd, positionals[1]), options)
  203. elif script == "render_report.py" and positionals:
  204. products = Path(cwd, options.get("--products-dir") or positionals[0])
  205. name, body = "scan_finished", scan_finished(products)
  206. elif script == "patch_artifacts.py" and len(positionals) >= 2:
  207. name, body = "patches_written", patches_written(Path(cwd, positionals[1]))
  208. else:
  209. return
  210. if body is not None:
  211. sys.stdout.write(json.dumps({"metrics": {"ev": EVENTS[name], **body}}))
  212. def main(argv: list[str]) -> int:
  213. hooks = {"banner": banner, "metrics": metrics}
  214. if len(argv) != 1 or argv[0] not in hooks:
  215. sys.stderr.write("usage: hooks.py banner|metrics\n")
  216. return 2
  217. hooks[argv[0]]()
  218. return 0
  219. if __name__ == "__main__":
  220. sys.exit(main(sys.argv[1:]))