diffstate.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. """
  2. Git-derived diff/review-state helpers for the security-guidance plugin.
  3. Extracted from security_reminder_hook.py for readability. Re-exported
  4. there so callers keep resolving bare names through the hook module's
  5. globals — tests that ``monkeypatch.setattr(hook, "<fn>", …)`` continue
  6. to work without retargeting.
  7. """
  8. import os
  9. import subprocess
  10. from _base import debug_log, _PV
  11. from gitutil import (
  12. GIT_CMD,
  13. _git_dir, _git_toplevel, _git_status_porcelain,
  14. _git_rev_parse_head, _is_ancestor, _git_name_only,
  15. )
  16. from session_state import with_locked_state
  17. # =====================================================================
  18. # TTL constants
  19. # =====================================================================
  20. # stop_hook_fire_count expires after this many seconds.
  21. # The asyncRewake loop (vuln→exit(2)→fix→Stop again) is ~30-60s/cycle, so 120s
  22. # comfortably contains MAX_STOP_HOOK_FIRINGS while letting the next user turn
  23. # proceed unblocked. Replaces the UPS-reset that raced against background Stop.
  24. STOP_LOOP_STATE_TTL_SEC = 120
  25. # previous_findings expires independently. Dedup is content-based ((filePath,
  26. # vulnerableCode) — see _record_fire), so a longer TTL suppresses exact-repeat
  27. # re-flags across turns without masking regressions that change the code. v2's
  28. # git-derived review set can re-surface the same uncommitted file across turns;
  29. # 120s could let warnings pile up over a long session.
  30. PREVIOUS_FINDINGS_TTL_SEC = int(os.environ.get("PREVIOUS_FINDINGS_TTL_SEC", "3600"))
  31. # =====================================================================
  32. # Git baseline + stop-state management
  33. # =====================================================================
  34. def save_baseline_sha(session_id, sha):
  35. """Save the git baseline SHA to state."""
  36. def _save(state):
  37. state["baseline_sha"] = sha
  38. with_locked_state(session_id, _save)
  39. def load_baseline_sha(session_id):
  40. """Load the git baseline SHA from state."""
  41. def _load(state):
  42. return state.get("baseline_sha")
  43. return with_locked_state(session_id, _load)
  44. def record_touched_path(session_id, file_path):
  45. """Append a file path to the touched_paths list (deduped, capped at 200).
  46. Stop is the consumer and clears under the same lock it reads with; UPS
  47. no longer wipes. The cap is a defensive bound for sessions where Stop
  48. never fires (disabled mid-session, abort) — git diff naturally filters
  49. stale paths so over-retention is harmless, just wasteful.
  50. """
  51. def _record(state):
  52. paths = state.setdefault("touched_paths", [])
  53. if file_path not in paths:
  54. paths.append(file_path)
  55. if len(paths) > 200:
  56. del paths[:len(paths) - 200]
  57. with_locked_state(session_id, _record)
  58. def consume_stop_state(session_id):
  59. """Atomically snapshot all state the Stop hook needs and clear touched_paths.
  60. The Stop hook is asyncRewake — it runs in the background after Claude's
  61. turn ends. The user can submit a new prompt before this hook finishes its
  62. initial state read. Telemetry showed a meaningful share of would-be reviews lost when
  63. the next turn's UPS wiped touched_paths before Stop read it.
  64. Single locked read-then-clear closes that window: PostToolUse appends
  65. after this clear go into the next snapshot; UPS overwrites of baseline_sha
  66. after this snapshot are invisible to this Stop fire.
  67. """
  68. import time as _time
  69. now = _time.time()
  70. def _snap(state):
  71. fire_ts = state.get("stop_hook_fire_count_ts", 0)
  72. expired = (now - fire_ts) > STOP_LOOP_STATE_TTL_SEC
  73. findings_ts = state.get("previous_findings_ts", fire_ts)
  74. findings_expired = (now - findings_ts) > PREVIOUS_FINDINGS_TTL_SEC
  75. snap = {
  76. "touched_paths": list(state.get("touched_paths", [])),
  77. "baseline_sha": state.get("baseline_sha"),
  78. "head_at_capture": state.get("head_at_capture"),
  79. "untracked_at_baseline": (
  80. dict(state["untracked_at_baseline"])
  81. if isinstance(state.get("untracked_at_baseline"), dict) else {}
  82. ),
  83. "fire_count": 0 if expired else state.get("stop_hook_fire_count", 0),
  84. "fire_count_expired": expired and state.get("stop_hook_fire_count", 0) > 0,
  85. "previous_findings": [] if findings_expired else list(state.get("previous_findings", [])),
  86. }
  87. state["touched_paths"] = []
  88. return snap
  89. return with_locked_state(session_id, _snap) or {
  90. "touched_paths": [], "baseline_sha": None, "head_at_capture": None,
  91. "untracked_at_baseline": {},
  92. "fire_count": 0, "fire_count_expired": False, "previous_findings": [],
  93. }
  94. def restore_unreviewed_stop_state(session_id, paths, baseline_sha):
  95. """Put consumed touched_paths back so the next Stop reviews them.
  96. consume_stop_state cleared touched_paths on disk; if Stop then exits
  97. early for a transient reason (CCR API unreachable, Haiku HTTP error)
  98. the next UPS would see an empty list, fall through the preservation
  99. guard, and re-baseline past the unreviewed edits. Restoring keeps the
  100. guard armed. Prepend+dedupe so any concurrent next-turn PostToolUse
  101. appends survive.
  102. """
  103. if not paths:
  104. return
  105. def _restore(state):
  106. existing = state.get("touched_paths", [])
  107. merged = list(dict.fromkeys(list(paths) + list(existing)))
  108. if len(merged) > 200:
  109. merged = merged[:200]
  110. state["touched_paths"] = merged
  111. if baseline_sha and not state.get("baseline_sha"):
  112. state["baseline_sha"] = baseline_sha
  113. with_locked_state(session_id, _restore)
  114. def get_baseline_file_content(session_id, file_path, cwd):
  115. """Get the content of a file at the baseline SHA. Returns None if unavailable.
  116. Decode the file content as UTF-8 with errors="replace" rather than using
  117. text=True: source files in user repos can be latin-1 / cp1252 / shift-jis
  118. / etc., and on Windows text=True would decode via locale.getpreferredencoding()
  119. in strict mode and raise UnicodeDecodeError in the subprocess reader
  120. thread — leaving result.stdout=None and propagating AttributeError when
  121. the caller tries to use it. Same class as the existing migrations at
  122. security_reminder_hook.py:540 (reflog subjects) and :1115 (commit
  123. diffs); this helper was missed in that pass. See
  124. anthropics/claude-plugins-official#2056."""
  125. baseline_sha = load_baseline_sha(session_id)
  126. if not baseline_sha:
  127. return None
  128. try:
  129. abs_path = os.path.abspath(file_path)
  130. cwd_abs = os.path.abspath(cwd) if cwd else os.getcwd()
  131. try:
  132. rel_path = os.path.relpath(abs_path, cwd_abs)
  133. except ValueError:
  134. return None
  135. result = subprocess.run(
  136. [*GIT_CMD, "show", f"{baseline_sha}:{rel_path}"],
  137. cwd=cwd, capture_output=True, timeout=5
  138. )
  139. if result.returncode == 0:
  140. return (result.stdout or b"").decode("utf-8", errors="replace")
  141. return None
  142. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
  143. return None
  144. def capture_git_baseline(cwd):
  145. """
  146. Capture a git ref representing the current working tree state.
  147. Uses `git stash create` which creates a commit object for the current state
  148. (HEAD + uncommitted changes) without modifying the stash list or working tree.
  149. Falls back to HEAD if the working tree is clean.
  150. Returns the SHA string, or None if not in a git repo or if the repo has no commits.
  151. NOTE: `git stash create` does NOT capture untracked files. UPS pairs this
  152. SHA with a `_list_untracked()` snapshot stored as `untracked_at_baseline`,
  153. and `compute_v2_review_set` subtracts that set so pre-existing untracked
  154. files are not reviewed as Claude-authored.
  155. """
  156. # stdout is a SHA so text=True is safe on stdout, but a non-ASCII
  157. # filename in `git stash create`'s STDERR warning (e.g. a worktree
  158. # with `Ávila_report.txt` triggers a quotePath/locale warning) would
  159. # trip the stderr reader thread on Windows cp1252. Decode both streams
  160. # leniently for symmetry with _list_untracked. See #2056.
  161. try:
  162. # Check if HEAD exists (i.e., repo has at least one commit)
  163. head_check = subprocess.run(
  164. [*GIT_CMD, "rev-parse", "HEAD"],
  165. cwd=cwd, capture_output=True, timeout=5
  166. )
  167. if head_check.returncode != 0:
  168. # No commits yet — skip review rather than creating commits in the user's repo
  169. debug_log("No commits in repo, skipping baseline capture")
  170. return None
  171. result = subprocess.run(
  172. [*GIT_CMD, "stash", "create"],
  173. cwd=cwd, capture_output=True, timeout=15
  174. )
  175. sha = (result.stdout or b"").decode("utf-8", errors="replace").strip()
  176. if sha:
  177. return sha
  178. # Working tree is clean — stash create returns empty. Use HEAD.
  179. result = subprocess.run(
  180. [*GIT_CMD, "rev-parse", "HEAD"],
  181. cwd=cwd, capture_output=True, timeout=5
  182. )
  183. sha = (result.stdout or b"").decode("utf-8", errors="replace").strip()
  184. return sha if sha else None
  185. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  186. debug_log(f"Failed to capture git baseline: {e}")
  187. return None
  188. # ─── push-sweep reviewed-commit tracking ────────────────────────────────────
  189. #
  190. # Repo-local (not session-local) record of which commits the commit-review
  191. # hook has already reviewed, so the push-sweep can advance its diff base past
  192. # the contiguous reviewed prefix and skip entirely when everything pushed was
  193. # already covered. Lives under `.git/` (same precedent as CC's
  194. # `.git/claude-trailers`) so it survives across sessions and is per-clone.
  195. #
  196. # Format: one line per reviewed sha, append-only:
  197. # <40-hex-sha>\t<unix-ts>\t<pv>\t<vulns_found>
  198. #
  199. # The trailing columns are observability only — load reads just the sha set.
  200. # GC keeps the last _REVIEWED_SHAS_CAP entries; the file is small (~64 bytes
  201. # per line) so even at the cap it's ~32KB.
  202. # =====================================================================
  203. # Reviewed-SHA log (commit/push dedup)
  204. # =====================================================================
  205. # ─── push-sweep reviewed-commit tracking ────────────────────────────────────
  206. #
  207. # Repo-local (not session-local) record of which commits the commit-review
  208. # hook has already reviewed, so the push-sweep can advance its diff base past
  209. # the contiguous reviewed prefix and skip entirely when everything pushed was
  210. # already covered. Lives under `.git/` (same precedent as CC's
  211. # `.git/claude-trailers`) so it survives across sessions and is per-clone.
  212. #
  213. # Format: one line per reviewed sha, append-only:
  214. # <40-hex-sha>\t<unix-ts>\t<pv>\t<vulns_found>
  215. #
  216. # The trailing columns are observability only — load reads just the sha set.
  217. # GC keeps the last _REVIEWED_SHAS_CAP entries; the file is small (~64 bytes
  218. # per line) so even at the cap it's ~32KB.
  219. _REVIEWED_SHAS_BASENAME = "sg-reviewed-shas"
  220. _REVIEWED_SHAS_CAP = 500
  221. def _reviewed_shas_path(repo_root):
  222. gd = _git_dir(repo_root)
  223. return os.path.join(gd, _REVIEWED_SHAS_BASENAME) if gd else None
  224. def _load_reviewed_shas(repo_root):
  225. """Set of full 40-hex shas previously reviewed in this clone."""
  226. p = _reviewed_shas_path(repo_root)
  227. if not p or not os.path.exists(p):
  228. return set()
  229. out = set()
  230. try:
  231. with open(p, "r") as f:
  232. for line in f:
  233. sha = line.split("\t", 1)[0].strip()
  234. if len(sha) == 40 and all(c in "0123456789abcdef" for c in sha):
  235. out.add(sha)
  236. except OSError:
  237. pass
  238. return out
  239. def _append_reviewed_shas(repo_root, shas, vulns_found=0):
  240. """Record that `shas` were reviewed. Best-effort; never raises.
  241. Uses fcntl.flock for the read-gc-write; appends are O_APPEND-atomic but
  242. GC needs the lock so concurrent CC sessions in the same clone don't race
  243. each other's truncation.
  244. """
  245. p = _reviewed_shas_path(repo_root)
  246. if not p or not shas:
  247. return
  248. import time as _time
  249. ts = int(_time.time())
  250. pv = _PV or 0
  251. lines = [f"{s}\t{ts}\t{pv}\t{int(vulns_found)}\n" for s in shas]
  252. try:
  253. import fcntl
  254. with open(p, "a+") as f:
  255. fcntl.flock(f.fileno(), fcntl.LOCK_EX)
  256. try:
  257. f.seek(0)
  258. existing = f.read().splitlines(keepends=True)
  259. # Dedup by sha (first column) — keep newest, then cap.
  260. seen = set()
  261. merged = []
  262. for ln in (existing + lines)[::-1]:
  263. sha = ln.split("\t", 1)[0].strip()
  264. if sha and sha not in seen:
  265. seen.add(sha)
  266. merged.append(ln if ln.endswith("\n") else ln + "\n")
  267. merged = merged[:_REVIEWED_SHAS_CAP][::-1]
  268. f.seek(0)
  269. f.truncate()
  270. f.writelines(merged)
  271. finally:
  272. fcntl.flock(f.fileno(), fcntl.LOCK_UN)
  273. except (OSError, ImportError):
  274. # fcntl unavailable (Windows) or write failed — degrade to plain
  275. # append; cap enforcement happens on the next locked write.
  276. try:
  277. with open(p, "a") as f:
  278. f.writelines(lines)
  279. except OSError:
  280. pass
  281. # =====================================================================
  282. # v2 review-set computation (Stop hook)
  283. # =====================================================================
  284. UNTRACKED_BASELINE_CAP = 2000
  285. def _list_untracked(cwd):
  286. """Repo-root-relative untracked (and not-ignored) path → mtime_ns, or {}
  287. on error. Used at UPS to snapshot the pre-turn untracked set so the Stop
  288. hook can exclude unchanged pre-existing untracked files from review.
  289. mtime is captured so an in-place edit during the turn is still reviewed.
  290. Uses ls-files (not status) for the UPS path: the index diff isn't needed,
  291. and ls-files --others only walks the worktree against .gitignore.
  292. Decodes stdout/stderr as UTF-8 with errors="replace" instead of using
  293. text=True. With core.quotePath=false git emits raw UTF-8 bytes for
  294. non-ASCII filenames; text=True decodes via locale.getpreferredencoding()
  295. in strict mode — on Windows that's cp1252 with several undefined bytes
  296. (0x81/0x8D/0x8F/0x90/0x9D), all of which appear in UTF-8 encodings of
  297. common accented capitals (Á Í Ï Ð Ý) and most CJK/emoji codepoints.
  298. A non-ASCII filename in the worktree crashed the subprocess reader
  299. thread, left r.stdout=None, and propagated AttributeError out of the
  300. helper — silently losing the baseline snapshot every UserPromptSubmit.
  301. See anthropics/claude-plugins-official#2056. The sibling helpers in
  302. gitutil.py already follow the lenient pattern; this function and
  303. capture_git_baseline / _git_name_only / _git_status_porcelain were
  304. the holdouts."""
  305. try:
  306. repo = _git_toplevel(cwd) or cwd
  307. # core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
  308. r = subprocess.run(
  309. [*GIT_CMD, "ls-files", "--others", "--exclude-standard", "-z"],
  310. cwd=repo, capture_output=True, timeout=15,
  311. )
  312. if r.returncode != 0:
  313. stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
  314. debug_log(f"_list_untracked rc={r.returncode}: {stderr_str[:200]}")
  315. return {}
  316. stdout = (r.stdout or b"").decode("utf-8", errors="replace")
  317. out = {}
  318. for p in stdout.split("\0"):
  319. if not p:
  320. continue
  321. try:
  322. out[p] = os.stat(os.path.join(repo, p)).st_mtime_ns
  323. except OSError:
  324. out[p] = 0
  325. if len(out) >= UNTRACKED_BASELINE_CAP:
  326. debug_log(f"_list_untracked: capped at {UNTRACKED_BASELINE_CAP}")
  327. break
  328. return out
  329. except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
  330. # ValueError guards against any future strict-decode regression
  331. # so the helper degrades to {} instead of crashing the hook.
  332. debug_log(f"_list_untracked error: {e}")
  333. return {}
  334. def compute_v2_review_set(cwd, baseline_sha, head_at_capture, untracked_at_baseline=None):
  335. """v2 diff strategy: derive the review set from git state alone.
  336. review_set = (files dirty vs current HEAD, plus files committed this turn
  337. when HEAD advanced linearly) ∩ (files whose content differs from the
  338. pre-turn stash baseline). The first term is immune to checkout/pull
  339. ballooning; the second filters out the user's untouched pre-turn WIP.
  340. Falls back to dirty_now alone when no baseline is available.
  341. untracked_at_baseline: {repo-root-relative path: mtime_ns} captured at
  342. UPS. `git stash create` doesn't include untracked files, so without this
  343. snapshot a pre-existing untracked file looks "new since baseline" forever.
  344. A file is excluded only if it was untracked at baseline AND its mtime is
  345. unchanged — an in-place edit during the turn is still reviewed.
  346. Known limitation: a Bash-only turn that's interrupted before Stop fires
  347. leaves touched_paths empty, so the next UPS re-baselines past those edits.
  348. v1 never reviews Bash-only turns at all, so v2 is no worse there.
  349. Returns (absolute paths sorted, diff_base, repo_root, metrics).
  350. diff_base is "HEAD" unless HEAD advanced linearly this turn (commits),
  351. in which case it's head_at_capture so committed files produce a diff.
  352. repo_root is the git toplevel — `git diff --name-only` outputs paths
  353. relative to it (not to cwd), so the caller's get_git_diff must run
  354. from there too or pathspecs won't match.
  355. Also returns the untracked subset of review_set so get_git_diff can do
  356. a targeted `add -N -- <files>` instead of a whole-tree scan.
  357. """
  358. repo = _git_toplevel(cwd) or cwd
  359. if not isinstance(untracked_at_baseline, dict):
  360. untracked_at_baseline = {}
  361. tracked_dirty, untracked = _git_status_porcelain(repo)
  362. if tracked_dirty is None:
  363. return [], "HEAD", repo, [], {"dirty_now_count": -1, "changed_since_count": -1, "review_set_count": 0}
  364. def _unchanged_since_baseline(p):
  365. base_mtime = untracked_at_baseline.get(p)
  366. if base_mtime is None:
  367. return False
  368. try:
  369. return os.stat(os.path.join(repo, p)).st_mtime_ns == base_mtime
  370. except OSError:
  371. return False
  372. preexisting_unchanged = {p for p in untracked if _unchanged_since_baseline(p)}
  373. new_untracked = untracked - preexisting_unchanged
  374. dirty_now = tracked_dirty | new_untracked
  375. diff_base = "HEAD"
  376. current_head = _git_rev_parse_head(repo)
  377. if (head_at_capture and current_head and head_at_capture != current_head
  378. and _is_ancestor(repo, head_at_capture, current_head)):
  379. dirty_now |= _git_name_only(repo, f"{head_at_capture}..HEAD") or set()
  380. diff_base = head_at_capture
  381. # changed_since: tracked files vs the stash baseline (no temp index — the
  382. # stash never contained untracked files anyway), then union with
  383. # currently-untracked. The previous `include_untracked=True` arm cost a
  384. # full `git add -N .` (slow in large repos) per call to surface
  385. # untracked files in the diff output — but `git diff <stash>` already
  386. # lists them as "only in worktree" without that, and we have the explicit
  387. # set from status regardless.
  388. if baseline_sha:
  389. changed_since = _git_name_only(repo, baseline_sha)
  390. if changed_since is not None:
  391. changed_since |= new_untracked
  392. else:
  393. changed_since = None
  394. # changed_since is None on missing baseline OR on git error (e.g. the
  395. # dangling stash SHA was pruned). Either way, don't intersect with ∅ —
  396. # that would silently zero the review set. Fall back to dirty_now.
  397. review_set = (dirty_now & changed_since) if changed_since is not None else dirty_now
  398. review_paths = [os.path.join(repo, p) for p in sorted(review_set)]
  399. untracked_in_review = sorted(new_untracked & review_set)
  400. metrics = {
  401. "dirty_now_count": len(dirty_now),
  402. "changed_since_count": len(changed_since) if changed_since is not None else -1,
  403. "review_set_count": len(review_set),
  404. }
  405. # Only emit when nonzero to stay under the 10-key telemetry cap.
  406. if preexisting_unchanged:
  407. metrics["preexisting_untracked_excluded"] = len(preexisting_unchanged)
  408. return review_paths, diff_base, repo, untracked_in_review, metrics