_base.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. """
  2. Shared low-level helpers for the security-guidance hook modules.
  3. This module exists so that ``patterns``/``session_state``/``gitutil`` can use
  4. ``debug_log`` without importing ``security_reminder_hook`` (which would be a
  5. circular import). It must stay free of any other intra-plugin imports.
  6. """
  7. import json
  8. import os
  9. import re
  10. import threading
  11. from datetime import datetime
  12. def state_dir():
  13. """Return the absolute path of the plugin's state directory.
  14. Resolution precedence (highest first):
  15. 1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing)
  16. 2. CLAUDE_CONFIG_DIR/security — CC's config-dir env var (#1868)
  17. 3. ~/.claude/security — default fallback
  18. Empty-string env vars are treated as not-set so a misconfigured shell
  19. (`CLAUDE_CONFIG_DIR=` with no value) doesn't silently write to
  20. /security at the filesystem root.
  21. Returns a fully-expanded absolute path (no literal `~`) so subprocess
  22. callers can pass it through to code that doesn't re-expand tildes.
  23. Called per-invocation rather than cached at import time so test
  24. monkeypatches of the env vars take effect — the plugin's hooks each
  25. run as fresh subprocesses in production, so the per-call cost is
  26. negligible compared to subprocess spawn.
  27. """
  28. explicit = os.environ.get("SECURITY_WARNINGS_STATE_DIR")
  29. if explicit:
  30. return os.path.expanduser(explicit)
  31. cc_config = os.environ.get("CLAUDE_CONFIG_DIR")
  32. if cc_config:
  33. return os.path.expanduser(os.path.join(cc_config, "security"))
  34. return os.path.expanduser("~/.claude/security")
  35. # Debug log file. Lives under the plugin state dir (default ~/.claude/security/)
  36. # rather than /tmp because /tmp is world-writable on multi-user hosts (TOCTOU /
  37. # symlink-attack surface, cross-user log leakage). Overridable per-process via
  38. # SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR
  39. # (plugin-specific override) or CLAUDE_CONFIG_DIR (CC-wide config dir, #1868).
  40. DEBUG_LOG_FILE = os.environ.get("SECURITY_GUIDANCE_DEBUG_LOG") or os.path.join(
  41. state_dir(), "log.txt"
  42. )
  43. # Cap the debug log so parallel-worker fleets don't fill disk. When the active
  44. # file exceeds this it's atomically rotated to <file>.1 (overwriting any prior
  45. # rotation), so total disk stays ~2× this.
  46. DEBUG_LOG_MAX_BYTES = 1 * 1024 * 1024
  47. def debug_log(message):
  48. """Append debug message to log file with timestamp."""
  49. try:
  50. # Ensure parent dir exists — first hook invocation on a fresh install
  51. # creates ~/.claude/security/ if it isn't already there. 0700 so other
  52. # local users can't read review/debug output (only applies on creation).
  53. try:
  54. os.makedirs(os.path.dirname(DEBUG_LOG_FILE), mode=0o700, exist_ok=True)
  55. except OSError:
  56. pass
  57. try:
  58. if os.path.getsize(DEBUG_LOG_FILE) > DEBUG_LOG_MAX_BYTES:
  59. # os.replace is atomic on POSIX; under a racing fleet the loser
  60. # gets FileNotFoundError, which is fine — the append below
  61. # recreates the file.
  62. os.replace(DEBUG_LOG_FILE, DEBUG_LOG_FILE + ".1")
  63. except OSError:
  64. pass
  65. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  66. # 0600 on creation; existing files keep their mode.
  67. fd = os.open(DEBUG_LOG_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
  68. with os.fdopen(fd, "a") as f:
  69. f.write(f"[{timestamp}] {message}\n")
  70. except Exception:
  71. pass
  72. # Provenance tag prepended to injected/emitted text so a reader (especially a
  73. # model hardened against prompt injection) can recognize the source. Not an
  74. # authority claim — an attacker could spoof the exact string; the tag is a
  75. # signpost so the agent can ask the operator "is this from your plugin?" with
  76. # a concrete reference instead of treating it as unknown-actor injection.
  77. # Some autonomous-agent setups flag un-attributed injected text as prompt
  78. # injection and stall; the banner makes the provenance explicit.
  79. PROVENANCE_TAG = "[from security-guidance@claude-code-plugins plugin]"
  80. PROVENANCE_BANNER = (
  81. "[from security-guidance@claude-code-plugins plugin — automated "
  82. "security review, not user input.]"
  83. )
  84. # Output that only Claude Code reads: the `metrics` and `rewakeSummary` fields,
  85. # and the separate `{"async": ...}` line an async hook prints first. Other hosts
  86. # that run this plugin's hooks can validate hook output against a closed schema
  87. # and reject the whole output over a field or a second line they do not know,
  88. # which shows up as a hook error.
  89. _CLAUDE_CODE_OUTPUT_FIELDS = ("metrics", "rewakeSummary")
  90. def _detect_portable_output():
  91. forced = os.environ.get("SECURITY_GUIDANCE_PORTABLE_OUTPUT", "").strip().lower()
  92. if forced in ("1", "true", "yes", "on"):
  93. return True
  94. if forced in ("0", "false", "no", "off"):
  95. return False
  96. if os.environ.get("CLAUDECODE") or os.environ.get("CLAUDE_CODE_ENTRYPOINT"):
  97. return False
  98. return bool(os.environ.get("PLUGIN_ROOT"))
  99. # Decided once, from the environment the host started the hook with: code the
  100. # hook runs in-process later can set these variables itself.
  101. _PORTABLE_OUTPUT = _detect_portable_output()
  102. def portable_output():
  103. """True when stdout should carry only the standard hook output fields.
  104. SECURITY_GUIDANCE_PORTABLE_OUTPUT=1 or =0 forces it on or off. Otherwise it
  105. is on only when the host passes PLUGIN_ROOT and does not identify itself as
  106. Claude Code, so Claude Code and direct invocations are unchanged."""
  107. return _PORTABLE_OUTPUT
  108. def write_hook_output(out):
  109. """Print one hook output line. In portable mode the Claude Code only
  110. fields are left out; what remains (possibly `{}`) is still valid output."""
  111. if portable_output():
  112. out = {k: v for k, v in out.items() if k not in _CLAUDE_CODE_OUTPUT_FIELDS}
  113. print(json.dumps(out), flush=True)
  114. def _read_plugin_version_int():
  115. """Encode plugin.json version "M.m.p" as M*10000 + m*100 + p so it fits the
  116. bool|number metrics constraint. Returns 0 if unreadable."""
  117. try:
  118. with open(os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")) as f:
  119. v = json.load(f)["version"]
  120. major, minor, patch = (int(x) for x in v.split(".")[:3])
  121. return major * 10000 + minor * 100 + patch
  122. except Exception:
  123. return 0
  124. _PV = _read_plugin_version_int()
  125. # ──────────────────────────────────────────────────────────────────────────
  126. # Token-usage accumulator. Each hook invocation is a fresh subprocess, so a
  127. # module-global is naturally per-invocation. _call_claude_dual_or and
  128. # _agentic_review_with_race run legs in ThreadPoolExecutor → lock required.
  129. # Emitted via _usage_metrics() into the existing emit_metrics() channel so
  130. # hook metrics rows carry per-invocation token/cost totals
  131. # alongside the existing skip_reason / vulns_found fields.
  132. _USAGE = {
  133. "in": 0, "out": 0, "cr": 0, "cw": 0, "cost": 0.0, "n": 0,
  134. # HTTP error visibility (#2098 visibility gap — see emit comment in
  135. # _usage_metrics). Without this, API failures from `_call_claude` left
  136. # zero fingerprint in telemetry: the call returns None, the caller's
  137. # emit_metrics carries no api_calls field, and the failure is
  138. # indistinguishable from "no review needed". The deprecation outage
  139. # that broke every commit-review LLM call was invisible until users
  140. # reported it manually.
  141. "http_err_last": 0, # most recent HTTP error code this invocation
  142. "http_err_count": 0, # total HTTP errors (4xx + 5xx + network)
  143. # For the usage log line only: model ids as recorded (the requested alias
  144. # on the Agent SDK path), and whether any call had no price-table entry.
  145. "models": [],
  146. "default_priced": False,
  147. }
  148. _USAGE_LOCK = threading.Lock()
  149. # $/Mtok (input, output). Used only for the raw-HTTP path; the SDK path
  150. # reports total_cost_usd directly. Cache reads/writes are priced at the
  151. # canonical 0.1×/1.25× of input. Unknown models fall back to sonnet pricing
  152. # so cost_usd is never silently zero. Re-pricing downstream from the raw tok_*
  153. # fields is the source of truth — cost_usd here is a convenience rollup.
  154. _PRICE_PER_MTOK = {
  155. "claude-haiku-4-5": (1.0, 5.0),
  156. "claude-sonnet-4-6": (3.0, 15.0),
  157. "claude-opus-4-6": (15.0, 75.0),
  158. "claude-opus-4-7": (5.0, 25.0),
  159. "claude-opus-4-8": (5.0, 25.0),
  160. "claude-opus-5": (5.0, 25.0),
  161. "claude-opus-5-5": (4.0, 20.0),
  162. }
  163. _PRICE_DEFAULT = (3.0, 15.0)
  164. def _record_usage(usage, model, cost_usd=None):
  165. """Accumulate one API response's token usage. `usage` is the Anthropic
  166. `usage` dict (HTTP) or the SDK ResultMessage.usage dict — both use the
  167. same key names. `cost_usd` (SDK-provided) is preferred when present;
  168. otherwise computed from _PRICE_PER_MTOK keyed on the response model id
  169. (longest-prefix match so `claude-sonnet-4-6-20251015` → sonnet row)."""
  170. if not usage and cost_usd is None:
  171. return
  172. u = usage or {}
  173. try:
  174. i = int(u.get("input_tokens") or 0)
  175. o = int(u.get("output_tokens") or 0)
  176. cr = int(u.get("cache_read_input_tokens") or 0)
  177. cw = int(u.get("cache_creation_input_tokens") or 0)
  178. except (TypeError, ValueError):
  179. return
  180. default_priced = False
  181. if cost_usd is None:
  182. pin, pout = _PRICE_DEFAULT
  183. default_priced = True
  184. m = (model or "").lower()
  185. for k, v in sorted(_PRICE_PER_MTOK.items(), key=lambda kv: -len(kv[0])):
  186. if m.startswith(k):
  187. pin, pout = v
  188. default_priced = False
  189. break
  190. cost_usd = (i * pin + o * pout + cr * pin * 0.1 + cw * pin * 1.25) / 1_000_000
  191. with _USAGE_LOCK:
  192. _USAGE["in"] += i
  193. _USAGE["out"] += o
  194. _USAGE["cr"] += cr
  195. _USAGE["cw"] += cw
  196. _USAGE["cost"] += float(cost_usd or 0.0)
  197. _USAGE["n"] += 1
  198. _USAGE["default_priced"] = _USAGE["default_priced"] or default_priced
  199. name = re.sub(r"[^A-Za-z0-9_.:/@-]", "_", str(model or ""))[:80]
  200. if name and name not in _USAGE["models"]:
  201. _USAGE["models"].append(name)
  202. def _record_http_error(status):
  203. """Record an HTTP error from an LLM API call. `status` is the HTTP
  204. status code (integer 400–599) or -1 for network/timeout errors. Stored
  205. in `_USAGE["http_err_last"]` (most recent) and counted in
  206. `_USAGE["http_err_count"]`. Snapshot via `_usage_metrics()` so every
  207. subsequent `emit_metrics` includes the failure fingerprint.
  208. Background: without this, the most recent example was the #2098
  209. deprecation 400. Every hook fire's LLM call returned HTTP 400; the
  210. plugin caught it and returned None; the emit_metrics carried no
  211. api_calls field; aggregate dashboards looked normal. The failure
  212. only became visible when a user manually reported errors out of
  213. their debug log. With this field, a category-of-failure spike (4xx,
  214. 5xx, or -1 network) is queryable from BQ in real time.
  215. """
  216. try:
  217. s = int(status)
  218. except (TypeError, ValueError):
  219. return
  220. with _USAGE_LOCK:
  221. _USAGE["http_err_last"] = s
  222. _USAGE["http_err_count"] += 1
  223. # Set by main() and the review handlers: which review this invocation runs
  224. # and for which Claude Code session, so the usage log line can be attributed.
  225. _USAGE_CONTEXT = {"session_id": "", "review": "stop"}
  226. def _log_usage(usage):
  227. """Write this invocation's review usage to the debug log.
  228. `usage` is a `_usage_metrics()` snapshot, the same numbers that go out
  229. with the hook's metrics. The raw-HTTP review leaves no Claude Code
  230. transcript and no OpenTelemetry event; without this line nothing on the
  231. machine records what it used. `est_cost_usd` is that same rollup: list
  232. prices (the inner CLI's own figure on the Agent SDK path), `~` appended
  233. when a model had no price-table entry and the default rate was used.
  234. """
  235. if not usage.get("api_calls"):
  236. return
  237. with _USAGE_LOCK:
  238. models = ",".join(_USAGE["models"]) or "-"
  239. approx = "~" if _USAGE["default_priced"] else ""
  240. debug_log(
  241. f"Review usage: review={_USAGE_CONTEXT.get('review') or '-'} "
  242. f"session={_USAGE_CONTEXT.get('session_id') or '-'} "
  243. f"model={models} calls={usage['api_calls']} "
  244. f"in={usage['tok_in']} out={usage['tok_out']} "
  245. f"cache_read={usage['tok_cache_r']} cache_write={usage['tok_cache_w']} "
  246. f"est_cost_usd={usage['cost_usd']:.4f}{approx}"
  247. )
  248. def _usage_metrics():
  249. """Snapshot the accumulator as metric keys. Returns {} when no API calls
  250. AND no HTTP errors were made so skip-path emits don't burn key budget.
  251. cost_usd rounded to 1e-6 to keep the float finite/short for the zod
  252. schema.
  253. HTTP errors (`http_err_last`, `http_err_count`) emitted ONLY when
  254. `http_err_count > 0` so successful calls don't pad every metrics row
  255. with two zero fields.
  256. """
  257. with _USAGE_LOCK:
  258. if _USAGE["n"] == 0 and _USAGE["http_err_count"] == 0:
  259. return {}
  260. out = {}
  261. if _USAGE["n"] > 0:
  262. out.update({
  263. "tok_in": _USAGE["in"],
  264. "tok_out": _USAGE["out"],
  265. "tok_cache_r": _USAGE["cr"],
  266. "tok_cache_w": _USAGE["cw"],
  267. "cost_usd": round(_USAGE["cost"], 6),
  268. "api_calls": _USAGE["n"],
  269. })
  270. if _USAGE["http_err_count"] > 0:
  271. out["http_err_last"] = _USAGE["http_err_last"]
  272. out["http_err_count"] = _USAGE["http_err_count"]
  273. return out