diffstate.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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. baseline_sha = load_baseline_sha(session_id)
  117. if not baseline_sha:
  118. return None
  119. try:
  120. abs_path = os.path.abspath(file_path)
  121. cwd_abs = os.path.abspath(cwd) if cwd else os.getcwd()
  122. try:
  123. rel_path = os.path.relpath(abs_path, cwd_abs)
  124. except ValueError:
  125. return None
  126. result = subprocess.run(
  127. [*GIT_CMD, "show", f"{baseline_sha}:{rel_path}"],
  128. cwd=cwd, capture_output=True, text=True, timeout=5
  129. )
  130. if result.returncode == 0:
  131. return result.stdout
  132. return None
  133. except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
  134. return None
  135. def capture_git_baseline(cwd):
  136. """
  137. Capture a git ref representing the current working tree state.
  138. Uses `git stash create` which creates a commit object for the current state
  139. (HEAD + uncommitted changes) without modifying the stash list or working tree.
  140. Falls back to HEAD if the working tree is clean.
  141. Returns the SHA string, or None if not in a git repo or if the repo has no commits.
  142. NOTE: `git stash create` does NOT capture untracked files. UPS pairs this
  143. SHA with a `_list_untracked()` snapshot stored as `untracked_at_baseline`,
  144. and `compute_v2_review_set` subtracts that set so pre-existing untracked
  145. files are not reviewed as Claude-authored.
  146. """
  147. try:
  148. # Check if HEAD exists (i.e., repo has at least one commit)
  149. head_check = subprocess.run(
  150. [*GIT_CMD, "rev-parse", "HEAD"],
  151. cwd=cwd, capture_output=True, text=True, timeout=5
  152. )
  153. if head_check.returncode != 0:
  154. # No commits yet — skip review rather than creating commits in the user's repo
  155. debug_log("No commits in repo, skipping baseline capture")
  156. return None
  157. result = subprocess.run(
  158. [*GIT_CMD, "stash", "create"],
  159. cwd=cwd, capture_output=True, text=True, timeout=15
  160. )
  161. sha = result.stdout.strip()
  162. if sha:
  163. return sha
  164. # Working tree is clean — stash create returns empty. Use HEAD.
  165. result = subprocess.run(
  166. [*GIT_CMD, "rev-parse", "HEAD"],
  167. cwd=cwd, capture_output=True, text=True, timeout=5
  168. )
  169. sha = result.stdout.strip()
  170. return sha if sha else None
  171. except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
  172. debug_log(f"Failed to capture git baseline: {e}")
  173. return None
  174. # ─── push-sweep reviewed-commit tracking ────────────────────────────────────
  175. #
  176. # Repo-local (not session-local) record of which commits the commit-review
  177. # hook has already reviewed, so the push-sweep can advance its diff base past
  178. # the contiguous reviewed prefix and skip entirely when everything pushed was
  179. # already covered. Lives under `.git/` (same precedent as CC's
  180. # `.git/claude-trailers`) so it survives across sessions and is per-clone.
  181. #
  182. # Format: one line per reviewed sha, append-only:
  183. # <40-hex-sha>\t<unix-ts>\t<pv>\t<vulns_found>
  184. #
  185. # The trailing columns are observability only — load reads just the sha set.
  186. # GC keeps the last _REVIEWED_SHAS_CAP entries; the file is small (~64 bytes
  187. # per line) so even at the cap it's ~32KB.
  188. # =====================================================================
  189. # Reviewed-SHA log (commit/push dedup)
  190. # =====================================================================
  191. # ─── push-sweep reviewed-commit tracking ────────────────────────────────────
  192. #
  193. # Repo-local (not session-local) record of which commits the commit-review
  194. # hook has already reviewed, so the push-sweep can advance its diff base past
  195. # the contiguous reviewed prefix and skip entirely when everything pushed was
  196. # already covered. Lives under `.git/` (same precedent as CC's
  197. # `.git/claude-trailers`) so it survives across sessions and is per-clone.
  198. #
  199. # Format: one line per reviewed sha, append-only:
  200. # <40-hex-sha>\t<unix-ts>\t<pv>\t<vulns_found>
  201. #
  202. # The trailing columns are observability only — load reads just the sha set.
  203. # GC keeps the last _REVIEWED_SHAS_CAP entries; the file is small (~64 bytes
  204. # per line) so even at the cap it's ~32KB.
  205. _REVIEWED_SHAS_BASENAME = "sg-reviewed-shas"
  206. _REVIEWED_SHAS_CAP = 500
  207. def _reviewed_shas_path(repo_root):
  208. gd = _git_dir(repo_root)
  209. return os.path.join(gd, _REVIEWED_SHAS_BASENAME) if gd else None
  210. def _load_reviewed_shas(repo_root):
  211. """Set of full 40-hex shas previously reviewed in this clone."""
  212. p = _reviewed_shas_path(repo_root)
  213. if not p or not os.path.exists(p):
  214. return set()
  215. out = set()
  216. try:
  217. with open(p, "r") as f:
  218. for line in f:
  219. sha = line.split("\t", 1)[0].strip()
  220. if len(sha) == 40 and all(c in "0123456789abcdef" for c in sha):
  221. out.add(sha)
  222. except OSError:
  223. pass
  224. return out
  225. def _append_reviewed_shas(repo_root, shas, vulns_found=0):
  226. """Record that `shas` were reviewed. Best-effort; never raises.
  227. Uses fcntl.flock for the read-gc-write; appends are O_APPEND-atomic but
  228. GC needs the lock so concurrent CC sessions in the same clone don't race
  229. each other's truncation.
  230. """
  231. p = _reviewed_shas_path(repo_root)
  232. if not p or not shas:
  233. return
  234. import time as _time
  235. ts = int(_time.time())
  236. pv = _PV or 0
  237. lines = [f"{s}\t{ts}\t{pv}\t{int(vulns_found)}\n" for s in shas]
  238. try:
  239. import fcntl
  240. with open(p, "a+") as f:
  241. fcntl.flock(f.fileno(), fcntl.LOCK_EX)
  242. try:
  243. f.seek(0)
  244. existing = f.read().splitlines(keepends=True)
  245. # Dedup by sha (first column) — keep newest, then cap.
  246. seen = set()
  247. merged = []
  248. for ln in (existing + lines)[::-1]:
  249. sha = ln.split("\t", 1)[0].strip()
  250. if sha and sha not in seen:
  251. seen.add(sha)
  252. merged.append(ln if ln.endswith("\n") else ln + "\n")
  253. merged = merged[:_REVIEWED_SHAS_CAP][::-1]
  254. f.seek(0)
  255. f.truncate()
  256. f.writelines(merged)
  257. finally:
  258. fcntl.flock(f.fileno(), fcntl.LOCK_UN)
  259. except (OSError, ImportError):
  260. # fcntl unavailable (Windows) or write failed — degrade to plain
  261. # append; cap enforcement happens on the next locked write.
  262. try:
  263. with open(p, "a") as f:
  264. f.writelines(lines)
  265. except OSError:
  266. pass
  267. # =====================================================================
  268. # v2 review-set computation (Stop hook)
  269. # =====================================================================
  270. UNTRACKED_BASELINE_CAP = 2000
  271. def _list_untracked(cwd):
  272. """Repo-root-relative untracked (and not-ignored) path → mtime_ns, or {}
  273. on error. Used at UPS to snapshot the pre-turn untracked set so the Stop
  274. hook can exclude unchanged pre-existing untracked files from review.
  275. mtime is captured so an in-place edit during the turn is still reviewed.
  276. Uses ls-files (not status) for the UPS path: the index diff isn't needed,
  277. and ls-files --others only walks the worktree against .gitignore."""
  278. try:
  279. repo = _git_toplevel(cwd) or cwd
  280. r = subprocess.run(
  281. [*GIT_CMD, "-c", "core.quotePath=false", "ls-files",
  282. "--others", "--exclude-standard", "-z"],
  283. cwd=repo, capture_output=True, text=True, timeout=15,
  284. )
  285. if r.returncode != 0:
  286. debug_log(f"_list_untracked rc={r.returncode}: {r.stderr[:200]}")
  287. return {}
  288. out = {}
  289. for p in r.stdout.split("\0"):
  290. if not p:
  291. continue
  292. try:
  293. out[p] = os.stat(os.path.join(repo, p)).st_mtime_ns
  294. except OSError:
  295. out[p] = 0
  296. if len(out) >= UNTRACKED_BASELINE_CAP:
  297. debug_log(f"_list_untracked: capped at {UNTRACKED_BASELINE_CAP}")
  298. break
  299. return out
  300. except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
  301. debug_log(f"_list_untracked error: {e}")
  302. return {}
  303. def compute_v2_review_set(cwd, baseline_sha, head_at_capture, untracked_at_baseline=None):
  304. """v2 diff strategy: derive the review set from git state alone.
  305. review_set = (files dirty vs current HEAD, plus files committed this turn
  306. when HEAD advanced linearly) ∩ (files whose content differs from the
  307. pre-turn stash baseline). The first term is immune to checkout/pull
  308. ballooning; the second filters out the user's untouched pre-turn WIP.
  309. Falls back to dirty_now alone when no baseline is available.
  310. untracked_at_baseline: {repo-root-relative path: mtime_ns} captured at
  311. UPS. `git stash create` doesn't include untracked files, so without this
  312. snapshot a pre-existing untracked file looks "new since baseline" forever.
  313. A file is excluded only if it was untracked at baseline AND its mtime is
  314. unchanged — an in-place edit during the turn is still reviewed.
  315. Known limitation: a Bash-only turn that's interrupted before Stop fires
  316. leaves touched_paths empty, so the next UPS re-baselines past those edits.
  317. v1 never reviews Bash-only turns at all, so v2 is no worse there.
  318. Returns (absolute paths sorted, diff_base, repo_root, metrics).
  319. diff_base is "HEAD" unless HEAD advanced linearly this turn (commits),
  320. in which case it's head_at_capture so committed files produce a diff.
  321. repo_root is the git toplevel — `git diff --name-only` outputs paths
  322. relative to it (not to cwd), so the caller's get_git_diff must run
  323. from there too or pathspecs won't match.
  324. Also returns the untracked subset of review_set so get_git_diff can do
  325. a targeted `add -N -- <files>` instead of a whole-tree scan.
  326. """
  327. repo = _git_toplevel(cwd) or cwd
  328. if not isinstance(untracked_at_baseline, dict):
  329. untracked_at_baseline = {}
  330. tracked_dirty, untracked = _git_status_porcelain(repo)
  331. if tracked_dirty is None:
  332. return [], "HEAD", repo, [], {"dirty_now_count": -1, "changed_since_count": -1, "review_set_count": 0}
  333. def _unchanged_since_baseline(p):
  334. base_mtime = untracked_at_baseline.get(p)
  335. if base_mtime is None:
  336. return False
  337. try:
  338. return os.stat(os.path.join(repo, p)).st_mtime_ns == base_mtime
  339. except OSError:
  340. return False
  341. preexisting_unchanged = {p for p in untracked if _unchanged_since_baseline(p)}
  342. new_untracked = untracked - preexisting_unchanged
  343. dirty_now = tracked_dirty | new_untracked
  344. diff_base = "HEAD"
  345. current_head = _git_rev_parse_head(repo)
  346. if (head_at_capture and current_head and head_at_capture != current_head
  347. and _is_ancestor(repo, head_at_capture, current_head)):
  348. dirty_now |= _git_name_only(repo, f"{head_at_capture}..HEAD") or set()
  349. diff_base = head_at_capture
  350. # changed_since: tracked files vs the stash baseline (no temp index — the
  351. # stash never contained untracked files anyway), then union with
  352. # currently-untracked. The previous `include_untracked=True` arm cost a
  353. # full `git add -N .` (slow in large repos) per call to surface
  354. # untracked files in the diff output — but `git diff <stash>` already
  355. # lists them as "only in worktree" without that, and we have the explicit
  356. # set from status regardless.
  357. if baseline_sha:
  358. changed_since = _git_name_only(repo, baseline_sha)
  359. if changed_since is not None:
  360. changed_since |= new_untracked
  361. else:
  362. changed_since = None
  363. # changed_since is None on missing baseline OR on git error (e.g. the
  364. # dangling stash SHA was pruned). Either way, don't intersect with ∅ —
  365. # that would silently zero the review set. Fall back to dirty_now.
  366. review_set = (dirty_now & changed_since) if changed_since is not None else dirty_now
  367. review_paths = [os.path.join(repo, p) for p in sorted(review_set)]
  368. untracked_in_review = sorted(new_untracked & review_set)
  369. metrics = {
  370. "dirty_now_count": len(dirty_now),
  371. "changed_since_count": len(changed_since) if changed_since is not None else -1,
  372. "review_set_count": len(review_set),
  373. }
  374. # Only emit when nonzero to stay under the 10-key telemetry cap.
  375. if preexisting_unchanged:
  376. metrics["preexisting_untracked_excluded"] = len(preexisting_unchanged)
  377. return review_paths, diff_base, repo, untracked_in_review, metrics