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