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 / gitutil.py
1,101 lines · 45.0 KBRaw
1"""
2Leaf git/subprocess helpers and diff parsing for the security-guidance plugin.
3
4Everything here is a thin wrapper over ``git``/``subprocess`` plus pure
5diff-text parsing and source-file classification. None of these functions
6reference any name that the test suite monkeypatches on
7``security_reminder_hook`` and then calls *through* another function in this
8module — that property is what makes them safe to live in their own module
9while still being re-exported (so tests that patch ``hook._git_toplevel`` and
10then call a handler in ``security_reminder_hook`` continue to see the patched
11binding).
12
13Functions that DO compose patched leaves (``compute_v2_review_set``,
14``_list_untracked``, ``_append_reviewed_shas``) deliberately remain in
15``security_reminder_hook.py`` for that reason.
16"""
17import contextlib
18import os
19import re
20import subprocess
21
22from _base import debug_log
23
24
25GIT_CMD = [
26 "git",
27 "-c", "core.fsmonitor=false",
28 "-c", "core.hooksPath=/dev/null",
29 # core.quotePath=false: emit raw UTF-8 in path-emitting commands instead
30 # of C-quoting non-ASCII bytes (default `"\\303\\201vila/..."` vs
31 # `Ávila/...`). Downstream parsers — both ours (parse_diff_into_files,
32 # extract_file_paths_from_diff) and Python stdlib (os.path.isabs,
33 # os.path.join) — expect raw paths and silently drop / mishandle the
34 # quoted form. Adding the flag globally to GIT_CMD covers every
35 # subprocess.run site that uses the splat — diff feeders, rev-parse
36 # path queries (--show-toplevel, --git-dir, --git-common-dir),
37 # reflog %gs subjects, ls-files, status, etc. — without per-site
38 # flag duplication. See #2082, #2099.
39 "-c", "core.quotePath=false",
40 # A work-tree `git diff` that meets stat-stale entries (mtime changed,
41 # content same) rewrites the index under index.lock, GIT_OPTIONAL_LOCKS or
42 # not. Off, every diff here only reads the index, so none needs a copy.
43 "-c", "diff.autoRefreshIndex=false",
44]
45
46SAFE_GIT_CONFIG = (
47 ("core.fsmonitor", "false"),
48 ("core.hooksPath", "/dev/null"),
49)
50
51# The hook's git runs in the background beside the session's own git. Without
52# this, `git status` holds .git/index.lock while it writes back a refreshed
53# index: a concurrent `git add` fails, and a timeout kill leaves the lock.
54SAFE_GIT_ENV = {
55 "GIT_OPTIONAL_LOCKS": "0",
56}
57
58
59def git_config_env(pairs, base=None):
60 base = os.environ if base is None else base
61 try:
62 n = max(0, int(base.get("GIT_CONFIG_COUNT") or 0))
63 except (TypeError, ValueError):
64 n = 0
65 env = {}
66 for i, (k, v) in enumerate(pairs, start=n):
67 env[f"GIT_CONFIG_KEY_{i}"] = k
68 env[f"GIT_CONFIG_VALUE_{i}"] = v
69 env["GIT_CONFIG_COUNT"] = str(n + len(pairs))
70 return env
71
72
73def apply_safe_git_env():
74 os.environ.update(git_config_env(SAFE_GIT_CONFIG))
75 os.environ.update(SAFE_GIT_ENV)
76
77
78def _git_rev_parse_head(cwd):
79 """Return the current HEAD SHA, or None if not a git repo / no commits."""
80 try:
81 # See #2099: text=True on Windows cp1252 crashes the reader thread on
82 # any UTF-8 byte undefined in cp1252 (e.g. via a git error message
83 # referencing a non-ASCII filename in stderr). stdout is a SHA so it
84 # IS safe; stderr is not. capture_output=True with bytes-by-default
85 # never decodes, so the reader thread can't crash.
86 result = subprocess.run(
87 [*GIT_CMD, "rev-parse", "HEAD"],
88 cwd=cwd, capture_output=True, timeout=5
89 )
90 if result.returncode == 0 and result.stdout.strip():
91 return result.stdout.decode("utf-8", errors="replace").strip()
92 return None
93 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
94 return None
95
96
97
98
99def _find_git_index(cwd):
100 """
101 Find the real index file for a git repo. Handles worktrees where .git
102 is a file pointing to the main repo's gitdir.
103 Returns the absolute path to the index file, or None.
104 """
105 try:
106 # See #2099: stdout here is a PATH which can contain non-ASCII bytes
107 # (e.g. C:\אבטחה\repo\.git). text=True decodes via cp1252 strict on
108 # Windows → crashes the reader thread → returns stdout=None →
109 # caller does .strip() on None → AttributeError. Decode manually.
110 result = subprocess.run(
111 [*GIT_CMD, "rev-parse", "--git-dir"],
112 cwd=cwd, capture_output=True, timeout=5
113 )
114 if result.returncode != 0:
115 return None
116 git_dir = result.stdout.decode("utf-8", errors="replace").strip()
117 if not os.path.isabs(git_dir):
118 git_dir = os.path.join(cwd, git_dir)
119 index_path = os.path.join(git_dir, "index")
120 return index_path if os.path.isfile(index_path) else None
121 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
122 return None
123
124
125def _diff_pathspec(cwd, paths):
126 """Convert absolute touched-paths to repo-relative pathspec args for
127 git diff. Paths outside cwd (e.g. ~/.claude/…) are dropped. Returns the
128 list to splice after `--`, or [] for an unrestricted diff. realpath both
129 sides so the macOS /var ↔ /private/var symlink doesn't make in-repo
130 paths look external."""
131 if not paths:
132 return []
133 cwd_abs = os.path.realpath(cwd)
134 rel = []
135 for p in paths:
136 try:
137 r = os.path.relpath(os.path.realpath(p), cwd_abs)
138 except ValueError:
139 continue
140 if r.startswith(".."):
141 continue
142 rel.append(r)
143 return ["--"] + rel if rel else []
144
145
146_TEMP_INDEX_PREFIX = "security_hook_idx_"
147# A copy is named <prefix>pid<hook pid>_<8 random chars>, so a later run can
148# tell that the hook which made it is gone. Names from earlier releases have
149# only the 8 random characters and never match.
150_OWNER_PID = re.compile(re.escape(_TEMP_INDEX_PREFIX) + r"pid(\d+)_[^.]{8}(?:\.|$)")
151# A live copy is in use for under a minute (a couple of bounded git calls), so
152# anything this old belongs to a run that was killed.
153_TEMP_INDEX_STALE_S = 1800
154# Unlinking index-sized files is slow; a large backlog drains over several
155# calls instead of stalling one hook past its timeout.
156_SWEEP_BUDGET_S = 2
157
158
159def _owner_is_gone(name):
160 """True when `name` records the pid of the hook that made it and no such
161 process exists. A reused pid reads as alive: the file then waits for the
162 age limit. Never probes on Windows, where os.kill would end the process."""
163 if not hasattr(os, "getuid"):
164 return False
165 m = _OWNER_PID.match(name)
166 if not m:
167 return False
168 try:
169 os.kill(int(m.group(1)), 0)
170 except ProcessLookupError:
171 return True
172 except (OSError, OverflowError, ValueError):
173 return False
174 return False
175
176
177def _sweep_stale_indexes(dirpath, uid):
178 """Delete index copies in `dirpath` that a killed run left behind. Only
179 regular files owned by `uid` (None: no ownership check) are touched.
180
181 A copy goes as soon as the hook that made it is gone: a killed hook can
182 leave one per run, and index-sized files fill a disk long before the age
183 limit. The age limit covers names without a pid and reused pids.
184
185 Age is the newer of mtime and ctime: a copy keeps .git/index's mtime,
186 which may be days old, so mtime alone would delete a file that another
187 running hook is still using."""
188 import stat
189 import time
190 cutoff = time.time() - _TEMP_INDEX_STALE_S
191 deadline = time.monotonic() + _SWEEP_BUDGET_S
192 try:
193 names = os.listdir(dirpath)
194 except OSError:
195 return
196 for name in names:
197 if not name.startswith(_TEMP_INDEX_PREFIX):
198 continue
199 p = os.path.join(dirpath, name)
200 try:
201 st = os.lstat(p)
202 if not stat.S_ISREG(st.st_mode):
203 continue
204 if uid is not None and st.st_uid != uid:
205 continue
206 if max(st.st_mtime, st.st_ctime) < cutoff or _owner_is_gone(name):
207 os.unlink(p)
208 if time.monotonic() > deadline:
209 return
210 except OSError:
211 pass
212
213
214def _sweep_stale_stash_indexes(real_index):
215 """Delete `<index>.stash.<pid>` files a killed `git stash create` left
216 next to the real index. Releases before 2.0.10 ran it there, so a clone
217 can hold hundreds, each the size of the index, and nothing else removes
218 them. Only our own regular files past the age limit go: git keeps one for
219 seconds. `index.lock` is never touched; a live git may hold it."""
220 import stat
221 import time
222 uid = os.getuid() if hasattr(os, "getuid") else None
223 cutoff = time.time() - _TEMP_INDEX_STALE_S
224 deadline = time.monotonic() + _SWEEP_BUDGET_S
225 dirpath = os.path.dirname(real_index)
226 stale = re.compile(re.escape(os.path.basename(real_index)) + r"\.stash\.\d+$")
227 try:
228 names = os.listdir(dirpath)
229 except OSError:
230 return
231 for name in names:
232 if not stale.match(name):
233 continue
234 p = os.path.join(dirpath, name)
235 try:
236 st = os.lstat(p)
237 if not stat.S_ISREG(st.st_mode):
238 continue
239 if uid is not None and st.st_uid != uid:
240 continue
241 if max(st.st_mtime, st.st_ctime) < cutoff:
242 os.unlink(p)
243 if time.monotonic() > deadline:
244 return
245 except OSError:
246 pass
247
248
249# Index copies this process has made and not yet removed.
250_live_copies = set()
251
252# A hook working on a copy needs up to three times the index's size for a
253# moment (the copy, and git's rewrite and stash index next to it). With many
254# agents in one clone that alone can fill a disk, so only so many hooks per
255# user work on a copy at once; the others wait, then go without.
256_MAX_LIVE_COPIES = 8
257_SLOT_WAIT_S = 5
258
259
260def _copy_slots(tmpdir, index_bytes):
261 """How many hooks may work on a copy at once: what fits into a quarter of
262 the free space at three times the index each, from 1 to
263 _MAX_LIVE_COPIES."""
264 import shutil
265 try:
266 free = shutil.disk_usage(tmpdir).free
267 except OSError:
268 return 1
269 return max(1, min(_MAX_LIVE_COPIES, free // (12 * max(index_bytes, 1))))
270
271
272@contextlib.contextmanager
273def _copy_slot(tmpdir, index_bytes=0):
274 """Hold one of the copy slots; yields False when none came free in
275 _SLOT_WAIT_S. A slot is an flock, so the kernel frees it when its holder
276 dies, however it dies. No cap without flock or a private dir."""
277 import time
278 try:
279 import fcntl
280 except ImportError:
281 fcntl = None
282 if fcntl is None or tmpdir is None:
283 yield True
284 return
285 deadline = time.monotonic() + _SLOT_WAIT_S
286 held = None
287 while held is None:
288 for i in range(_copy_slots(tmpdir, index_bytes)):
289 try:
290 fd = os.open(os.path.join(tmpdir, f"slot{i}.lock"),
291 os.O_RDWR | os.O_CREAT, 0o600)
292 except OSError:
293 continue
294 try:
295 fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
296 held = fd
297 break
298 except OSError:
299 os.close(fd)
300 if held is None:
301 if time.monotonic() >= deadline:
302 break
303 time.sleep(0.1)
304 try:
305 yield held is not None
306 finally:
307 if held is not None:
308 os.close(held)
309
310
311def _remove_copy(tmp_index):
312 """Remove an index copy and the files git wrote next to it."""
313 import glob
314 for p in glob.glob(glob.escape(tmp_index) + "*"):
315 try:
316 os.unlink(p)
317 except OSError:
318 pass
319
320
321def remove_copies_on_termination():
322 """Remove this process's index copies when it is told to terminate.
323 Python's default SIGTERM action ends the process without running
324 `finally`, which left an index-sized file behind each time."""
325 import signal
326
327 def _on_term(signum, _frame):
328 for tmp_index in list(_live_copies):
329 _remove_copy(tmp_index)
330 os._exit(128 + signum)
331
332 try:
333 signal.signal(signal.SIGTERM, _on_term)
334 except (ValueError, OSError, AttributeError):
335 pass # not the main thread, or no SIGTERM here
336
337
338def _hook_tmpdir():
339 """Private per-user directory for throwaway index copies, or None to use
340 the bare temp dir.
341
342 An index copy can be hundreds of MB and a killed hook cannot clean up, so
343 copies live in one 0700 directory and anything stale is swept on entry,
344 here and in the bare temp dir. The name is predictable and the temp dir
345 may be shared, so anything that is not a real directory we own is
346 refused. Windows has no uid and its temp dir is already per-user."""
347 import stat
348 import tempfile
349 base = tempfile.gettempdir()
350 uid = os.getuid() if hasattr(os, "getuid") else None
351 _sweep_stale_indexes(base, uid)
352 if uid is None:
353 return None
354 d = os.path.join(base, f"claude-security-guidance-{uid}")
355 try:
356 os.mkdir(d, 0o700)
357 except FileExistsError:
358 pass
359 except OSError:
360 return None
361 try:
362 st = os.lstat(d)
363 except OSError:
364 return None
365 if not stat.S_ISDIR(st.st_mode) or st.st_uid != uid:
366 return None
367 try:
368 os.chmod(d, 0o700)
369 except OSError:
370 pass
371 _sweep_stale_indexes(d, uid)
372 return d
373
374
375@contextlib.contextmanager
376def _temp_index(cwd, untracked_paths=None):
377 """Yield an env dict pointing GIT_INDEX_FILE at a throwaway copy of the
378 repo's index with `git add --intent-to-add` applied, so untracked files
379 show up in subsequent `git diff` calls without touching the user's real
380 index. Yields None if no index can be found (bare repo / not a repo) or
381 no copy slot came free; the caller should fall back to a plain diff.
382 Always cleans up the temp file.
383
384 Perf: when `untracked_paths` is given, only those paths are added (O(n)
385 in untracked count). The default `add -N .` stats every file in the
386 worktree — slow in large repos vs fast targeted scan. v2 callers
387 already know the untracked set from `git status --porcelain`, so they
388 pass it; v1 keeps the whole-tree scan since it has no prior list.
389
390 Cleanup removes `<tmp_index>*`, not just the copy: git writes
391 `<index>.lock` and `<index>.stash.<pid>` next to the index it is given and
392 cannot remove them itself when a subprocess timeout kills it."""
393 real_index = _find_git_index(cwd)
394 if not real_index:
395 yield None
396 return
397
398 _sweep_stale_stash_indexes(real_index)
399 tmpdir = _hook_tmpdir()
400 try:
401 index_bytes = os.path.getsize(real_index)
402 except OSError:
403 index_bytes = 0
404 with _copy_slot(tmpdir, index_bytes) as got_slot:
405 if not got_slot:
406 debug_log("no index copy slot came free; going without a copy")
407 yield None
408 return
409 with _copied_index(cwd, real_index, tmpdir, untracked_paths) as env:
410 yield env
411
412
413@contextlib.contextmanager
414def _copied_index(cwd, real_index, tmpdir, untracked_paths):
415 import shutil
416 import tempfile
417
418 tmp_fd, tmp_index = tempfile.mkstemp(
419 prefix=f"{_TEMP_INDEX_PREFIX}pid{os.getpid()}_", dir=tmpdir)
420 os.close(tmp_fd)
421 _live_copies.add(tmp_index)
422 try:
423 # A hard link where the filesystem allows it: no bytes are copied, and
424 # git never writes an index in place (it renames a new file over it),
425 # so the real index cannot change through the link. Either way the
426 # copy must keep .git/index's mtime (copy2, not copyfile): git
427 # re-reads any file whose mtime is not older than the index file's,
428 # and with a fresh mtime it would miss a same-size edit made in the
429 # same second as the last index write.
430 try:
431 os.unlink(tmp_index)
432 os.link(real_index, tmp_index)
433 except (OSError, NotImplementedError, AttributeError):
434 shutil.copy2(real_index, tmp_index)
435 env = {**os.environ, "GIT_INDEX_FILE": tmp_index}
436 if untracked_paths is None:
437 add_args = ["."]
438 elif untracked_paths:
439 # `git add -N -- a b nonexistent` is atomic — one missing path
440 # makes it exit 128 and add NOTHING, so a file removed between
441 # `git status` and here would silently drop ALL untracked files
442 # from the diff. --ignore-missing only works with --dry-run, so
443 # filter to surviving paths (lexists so dangling symlinks count).
444 surviving = [p for p in untracked_paths
445 if os.path.lexists(os.path.join(cwd, p))]
446 add_args = ["--"] + surviving if surviving else None
447 else:
448 add_args = None
449 if add_args:
450 # No stdout used here (only returncode matters), but text=True
451 # still spawns reader threads that decode stderr — git error
452 # messages can reference non-ASCII filenames and crash on
453 # cp1252. See #2099. Drop text=True so bytes stay raw.
454 subprocess.run(
455 [*GIT_CMD, "add", "--intent-to-add"] + add_args,
456 cwd=cwd, capture_output=True, timeout=10,
457 env=env,
458 )
459 yield env
460 finally:
461 _remove_copy(tmp_index)
462 _live_copies.discard(tmp_index)
463
464
465def _git_toplevel(cwd):
466 """Absolute repo root for `cwd`, or None if not in a work tree."""
467 try:
468 # See #2099: stdout is a PATH — `C:\אבטחה\repo` returned as UTF-8
469 # bytes by git. text=True would decode via cp1252 strict on Windows
470 # → reader-thread crash. Decode manually with errors="replace".
471 r = subprocess.run(
472 [*GIT_CMD, "rev-parse", "--show-toplevel"],
473 cwd=cwd, capture_output=True, timeout=5,
474 )
475 if r.returncode != 0:
476 return None
477 path = r.stdout.decode("utf-8", errors="replace").strip()
478 return path if path else None
479 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
480 return None
481
482
483def _git_dir(repo_root):
484 """Absolute shared `.git` directory for repo_root.
485
486 Uses `rev-parse --git-common-dir` so linked worktrees resolve to the
487 SHARED gitdir, not the per-worktree `.git/worktrees/<name>/`. That way
488 push-sweep's reviewed-shas record (and the bash-hook-once sentinel)
489 is per-clone — a commit reviewed in one worktree counts as reviewed
490 if a different worktree later pushes it. Returns None on failure so
491 callers can degrade (push-sweep state is best-effort).
492 """
493 try:
494 # See #2099: stdout is a PATH (shared gitdir), may be non-ASCII.
495 # Decode bytes manually to avoid cp1252 reader-thread crash.
496 r = subprocess.run(
497 [*GIT_CMD, "rev-parse", "--git-common-dir"],
498 cwd=repo_root, capture_output=True, timeout=5,
499 )
500 if r.returncode != 0:
501 return None
502 d = r.stdout.decode("utf-8", errors="replace").strip()
503 return d if os.path.isabs(d) else os.path.join(repo_root, d)
504 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
505 return None
506
507
508def _git_rev_list_range(repo_root, base, head="HEAD"):
509 """Shas in `base..head`, oldest→newest. Empty list on error."""
510 try:
511 # See #2099: stdout is ASCII SHAs, but stderr can carry git error
512 # messages referencing non-ASCII filenames — keep bytes raw.
513 r = subprocess.run(
514 [*GIT_CMD, "rev-list", "--reverse", f"{base}..{head}"],
515 cwd=repo_root, capture_output=True, timeout=10,
516 )
517 if r.returncode != 0:
518 return []
519 return [s for s in r.stdout.decode("utf-8", errors="replace").strip().split("\n") if s]
520 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
521 return []
522
523
524def _git_diff_range(repo_root, base, head="HEAD"):
525 """`git diff -p base head` as text on success, None on error.
526
527 Distinguishing failure from success-with-empty-diff matters: the push-sweep
528 caller marks the tail reviewed when the diff is empty (nothing to review),
529 but on failure (timeout, non-zero exit, missing git) it must NOT mark
530 them reviewed — otherwise unreviewed commits get permanently silenced.
531 """
532 try:
533 # GIT_CMD globally passes core.quotePath=false (see definition) so
534 # non-ASCII paths in `diff --git a/... b/...` headers come through as
535 # raw UTF-8, not C-quoted. Required by the downstream
536 # parse_diff_into_files / extract_file_paths_from_diff regex.
537 r = subprocess.run(
538 [*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head],
539 cwd=repo_root, capture_output=True, timeout=30,
540 )
541 if r.returncode != 0:
542 return None
543 return r.stdout.decode("utf-8", errors="replace")
544 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
545 return None
546
547
548def _detect_main_branch(repo_root):
549 for ref in ("origin/HEAD", "origin/main", "origin/master", "main", "master"):
550 try:
551 # See #2099: stdout is a SHA but stderr can carry non-ASCII git
552 # warnings — keep bytes raw to avoid cp1252 reader-thread crash.
553 r = subprocess.run(
554 [*GIT_CMD, "rev-parse", "--verify", "-q", ref],
555 cwd=repo_root, capture_output=True, timeout=5,
556 )
557 if r.returncode == 0 and r.stdout.strip():
558 return ref
559 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
560 pass
561 return None
562
563
564def _git_reflog_recent_commits(repo_root, max_age_s=120, max_n=5):
565 """Return (fresh_commit_shas, stale_count) from the HEAD reflog.
566
567 Scans the last `max_n` reflog entries and returns the SHAs whose action is
568 `commit*` AND whose commit timestamp is within `max_age_s` of now,
569 newest-first. `stale_count` is the number of commit-action entries that
570 were too old (so the caller can distinguish "no commit happened" from
571 "commit happened earlier than the window").
572
573 Used by commit-review when stdout-based `[branch sha]` detection fails
574 (output piped/redirected/-q, or a chained command after `git commit`
575 pushed the success line off — `git commit && git push` makes HEAD@{0}
576 `update by push`, not `commit:`). The HEAD@{0}-only check
577 keeps the not-yet-visible-HEAD skip rare; analysis showed the
578 residual is dominated by these chained-command and noop-guard cases.
579
580 Safety vs. blindly reading HEAD:
581 - cross-repo (`cd ../other && git commit`): repo_root's own reflog has
582 no fresh commit, so this returns ([], 0).
583 - commit actually failed (pre-commit reject, nothing-staged): reflog's
584 recent entries are the prior checkout/commit/reset → ([], 0) or only
585 stale entries.
586 - HEAD raced ahead (a second commit landed before this async hook ran):
587 both commits appear in the scan and both get reviewed — correct.
588 - prior Bash call's commit within the window: would be returned here,
589 but the call site deduplicates against `.git/sg-reviewed-shas` so a
590 SHA is reviewed at most once. This is also the non-overlap invariant
591 with push-sweep.
592 """
593 if not repo_root:
594 return [], 0
595 try:
596 # %gs (the reflog subject) is `commit: <commit-msg first line>` and can
597 # contain `|`; put it LAST so split("|", 2) leaves it intact. %H is
598 # hex and %ct is integer, so the first two fields are delimiter-safe.
599 #
600 # Bytes + decode utf-8/replace: %gs embeds commit-message subjects
601 # which git stores as raw bytes — commits can be authored in
602 # latin-1 / cp1252 / shift-jis etc., and text=True would raise
603 # UnicodeDecodeError in the subprocess reader thread on Windows
604 # cp1252 (subprocess.run returns r.stdout=None, then
605 # r.stdout.splitlines() AttributeErrors). Mirrors the existing
606 # migration at security_reminder_hook.py:540 — same pattern was
607 # missed here. See anthropics/claude-plugins-official#2056.
608 r = subprocess.run(
609 [*GIT_CMD, "log", "-g", "-n", str(max_n),
610 "--format=%H|%ct|%gs", "HEAD"],
611 cwd=repo_root, capture_output=True, timeout=5,
612 )
613 except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError):
614 return [], 0
615 if r.returncode != 0:
616 return [], 0
617 stdout = (r.stdout or b"").decode("utf-8", errors="replace")
618 import time as _time
619 now = int(_time.time())
620 fresh, stale = [], 0
621 for idx, line in enumerate(stdout.splitlines()):
622 parts = line.split("|", 2)
623 if len(parts) != 3:
624 continue
625 sha, ct, subject = parts
626 # `commit: msg`, `commit (amend): msg`, `commit (initial): msg`,
627 # `commit (merge): msg` — all create a reviewable commit object.
628 if not subject.startswith("commit"):
629 continue
630 try:
631 age = now - int(ct)
632 except ValueError:
633 continue
634 # HEAD@{0} (idx==0) is exempt from the age gate. The gate exists to
635 # bound the WIDENED HEAD@{1..max_n-1} scan from picking up commits
636 # made by *prior* Bash calls; HEAD@{0} is by definition the most
637 # recent reflog entry and was previously accepted unconditionally
638 # (_git_reflog_head_if_just_committed previously had no age check).
639 # Applying max_age_s to idx==0 made the not-yet-visible-HEAD skip
640 # noticeably more frequent on chained
641 # `git commit && <slow command>` where %ct is >120s old by the
642 # time the async PostToolUse hook fires.
643 if idx == 0 or age <= max_age_s:
644 fresh.append(sha)
645 else:
646 stale += 1
647 return fresh, stale
648
649
650def _git_name_only(cwd, base, include_untracked=False):
651 """Return the set of repo-root-relative paths that differ from `base`,
652 or None if git failed (unresolvable ref, not a repo, timeout). Callers
653 must distinguish None (error → don't trust as a filter) from set()
654 (genuinely nothing changed). `-c core.quotePath=false -z` keeps non-ASCII
655 and space-containing paths intact.
656
657 Against the work tree the set can also hold a file whose stat changed and
658 whose content did not: the diff does not refresh the index. Callers
659 intersect with `git status`, which compares content."""
660 # Decode stdout/stderr as UTF-8 with errors="replace" instead of using
661 # text=True. core.quotePath=false makes git emit raw UTF-8 for non-ASCII
662 # paths, and text=True on Windows decodes via cp1252 strict — a non-ASCII
663 # changed path would crash the subprocess reader thread, leave
664 # result.stdout=None, and propagate AttributeError out of the helper.
665 # Same fix shape as diffstate._list_untracked. See #2056.
666 def _run(env):
667 # core.quotePath=false comes from GIT_CMD globally (see definition).
668 result = subprocess.run(
669 [*GIT_CMD, "diff", "--name-only", "-z", base],
670 cwd=cwd, capture_output=True, timeout=30,
671 env=env,
672 )
673 if result.returncode != 0:
674 stderr_str = (result.stderr or b"").decode("utf-8", errors="replace")
675 debug_log(f"_git_name_only({base!r}) rc={result.returncode}: {stderr_str[:200]}")
676 return None
677 stdout = (result.stdout or b"").decode("utf-8", errors="replace")
678 return {p for p in stdout.split("\0") if p}
679
680 try:
681 # The diff only reads the index (diff.autoRefreshIndex is off in
682 # GIT_CMD); a copy is for `add -N` alone.
683 if not include_untracked:
684 return _run(None)
685 with _temp_index(cwd) as env:
686 return _run(env)
687 except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
688 debug_log(f"_git_name_only({base!r}) error: {e}")
689 return None
690
691
692def _git_status_porcelain(cwd):
693 """One `git status --porcelain=v1 -z` → (tracked_dirty, untracked) sets of
694 repo-root-relative paths, or (None, None) on error. Replaces the
695 `_temp_index + git diff HEAD --name-only` pair for the v2 dirty_now
696 computation: faster in large repos, and yields the
697 untracked set separately so the later get_git_diff can do a targeted
698 `add -N -- <files>` instead of a whole-tree `add -N .`.
699
700 -uall: list individual files inside untracked directories (default
701 collapses to `dir/`). Required so the untracked set subtracts cleanly
702 against the UPS-time `_list_untracked` snapshot, which uses ls-files and
703 therefore always lists individual files."""
704 # Lenient decode: same UTF-8 + errors="replace" pattern as the
705 # sibling helpers — a non-ASCII path in the worktree would otherwise
706 # crash the cp1252 reader thread on Windows. See #2056.
707 try:
708 # core.quotePath=false comes from GIT_CMD globally (see definition).
709 r = subprocess.run(
710 [*GIT_CMD, "status", "--porcelain=v1", "-uall", "-z"],
711 cwd=cwd, capture_output=True, timeout=30,
712 )
713 if r.returncode != 0:
714 stderr_str = (r.stderr or b"").decode("utf-8", errors="replace")
715 debug_log(f"_git_status_porcelain rc={r.returncode}: {stderr_str[:200]}")
716 return None, None
717 tracked, untracked = set(), set()
718 stdout = (r.stdout or b"").decode("utf-8", errors="replace")
719 entries = stdout.split("\0")
720 i = 0
721 while i < len(entries):
722 e = entries[i]
723 if not e:
724 i += 1
725 continue
726 xy, path = e[:2], e[3:]
727 if xy == "??":
728 untracked.add(path)
729 else:
730 tracked.add(path)
731 # Rename/copy entries are XY old\0new\0 — second NUL field is
732 # the origin path; consume it so it isn't misparsed as a new
733 # 2-char-status entry.
734 if "R" in xy or "C" in xy:
735 i += 1
736 i += 1
737 return tracked, untracked
738 except (subprocess.TimeoutExpired, FileNotFoundError, OSError, ValueError) as e:
739 # ValueError guards against any future strict-decode regression
740 # so the helper degrades to (None, None) instead of crashing.
741 debug_log(f"_git_status_porcelain error: {e}")
742 return None, None
743
744
745
746def _is_ancestor(cwd, maybe_ancestor, descendant):
747 """True if `maybe_ancestor` is reachable from `descendant` (i.e. HEAD
748 moved forward via commit/merge, not sideways via checkout)."""
749 try:
750 # See #2099: only returncode matters, but text=True spawns reader
751 # threads that decode stderr — git error messages can carry non-ASCII
752 # filenames. Drop text=True to keep bytes raw, avoid cp1252 crash.
753 result = subprocess.run(
754 [*GIT_CMD, "merge-base", "--is-ancestor", maybe_ancestor, descendant],
755 cwd=cwd, capture_output=True, timeout=5,
756 )
757 return result.returncode == 0
758 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
759 return False
760
761
762
763def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_paths=None):
764 """
765 Get the git diff between the baseline SHA and the current working tree,
766 including untracked (new) files.
767
768 Uses a temporary copy of the git index (GIT_INDEX_FILE) so the user's
769 real index is never modified. The temp index gets intent-to-add entries
770 for untracked files, making them visible in the diff output. Cleanup
771 is just deleting the temp file in a finally block.
772
773 If `paths` is given, the diff is restricted to those paths (relative to
774 cwd; absolute paths are converted, paths outside cwd are dropped).
775 `untracked_paths` (repo-root-relative) is forwarded to _temp_index so it
776 can add only those files instead of scanning the whole worktree.
777 """
778 pathspec = _diff_pathspec(cwd, paths)
779 if paths and not pathspec:
780 # Caller restricted to specific paths but none are inside this repo
781 # (e.g. only ~/.claude/... edits). Returning "" flows to skip(6); an
782 # empty pathspec would mean an UNRESTRICTED diff — the bug this whole
783 # change exists to fix.
784 return ""
785
786 # core.quotePath=false comes from GIT_CMD globally (see definition).
787 cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
788 try:
789 if untracked_paths is not None and not untracked_paths:
790 # Nothing to `add -N`, and the diff only reads the index.
791 result = subprocess.run(cmd, cwd=cwd, capture_output=True, timeout=30)
792 else:
793 with _temp_index(cwd, untracked_paths) as env:
794 # env is None when there is no index (bare repo / not a repo)
795 # or no copy slot — diff still runs, just without
796 # untracked-file support.
797 result = subprocess.run(cmd, cwd=cwd, capture_output=True, timeout=30, env=env)
798 if result.returncode != 0:
799 debug_log(f"git diff failed: {result.stderr[:200].decode('utf-8', errors='replace')}")
800 return None
801 # Decode with errors='replace' so binary diffs don't crash
802 return result.stdout.decode("utf-8", errors="replace")
803 except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
804 debug_log(f"git diff error: {e}")
805 return None
806
807
808# Source file extensions worth reviewing for security
809SOURCE_CODE_EXTENSIONS = {
810 '.py', '.js', '.ts', '.jsx', '.tsx', '.go', '.java', '.rb', '.php',
811 '.rs', '.c', '.cpp', '.h', '.hpp', '.cs', '.swift', '.kt', '.scala',
812 '.html', '.htm', '.ejs', '.yaml', '.yml', '.properties',
813 '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte',
814 '.sh', '.bash', '.zsh', '.fish', '.ksh', '.ps1', '.sql',
815 '.gradle', '.groovy',
816 '.tf', '.hcl', '.tfvars',
817 '.json', '.toml', '.ipynb',
818}
819
820# Reviewable files identified by basename rather than extension (lowercased).
821# These are by-convention extensionless but contain executable recipes/DSL
822# with shell/exec surface (Make recipes, Jenkinsfile Groovy, Rakefile Ruby).
823SOURCE_CODE_BASENAMES = {
824 'dockerfile', 'makefile', 'gnumakefile', 'jenkinsfile', 'vagrantfile',
825 'rakefile', 'gemfile', 'procfile', 'brewfile', 'justfile',
826}
827
828# Extensionless basenames that are NOT source — plain-text metadata. Anything
829# extensionless not in this set is treated as source (likely a shebang script
830# under bin/ or scripts/). Analysis of skipped reviews found
831# extensionless executables (bin/deploy, scripts/run-canary) were the largest
832# remaining false-negative class — they carry shell-injection surface but
833# `splitext` gives '' so they were filtered out. _cap_files_for_prompt bounds
834# the byte cost downstream, and the reviewer ignores prose, so opting
835# extensionless IN with this small deny-list is the better default than
836# opting OUT.
837NON_SOURCE_EXTENSIONLESS_BASENAMES = {
838 'license', 'licence', 'copying', 'notice', 'patents', 'authors',
839 'contributors', 'maintainers', 'changelog', 'changes', 'news',
840 'readme', 'todo', 'install', 'version', 'codeowners',
841 'owners', 'copyright',
842}
843
844# Directory components and file suffixes that are never worth reviewing even
845# when the extension is in SOURCE_CODE_EXTENSIONS — vendored deps, build
846# output, generated code, minified bundles, lockfiles, protobuf stubs.
847# Matched as path *components* (so `node_modules/` matches anywhere in the
848# path, not just as a prefix) and as case-sensitive suffixes (the ecosystems
849# that emit `.min.js` / `_pb2.py` / `.pb.go` are case-consistent).
850SKIP_PATH_PATTERNS = (
851 'node_modules/', 'dist/', 'build/', '.next/', 'vendor/',
852 '__generated__/', '__pycache__/', '.venv/', 'target/',
853)
854SKIP_FILE_SUFFIXES = (
855 '.min.js', '.min.css', '.d.ts', '.d.mts', '.d.cts',
856 '.lock', '_pb2.py', '.pb.go',
857)
858
859# Path tokens that bump a file's review priority when a commit exceeds
860# MAX_DIFF_FILES and we have to pick a subset. These are exactly the surfaces
861# single-shot and agentic reviews disagree on most (auth, routing, IPC,
862# subprocess, deserialization). Matched as lowercase substrings against the
863# path; not regex — keep it cheap.
864_SECURITY_RISK_PATH_TOKENS = (
865 "auth", "login", "session", "token", "secret", "credential", "perm",
866 "acl", "rbac", "iam", "policy",
867 "route", "handler", "controller", "endpoint", "api/", "/api", "gateway",
868 "middleware", "view",
869 "exec", "subprocess", "shell", "spawn", "command",
870 "client", "request", "fetch", "http", "url",
871 "serialize", "pickle", "yaml", "parse", "deser",
872 # Short tokens that would substring-match unrelated names (`format`,
873 # `transform`, `sandbox`, `platform`) are intentionally omitted —
874 # `sql`/`query` already cover the DB surface.
875 "sql", "query",
876)
877# Suffixes that pass _is_reviewable_source but are almost always low-signal
878# in large scaffolds — generated clients, migrations, test fixtures, config
879# shims. These go to the BACK of the priority sort, not dropped outright.
880_LOW_PRIORITY_SUFFIXES = (
881 ".gen.ts", ".gen.tsx", ".generated.ts", "_gen.py",
882 ".test.ts", ".test.tsx", ".test.py", ".spec.ts", ".spec.js",
883 ".config.js", ".config.ts", ".config.mjs", ".config.cjs",
884)
885_LOW_PRIORITY_PATH_TOKENS = (
886 "/migrations/", "/alembic/versions/", "/__tests__/", "/fixtures/",
887)
888
889
890def _prioritize_diff_files(diff_files, cap):
891 """When `diff_files` exceeds `cap`, return the top-`cap` by security
892 relevance plus the count dropped. Otherwise return (diff_files, 0).
893
894 Score = (risk_tokens_in_path, not_low_priority, added_lines). The
895 added-lines proxy is `content.count('\\n+')` which counts diff additions
896 cheaply without re-parsing hunks. This is a heuristic, not a guarantee —
897 the goal is to review the likely-dangerous subset of an over-cap diff
898 instead of reviewing nothing. Diffs that exceed the cap are typically
899 large multi-file scaffolds, and the cross-file source→sink vulnerabilities
900 in them concentrate in a handful of api/client/route files.
901 """
902 if len(diff_files) <= cap:
903 return diff_files, 0
904
905 def _score(item):
906 fp, content = item
907 low = fp.lower()
908 # Prepend "/" so leading-slash patterns in _LOW_PRIORITY_PATH_TOKENS
909 # match top-level dirs (git diff paths are repo-root-relative, e.g.
910 # `migrations/001.py` not `/migrations/001.py`). Same trick as
911 # _is_reviewable_source.
912 low_slashed = "/" + low
913 risk = sum(1 for t in _SECURITY_RISK_PATH_TOKENS if t in low)
914 low_prio = (
915 fp.endswith(_LOW_PRIORITY_SUFFIXES)
916 or any(t in low_slashed for t in _LOW_PRIORITY_PATH_TOKENS)
917 )
918 # added_lines: count('\n+') over-counts by including '+++' header and
919 # any literal '+' at line start in context, but it's a consistent
920 # ordinal across files in the same diff which is all we need.
921 added = content.count("\n+")
922 return (risk, not low_prio, added)
923
924 ranked = sorted(diff_files, key=_score, reverse=True)
925 return ranked[:cap], len(diff_files) - cap
926
927
928def _is_reviewable_source(file_path):
929 # Normalize for component matching: a path like `.next/x.js` or
930 # `pkg/node_modules/y.ts` should both be excluded; matching against
931 # `'/' + path` lets each pattern be checked as `'/' + p in '/' + path`
932 # without false-positiving on `rebuild/` matching `build/`.
933 norm = "/" + file_path.replace("\\", "/")
934 if any(("/" + p) in norm for p in SKIP_PATH_PATTERNS):
935 return False
936 if file_path.endswith(SKIP_FILE_SUFFIXES):
937 return False
938 ext = os.path.splitext(file_path)[1].lower()
939 if ext in SOURCE_CODE_EXTENSIONS:
940 return True
941 base = os.path.basename(file_path).lower()
942 # Accept dot-suffixed variants too: `Dockerfile.dev`, `Makefile.am`,
943 # `Jenkinsfile.release`. splitext gives ext='.dev'/'.am' for these so they
944 # miss both the extension check and the exact-basename check otherwise.
945 if base in SOURCE_CODE_BASENAMES \
946 or base.split(".", 1)[0] in SOURCE_CODE_BASENAMES:
947 return True
948 # Extensionless files default to reviewable unless they're known
949 # plain-text metadata or dotfiles. Covers shebang scripts under bin/ or
950 # scripts/ (`deploy`, `run-canary`, `entrypoint`) which carry
951 # shell-injection surface but were previously filtered out — the largest
952 # remaining false-negative class for extensionless files. Dotfiles (`.gitignore`,
953 # `.nvmrc`, `.env`) are config, not code; `.bashrc`-style runnables are
954 # rare in repos and not worth the noise. The deny-list is prefix-aware on
955 # `-`/`_` so dual-license / i18n variants (`LICENSE-MIT`, `README-CN`)
956 # don't fall through as source.
957 if ext == "" and not base.startswith("."):
958 if any(base == x or base.startswith(x + "-") or base.startswith(x + "_")
959 for x in NON_SOURCE_EXTENSIONLESS_BASENAMES):
960 return False
961 return True
962 return False
963
964
965def extract_file_paths_from_diff(diff_output):
966 """
967 Extract file paths from unified diff output (without content).
968 Only includes files with source code extensions.
969 Returns a list of file paths.
970 """
971 if not diff_output or not diff_output.strip():
972 return []
973
974 paths = []
975 file_diffs = diff_output.split("diff --git ")
976
977 for file_diff in file_diffs:
978 if not file_diff.strip():
979 continue
980 lines = file_diff.split('\n')
981 header_match = re.match(r'^a/(.+?) b/(.+)$', lines[0])
982 if not header_match:
983 continue
984 file_path = header_match.group(2) or header_match.group(1) or ''
985 if not _is_reviewable_source(file_path):
986 continue
987 paths.append(file_path)
988
989 return paths
990
991
992
993def parse_diff_into_files(diff_output):
994 """
995 Parse unified diff output into a list of (file_path, diff_content) tuples.
996 Only includes files with source code extensions.
997 """
998 if not diff_output or not diff_output.strip():
999 return []
1000
1001 files = []
1002 file_diffs = diff_output.split("diff --git ")
1003
1004 for file_diff in file_diffs:
1005 if not file_diff.strip():
1006 continue
1007
1008 # Extract filename from first line: "a/path/to/file b/path/to/file"
1009 lines = file_diff.split('\n')
1010 header_match = re.match(r'^a/(.+?) b/(.+)$', lines[0])
1011 if not header_match:
1012 continue
1013
1014 file_path = header_match.group(2) or header_match.group(1) or ''
1015
1016 # Filter to source code files only
1017 if not _is_reviewable_source(file_path):
1018 continue
1019
1020 # Extract the diff content (from first @@ onwards)
1021 diff_lines = []
1022 in_hunks = False
1023 for line in lines[1:]:
1024 if line.startswith('@@'):
1025 in_hunks = True
1026 if in_hunks:
1027 diff_lines.append(line)
1028
1029 if diff_lines:
1030 files.append((file_path, '\n'.join(diff_lines)))
1031
1032 return files
1033
1034
1035def filter_preexisting_from_diff(diff_files, cwd, baseline_sha):
1036 """
1037 Filter out pre-existing content from diff files.
1038 When a file is fully rewritten (Write tool replaces entire content),
1039 git shows all lines as removed (-) then re-added (+). This function
1040 detects such rewrites and strips lines from the + section that also
1041 appeared in the - section, so the LLM reviewer only sees truly new code.
1042 """
1043 if not baseline_sha:
1044 return diff_files
1045
1046 filtered = []
1047 for file_path, diff_content in diff_files:
1048 lines = diff_content.split('\n')
1049
1050 # Collect removed and added lines (stripping the +/- prefix)
1051 removed_lines = set()
1052 added_lines = []
1053 for line in lines:
1054 if line.startswith('-') and not line.startswith('---'):
1055 removed_lines.add(line[1:].strip())
1056 elif line.startswith('+') and not line.startswith('+++'):
1057 added_lines.append(line[1:].strip())
1058
1059 if not removed_lines:
1060 # New file, no pre-existing content to filter
1061 filtered.append((file_path, diff_content))
1062 continue
1063
1064 # Check what fraction of added lines were pre-existing
1065 preexisting_count = sum(1 for l in added_lines if l in removed_lines)
1066 if preexisting_count == 0:
1067 filtered.append((file_path, diff_content))
1068 continue
1069
1070 added_lines_set = set(added_lines)
1071
1072 # Rebuild diff with pre-existing lines converted to context (space prefix).
1073 # Known imprecision: .strip() matches across indentation (so reindented
1074 # code is treated as unchanged) and the set lets one removal mask N
1075 # additions of the same stripped text. Accepted trade-off — this filter
1076 # exists for the full-file Write rewrite case where exact-match would
1077 # miss everything; the diff-review prompt's previous-findings recheck
1078 # is the backstop.
1079 new_lines = []
1080 for line in lines:
1081 if line.startswith('+') and not line.startswith('+++'):
1082 content = line[1:].strip()
1083 if content in removed_lines:
1084 # Convert to context line (pre-existing, not new)
1085 new_lines.append(' ' + line[1:])
1086 else:
1087 new_lines.append(line)
1088 elif line.startswith('-') and not line.startswith('---'):
1089 content = line[1:].strip()
1090 if content in added_lines_set:
1091 # Skip removed lines that were re-added (they become context)
1092 continue
1093 else:
1094 new_lines.append(line)
1095 else:
1096 new_lines.append(line)
1097
1098 filtered.append((file_path, '\n'.join(new_lines)))
1099
1100 return filtered
1101