_base.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  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 threading
  10. from datetime import datetime
  11. def state_dir():
  12. """Return the absolute path of the plugin's state directory.
  13. Resolution precedence (highest first):
  14. 1. SECURITY_WARNINGS_STATE_DIR — plugin-specific override (existing)
  15. 2. CLAUDE_CONFIG_DIR/security — CC's config-dir env var (#1868)
  16. 3. ~/.claude/security — default fallback
  17. Empty-string env vars are treated as not-set so a misconfigured shell
  18. (`CLAUDE_CONFIG_DIR=` with no value) doesn't silently write to
  19. /security at the filesystem root.
  20. Returns a fully-expanded absolute path (no literal `~`) so subprocess
  21. callers can pass it through to code that doesn't re-expand tildes.
  22. Called per-invocation rather than cached at import time so test
  23. monkeypatches of the env vars take effect — the plugin's hooks each
  24. run as fresh subprocesses in production, so the per-call cost is
  25. negligible compared to subprocess spawn.
  26. """
  27. explicit = os.environ.get("SECURITY_WARNINGS_STATE_DIR")
  28. if explicit:
  29. return os.path.expanduser(explicit)
  30. cc_config = os.environ.get("CLAUDE_CONFIG_DIR")
  31. if cc_config:
  32. return os.path.expanduser(os.path.join(cc_config, "security"))
  33. return os.path.expanduser("~/.claude/security")
  34. # Debug log file. Lives under the plugin state dir (default ~/.claude/security/)
  35. # rather than /tmp because /tmp is world-writable on multi-user hosts (TOCTOU /
  36. # symlink-attack surface, cross-user log leakage). Overridable per-process via
  37. # SECURITY_GUIDANCE_DEBUG_LOG, or per-state-dir via SECURITY_WARNINGS_STATE_DIR
  38. # (plugin-specific override) or CLAUDE_CONFIG_DIR (CC-wide config dir, #1868).
  39. DEBUG_LOG_FILE = os.environ.get("SECURITY_GUIDANCE_DEBUG_LOG") or os.path.join(
  40. state_dir(), "log.txt"
  41. )
  42. # Cap the debug log so parallel-worker fleets don't fill disk. When the active
  43. # file exceeds this it's atomically rotated to <file>.1 (overwriting any prior
  44. # rotation), so total disk stays ~2× this.
  45. DEBUG_LOG_MAX_BYTES = 1 * 1024 * 1024
  46. def debug_log(message):
  47. """Append debug message to log file with timestamp."""
  48. try:
  49. # Ensure parent dir exists — first hook invocation on a fresh install
  50. # creates ~/.claude/security/ if it isn't already there. 0700 so other
  51. # local users can't read review/debug output (only applies on creation).
  52. try:
  53. os.makedirs(os.path.dirname(DEBUG_LOG_FILE), mode=0o700, exist_ok=True)
  54. except OSError:
  55. pass
  56. try:
  57. if os.path.getsize(DEBUG_LOG_FILE) > DEBUG_LOG_MAX_BYTES:
  58. # os.replace is atomic on POSIX; under a racing fleet the loser
  59. # gets FileNotFoundError, which is fine — the append below
  60. # recreates the file.
  61. os.replace(DEBUG_LOG_FILE, DEBUG_LOG_FILE + ".1")
  62. except OSError:
  63. pass
  64. timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
  65. # 0600 on creation; existing files keep their mode.
  66. fd = os.open(DEBUG_LOG_FILE, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
  67. with os.fdopen(fd, "a") as f:
  68. f.write(f"[{timestamp}] {message}\n")
  69. except Exception:
  70. pass
  71. # Provenance tag prepended to injected/emitted text so a reader (especially a
  72. # model hardened against prompt injection) can recognize the source. Not an
  73. # authority claim — an attacker could spoof the exact string; the tag is a
  74. # signpost so the agent can ask the operator "is this from your plugin?" with
  75. # a concrete reference instead of treating it as unknown-actor injection.
  76. # Some autonomous-agent setups flag un-attributed injected text as prompt
  77. # injection and stall; the banner makes the provenance explicit.
  78. PROVENANCE_TAG = "[from security-guidance@claude-code-plugins plugin]"
  79. PROVENANCE_BANNER = (
  80. "[from security-guidance@claude-code-plugins plugin — automated "
  81. "security review, not user input.]"
  82. )
  83. def _read_plugin_version_int():
  84. """Encode plugin.json version "M.m.p" as M*10000 + m*100 + p so it fits the
  85. bool|number metrics constraint. Returns 0 if unreadable."""
  86. try:
  87. with open(os.path.join(os.path.dirname(__file__), "..", ".claude-plugin", "plugin.json")) as f:
  88. v = json.load(f)["version"]
  89. major, minor, patch = (int(x) for x in v.split(".")[:3])
  90. return major * 10000 + minor * 100 + patch
  91. except Exception:
  92. return 0
  93. _PV = _read_plugin_version_int()
  94. # ──────────────────────────────────────────────────────────────────────────
  95. # Token-usage accumulator. Each hook invocation is a fresh subprocess, so a
  96. # module-global is naturally per-invocation. _call_claude_dual_or and
  97. # _agentic_review_with_race run legs in ThreadPoolExecutor → lock required.
  98. # Emitted via _usage_metrics() into the existing emit_metrics() channel so
  99. # hook metrics rows carry per-invocation token/cost totals
  100. # alongside the existing skip_reason / vulns_found fields.
  101. _USAGE = {
  102. "in": 0, "out": 0, "cr": 0, "cw": 0, "cost": 0.0, "n": 0,
  103. # HTTP error visibility (#2098 visibility gap — see emit comment in
  104. # _usage_metrics). Without this, API failures from `_call_claude` left
  105. # zero fingerprint in telemetry: the call returns None, the caller's
  106. # emit_metrics carries no api_calls field, and the failure is
  107. # indistinguishable from "no review needed". The deprecation outage
  108. # that broke every commit-review LLM call was invisible until users
  109. # reported it manually.
  110. "http_err_last": 0, # most recent HTTP error code this invocation
  111. "http_err_count": 0, # total HTTP errors (4xx + 5xx + network)
  112. }
  113. _USAGE_LOCK = threading.Lock()
  114. # $/Mtok (input, output). Used only for the raw-HTTP path; the SDK path
  115. # reports total_cost_usd directly. Cache reads/writes are priced at the
  116. # canonical 0.1×/1.25× of input. Unknown models fall back to sonnet pricing
  117. # so cost_usd is never silently zero. Re-pricing downstream from the raw tok_*
  118. # fields is the source of truth — cost_usd here is a convenience rollup.
  119. _PRICE_PER_MTOK = {
  120. "claude-haiku-4-5": (1.0, 5.0),
  121. "claude-sonnet-4-6": (3.0, 15.0),
  122. "claude-opus-4-6": (15.0, 75.0),
  123. "claude-opus-4-7": (5.0, 25.0),
  124. }
  125. _PRICE_DEFAULT = (3.0, 15.0)
  126. def _record_usage(usage, model, cost_usd=None):
  127. """Accumulate one API response's token usage. `usage` is the Anthropic
  128. `usage` dict (HTTP) or the SDK ResultMessage.usage dict — both use the
  129. same key names. `cost_usd` (SDK-provided) is preferred when present;
  130. otherwise computed from _PRICE_PER_MTOK keyed on the response model id
  131. (longest-prefix match so `claude-sonnet-4-6-20251015` → sonnet row)."""
  132. if not usage and cost_usd is None:
  133. return
  134. u = usage or {}
  135. try:
  136. i = int(u.get("input_tokens") or 0)
  137. o = int(u.get("output_tokens") or 0)
  138. cr = int(u.get("cache_read_input_tokens") or 0)
  139. cw = int(u.get("cache_creation_input_tokens") or 0)
  140. except (TypeError, ValueError):
  141. return
  142. if cost_usd is None:
  143. pin, pout = _PRICE_DEFAULT
  144. m = (model or "").lower()
  145. for k, v in sorted(_PRICE_PER_MTOK.items(), key=lambda kv: -len(kv[0])):
  146. if m.startswith(k):
  147. pin, pout = v
  148. break
  149. cost_usd = (i * pin + o * pout + cr * pin * 0.1 + cw * pin * 1.25) / 1_000_000
  150. with _USAGE_LOCK:
  151. _USAGE["in"] += i
  152. _USAGE["out"] += o
  153. _USAGE["cr"] += cr
  154. _USAGE["cw"] += cw
  155. _USAGE["cost"] += float(cost_usd or 0.0)
  156. _USAGE["n"] += 1
  157. def _record_http_error(status):
  158. """Record an HTTP error from an LLM API call. `status` is the HTTP
  159. status code (integer 400–599) or -1 for network/timeout errors. Stored
  160. in `_USAGE["http_err_last"]` (most recent) and counted in
  161. `_USAGE["http_err_count"]`. Snapshot via `_usage_metrics()` so every
  162. subsequent `emit_metrics` includes the failure fingerprint.
  163. Background: without this, the most recent example was the #2098
  164. deprecation 400. Every hook fire's LLM call returned HTTP 400; the
  165. plugin caught it and returned None; the emit_metrics carried no
  166. api_calls field; aggregate dashboards looked normal. The failure
  167. only became visible when a user manually reported errors out of
  168. their debug log. With this field, a category-of-failure spike (4xx,
  169. 5xx, or -1 network) is queryable from BQ in real time.
  170. """
  171. try:
  172. s = int(status)
  173. except (TypeError, ValueError):
  174. return
  175. with _USAGE_LOCK:
  176. _USAGE["http_err_last"] = s
  177. _USAGE["http_err_count"] += 1
  178. def _usage_metrics():
  179. """Snapshot the accumulator as metric keys. Returns {} when no API calls
  180. AND no HTTP errors were made so skip-path emits don't burn key budget.
  181. cost_usd rounded to 1e-6 to keep the float finite/short for the zod
  182. schema.
  183. HTTP errors (`http_err_last`, `http_err_count`) emitted ONLY when
  184. `http_err_count > 0` so successful calls don't pad every metrics row
  185. with two zero fields.
  186. """
  187. with _USAGE_LOCK:
  188. if _USAGE["n"] == 0 and _USAGE["http_err_count"] == 0:
  189. return {}
  190. out = {}
  191. if _USAGE["n"] > 0:
  192. out.update({
  193. "tok_in": _USAGE["in"],
  194. "tok_out": _USAGE["out"],
  195. "tok_cache_r": _USAGE["cr"],
  196. "tok_cache_w": _USAGE["cw"],
  197. "cost_usd": round(_USAGE["cost"], 6),
  198. "api_calls": _USAGE["n"],
  199. })
  200. if _USAGE["http_err_count"] > 0:
  201. out["http_err_last"] = _USAGE["http_err_last"]
  202. out["http_err_count"] = _USAGE["http_err_count"]
  203. return out