spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

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