diffstate.py 21 KB

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