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 / security_reminder_hook.py
2,427 lines · 113.6 KBRaw
1#!/usr/bin/env python3
2"""
3Security Guidance Plugin for Claude Code
4
5A hooks-based plugin that guides Claude toward writing more secure code. It runs as
6UserPromptSubmit, PostToolUse, and Stop hooks via the Claude Code plugin system.
7
8## Architecture
9
10The plugin has two layers:
11
121. **Pattern-based rules (PostToolUse, every edit)**: Fast regex checks that run on
13 every file write. Detects common vulnerabilities like hardcoded secrets, SQL injection,
14 command injection, path traversal, and insecure session configs. Injects brief warnings
15 via additionalContext.
16
172. **Stop hook (final review)**: When Claude finishes, uses `git diff` against a
18 baseline SHA (captured at UserPromptSubmit) to get only the code changed during the
19 session. Runs two Haiku analyses on the diff:
20 a) Concrete vulnerability scan with severity ratings
21 b) Areas-of-concern analysis identifying categories to investigate
22 Exits with code 2 to force Claude to continue and address findings.
23
24## How the git baseline works
25
26On each UserPromptSubmit, the plugin runs `git stash create` to get a SHA representing
27the current working tree state (HEAD + any uncommitted changes). This SHA is saved to
28the session state file. When the Stop hook fires, it runs `git diff <baseline_sha>` to
29get only the changes made since that snapshot. After analysis, the baseline is updated
30so the next Stop hook iteration only sees new changes.
31
32This means:
33- Only code Claude actually changed is reviewed (not pre-existing code)
34- Mid-session commits are handled correctly (diff is against the snapshot, not HEAD)
35- Each turn only reviews new changes (baseline updates after each stop hook)
36
37## Configuration
38
39Kill switches:
40- SECURITY_GUIDANCE_DISABLE: "1" to fully disable the plugin (alias for ENABLE_SECURITY_REMINDER=0)
41- ENABLE_SECURITY_REMINDER: "0" to fully disable the plugin (legacy name)
42
43Per-feature toggles (all default enabled; set to "0" to disable):
44- ENABLE_PATTERN_RULES: PostToolUse regex warnings on Edit/Write
45- ENABLE_CODE_SECURITY_REVIEW: Stop-hook git-diff LLM review
46- ENABLE_COMMIT_REVIEW: PostToolUse[Bash] commit security review
47
48Other:
49- SECURITY_REVIEW_MODEL: Model for LLM review (default: the newest Opus)
50- ANTHROPIC_API_KEY: Required for LLM-based reviews
51- ANTHROPIC_AUTH_TOKEN: Alternative to API key — OAuth access token sent as Bearer auth.
52 Claude Code passes this automatically for OAuth-authenticated users.
53"""
54
55try:
56 import fcntl
57except ImportError:
58 fcntl = None
59import contextlib
60import glob
61import hashlib
62import json
63import os
64import random
65import re
66import subprocess
67import sys
68import threading
69import urllib.request
70from datetime import datetime
71from enum import IntEnum
72from typing import Optional, Tuple, Dict, Any, List
73
74# review_api is the importable surface for the agentic-review prompts,
75# schemas, and pure filters. External callers (e.g. agentic review harnesses)
76# import review_api directly so they run the same eval-covered prompts
77# without going through the CC hook protocol. The underscored names below
78# alias into it so this script stays the single CC-hook entrypoint.
79sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
80import review_api # noqa: E402
81from _base import ( # noqa: E402,F401
82 DEBUG_LOG_FILE, DEBUG_LOG_MAX_BYTES, debug_log,
83 PROVENANCE_TAG, PROVENANCE_BANNER,
84 _read_plugin_version_int, _PV, _USAGE, _USAGE_LOCK,
85 _PRICE_PER_MTOK, _PRICE_DEFAULT, _record_usage, _usage_metrics,
86 state_dir as _resolve_state_dir,
87)
88import extensibility # noqa: E402
89from patterns import ( # noqa: E402,F401
90 _JS_EXTS, _PY_EXTS, _DOC_EXTS,
91 _UNSAFE_DESERIALIZATION_REMINDER, _UNSAFE_YAML_LOAD_REMINDER,
92 _UNSAFE_TORCH_LOAD_REMINDER, SECURITY_PATTERNS, RuleId,
93 _RULE_NAME_TO_ID, rule_names_to_mask,
94)
95from session_state import ( # noqa: E402,F401
96 _state_key, get_state_file, get_lock_file, cleanup_old_state_files,
97 load_state, save_state, with_locked_state,
98)
99from gitutil import ( # noqa: E402,F401
100 GIT_CMD, apply_safe_git_env, remove_copies_on_termination,
101 _git_rev_parse_head, _find_git_index, _diff_pathspec, _temp_index,
102 _git_toplevel, _git_dir, _git_rev_list_range, _git_diff_range,
103 _detect_main_branch, _git_reflog_recent_commits, _git_name_only,
104 _git_status_porcelain, _is_ancestor, get_git_diff,
105 SOURCE_CODE_EXTENSIONS, SOURCE_CODE_BASENAMES,
106 NON_SOURCE_EXTENSIONLESS_BASENAMES, SKIP_PATH_PATTERNS,
107 SKIP_FILE_SUFFIXES, _SECURITY_RISK_PATH_TOKENS,
108 _LOW_PRIORITY_SUFFIXES, _LOW_PRIORITY_PATH_TOKENS,
109 _prioritize_diff_files, _is_reviewable_source,
110 extract_file_paths_from_diff, parse_diff_into_files,
111 filter_preexisting_from_diff,
112)
113from diffstate import ( # noqa: E402,F401
114 STOP_LOOP_STATE_TTL_SEC, PREVIOUS_FINDINGS_TTL_SEC,
115 save_baseline_sha, load_baseline_sha, record_touched_path,
116 consume_stop_state, restore_unreviewed_stop_state, record_reviewed_diff,
117 get_baseline_file_content, capture_git_baseline,
118 _REVIEWED_SHAS_BASENAME, _REVIEWED_SHAS_CAP,
119 _reviewed_shas_path, _load_reviewed_shas, _append_reviewed_shas,
120 UNTRACKED_BASELINE_CAP, _list_untracked, compute_v2_review_set,
121)
122from reporesolve import ( # noqa: E402,F401
123 RES_NONE, RES_CWD, RES_COMMAND, RES_SHA_SCAN, RES_TOUCHED_PATHS, RES_HINT,
124 COMMIT_SUBCOMMANDS, PUSH_SUBCOMMANDS,
125 toplevel_from_command, repo_containing_commit, scan_roots,
126 resolve_repo_root, save_repo_hint, load_repo_hint,
127)
128import llm # noqa: E402 module ref for reassignable globals (_last_call_claude_http_error etc.)
129from llm import ( # noqa: E402,F401
130 ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN, HAS_API_CREDENTIALS,
131 SECURITY_REVIEW_MODEL, CLAUDE_CODE_SYSTEM_PROMPT,
132 _last_call_claude_http_error,
133 ensure_anthropic_reachable,
134 _last_review_truncated_bytes, _auth_prefer_token,
135 DIFF_PER_FILE_BYTES, DIFF_TOTAL_BYTES, _AGENTIC_INVESTIGATE_SYSTEM,
136 _FINDINGS_SCHEMA, _SURVIVED_SCHEMA, _REWAKE_SUMMARY_BUDGET,
137 _cap_files_for_prompt, _build_auth_headers, _call_claude, _call_claude_dual_or,
138 _format_vulns_guidance, _format_vulns_summary, _finding_keys, _dedup_against_state,
139 analyze_code_security, _agentic_commit_review_enabled, agentic_review,
140 analyze_security_concerns,
141)
142
143# LLM-based code security review (enabled by default when API key is available)
144# Empty string or unset = enabled (default); "0" = disabled
145_enable_code_review_str = os.environ.get("ENABLE_CODE_SECURITY_REVIEW", "1")
146ENABLE_CODE_SECURITY_REVIEW = _enable_code_review_str != "0"
147
148# Pattern-based rules (enabled by default; set to "0" to use only LLM review)
149# Empty string or unset = enabled (default); "0" = disabled
150_enable_pattern_str = os.environ.get("ENABLE_PATTERN_RULES", "1")
151ENABLE_PATTERN_RULES = _enable_pattern_str != "0"
152
153# Per-feature kill switches. Each defaults to enabled. Set to "0" to disable
154# just that one feature without touching the rest. Motivated by feedback that
155# autonomous-agent setups sometimes need to disable specific injection points
156# (e.g. the PreToolUse[Task] prompt append, which can read as prompt injection
157# to hardened subagents) while keeping the rest of the plugin active. See
158# README for a full description of each feature.
159# Commit review also honors legacy SECURITY_GUIDANCE_COMMIT_REVIEW=off; see
160# is_commit_review_enabled().
161ENABLE_COMMIT_REVIEW = os.environ.get("ENABLE_COMMIT_REVIEW", "1") != "0"
162# Stop-hook git-diff review only — does NOT gate the commit/push reviews.
163# Lets multi-agent / shared-worktree deployments keep the commit reviewer
164# (anchored to a fixed SHA from the worker's own `git commit` stdout) while
165# turning off the Stop-hook diff (anchored on baseline_sha…HEAD, which a
166# sibling agent in the same worktree can move under us). The pre-existing
167# ENABLE_CODE_SECURITY_REVIEW gate is shared between Stop and commit/push
168# and stays for backwards compat as the all-LLM-review master switch.
169ENABLE_STOP_REVIEW = os.environ.get("ENABLE_STOP_REVIEW", "1") != "0"
170
171# Master kill switch. Either SECURITY_GUIDANCE_DISABLE=1 or
172# ENABLE_SECURITY_REMINDER=0 disables the plugin entirely. Kept as two names
173# because ENABLE_SECURITY_REMINDER predates the rename and some users already
174# have it baked into shell rc files; SECURITY_GUIDANCE_DISABLE reads correctly
175# as a kill switch (no double-negative).
176_disable_str = os.environ.get("SECURITY_GUIDANCE_DISABLE", "").strip().lower()
177SECURITY_GUIDANCE_DISABLED = (
178 _disable_str in ("1", "true", "yes", "on")
179 or os.environ.get("ENABLE_SECURITY_REMINDER", "1") == "0"
180)
181
182# Maximum number of times the stop hook can fire per user turn.
183# Allows iterative fixing: Claude stops → review → fix → stop → review again.
184# Set to 0 for unlimited (like the old plugin). Default 3 for iterative fixing.
185MAX_STOP_HOOK_FIRINGS = int(os.environ.get("MAX_STOP_HOOK_FIRINGS", "3"))
186
187# Cap on source files sent to the LLM reviewer per Stop fire. A stale baseline
188# meeting an ungitignored build directory can produce an enormous spurious
189# diff; unbounded diffs burn tokens and risk 400 on context length.
190MAX_DIFF_FILES = int(os.environ.get("MAX_DIFF_FILES", "30"))
191
192# Appended to all exit(2) guidance so the asyncRewake auto-turn doesn't
193# cause the model to abandon the user's original request.
194CONTINUATION_SUFFIX = (
195 "\n\nAfter addressing or acknowledging this finding, continue with the "
196 "user's original request or continue waiting for their reply — this "
197 "review is supplementary feedback, not a replacement for your previous "
198 "response."
199)
200
201def emit_metrics(
202 metrics,
203 rewake_summary=None,
204 additional_context=None,
205 system_message=None,
206 hook_event_name="PostToolUse",
207):
208 """
209 Write a SyncHookJSONOutput line to stdout for Claude Code to pick up.
210 For asyncRewake (Stop) hooks, CC scans stdout for the first {-prefixed line
211 that validates as SyncHookJSONOutput and emits the hook metrics event.
212 For sync (PostToolUse) hooks, the metrics key in the normal JSON response
213 is picked up directly.
214
215 Constraints: keys ^[a-z][a-z0-9_]{0,39}$, values bool|finite-number,
216 20-key cap (was 10 in older CC versions).
217
218 `pv` and the tok_*/cost_usd usage block are PREPENDED so they survive any
219 future overflow — CC keeps only the first 20 keys, so insertion order
220 decides what drops. The old `len(metrics) < 10` guard was load-bearing for
221 the same reason but stale: once `rate_count` was added to every
222 commit-review emit, the with-vulns dict hit 10 keys, `pv` was skipped, and
223 findings metrics landed without a plugin version attached, breaking
224 per-version breakdowns.
225
226 `rewake_summary` (asyncRewake only): per-run override of the static
227 rewakeSummary in hooks.json, shown to the user in the terminal as the
228 task-notification one-liner. Must be in the same JSON line as the metrics
229 because CC stops scanning stdout after the first {-prefixed line.
230
231 `additional_context` (asyncRewake findings): model-visible guidance text.
232 Delivery channel depends on `hook_event_name` because CC's hook-output
233 contract is NOT symmetric across events:
234
235 - PostToolUse (commit-review, push-sweep): surfaced via the modern
236 hookSpecificOutput.additionalContext protocol. `PostToolUse` is a
237 member of CC's hookSpecificOutput discriminated union
238 (coreSchemas.ts), so the JSON validates and metrics/rewakeSummary
239 are consumed. See #1375 / #1783 for why this replaced the legacy
240 stderr + exit(2) shape for PostToolUse.
241
242 - Stop / SubagentStop: there is NO `Stop` member in that union, so
243 emitting hookSpecificOutput{hookEventName:"Stop"} makes the whole
244 line fail isSyncHookJSONOutput validation — which on the asyncRewake
245 path silently drops metrics AND rewakeSummary, and (because the
246 legacy stderr write was removed) leaks the raw JSON to the model as
247 the rewake body. CC's asyncRewake delivery actually reads
248 `stderr || stdout` for the model-visible body and only scans stdout
249 JSON for metrics+rewakeSummary — it never reads additionalContext
250 on this path. So for Stop we use the documented clean pattern:
251 guidance on stderr, valid JSON (metrics + rewakeSummary +
252 top-level decision/reason) on stdout. The top-level decision:"block"
253 + reason also covers the sync-fallback path (single-shot `claude -p`,
254 where asyncRewake degrades to a sync Stop hook that reads
255 decision/reason). See #2159.
256
257 Empty/None additional_context emits neither channel (back-compat for
258 metrics-only callers).
259
260 `system_message` (optional, asyncRewake only): user-visible TUI message,
261 distinct from rewakeSummary which is the task-notification one-liner.
262 Use sparingly — the rewakeMessage in hooks.json is the primary user
263 surface; systemMessage adds a per-fire override when the static
264 rewakeMessage isn't specific enough for the finding being shown.
265
266 `hook_event_name` (used only when additional_context is set): selects the
267 delivery channel above. Defaults to "PostToolUse" (commit-review and
268 push-sweep are the most common callers); handle_stop_hook passes "Stop".
269 """
270 head = {}
271 if _PV and "pv" not in metrics:
272 head["pv"] = _PV
273 head.update(_usage_metrics())
274 if head:
275 metrics = {**head, **metrics}
276 out = {"metrics": metrics}
277 if rewake_summary:
278 out["rewakeSummary"] = rewake_summary
279 if additional_context:
280 if hook_event_name in ("Stop", "SubagentStop"):
281 # Stop is NOT in CC's hookSpecificOutput union — emitting it there
282 # fails schema validation and drops metrics+rewakeSummary (#2159).
283 # Clean pattern: guidance on stderr (the asyncRewake body channel,
284 # delivered via `stderr || stdout`), top-level decision/reason for
285 # the sync-fallback path. stdout JSON stays valid so metrics +
286 # rewakeSummary survive.
287 sys.stderr.write(additional_context)
288 sys.stderr.flush()
289 out["decision"] = "block"
290 out["reason"] = additional_context
291 else:
292 # PostToolUse et al. — valid union member; modern protocol.
293 out["hookSpecificOutput"] = {
294 "hookEventName": hook_event_name,
295 "additionalContext": additional_context,
296 }
297 if system_message:
298 out["systemMessage"] = system_message
299 print(json.dumps(out), flush=True)
300
301# =====================================================================
302# State management
303# =====================================================================
304
305#
306# Low-level state-file plumbing (_state_key, get_state_file,
307# get_lock_file, cleanup_old_state_files, load_state, save_state,
308# with_locked_state) moved to session_state.py and re-exported above.
309
310def atomic_check_and_mark_warning(session_id, warning_key):
311 """
312 Atomically check if a warning has been shown and mark it as shown if not.
313 Returns True if this is the first time seeing this warning (should show it),
314 False if it was already shown (should skip it).
315 """
316 def _check(state):
317 warnings = state["shown_warnings"]
318 if warning_key in warnings:
319 return False
320 warnings.append(warning_key)
321 return True
322
323 result = with_locked_state(session_id, _check)
324 return result if result is not None else True
325
326def atomic_check_counter(session_id, counter_key, max_count):
327 """
328 Atomically check if a counter has reached its limit and increment if not.
329 Returns True if the counter is below max_count (should proceed),
330 False if it has reached or exceeded max_count (should skip).
331 """
332 def _check(state):
333 counters = state.get("counters", {})
334 current = counters.get(counter_key, 0)
335 if current >= max_count:
336 return False
337 counters[counter_key] = current + 1
338 state["counters"] = counters
339 return True
340
341 result = with_locked_state(session_id, _check)
342 return result if result is not None else True
343
344def atomic_check_rate_limit(session_id, key, max_per_window, window_s):
345 """Rolling-window rate limit: allow at most `max_per_window` calls per
346 `window_s` seconds, per (session_id, key).
347
348 Returns (allowed: bool, count_in_window: int). count_in_window is the
349 post-decision count (i.e., includes this call if allowed) so callers can
350 emit it directly as a telemetry gauge.
351
352 Replaces session-lifetime `atomic_check_counter` for commit-review and
353 push-sweep. Telemetry showed a small but persistent share of sessions hit
354 the lifetime cap, and those were multi-day persistent sessions that then
355 lost coverage for many subsequent commits — not burst abusers. A rolling
356 hour keeps the same cost ceiling for any 1h window while letting long
357 sessions regain coverage.
358
359 State key: rate_limits: {"<key>": [ts, ts, ...]}. Timestamps are pruned
360 on every call so the list is bounded by max_per_window; no migration
361 needed from the old `counters` dict — different key.
362 """
363 import time as _time
364 now = _time.time()
365 cutoff = now - window_s
366
367 def _check(state):
368 buckets = state.setdefault("rate_limits", {})
369 ts_list = buckets.get(key, [])
370 # Prune; tolerate non-numeric junk from a corrupted state file.
371 ts_list = [t for t in ts_list if isinstance(t, (int, float)) and t > cutoff]
372 if len(ts_list) >= max_per_window:
373 buckets[key] = ts_list
374 return False, len(ts_list)
375 ts_list.append(now)
376 buckets[key] = ts_list
377 return True, len(ts_list)
378
379 result = with_locked_state(session_id, _check)
380 # State unavailable → fail-open (same posture as atomic_check_counter).
381 return result if result is not None else (True, 0)
382
383# =====================================================================
384# Warning outcome tracking
385#
386# Records each pattern warning as pending when it fires. At Stop, sweep
387# all pending entries: re-read each file, re-check patterns, and emit a
388# fixed-vs-unresolved tally. No per-edit work — pending is recorded only
389# when a pattern matches (rare), and the sweep runs once at session end.
390#
391# State key: pending_warnings: {"<file>:<rule>": true}
392# =====================================================================
393
394def record_pending_warnings(session_id, file_path, rule_names):
395 """Mark file:rule pairs as pending for the Stop-hook outcome sweep."""
396 def _record(state):
397 pending = state.get("pending_warnings")
398 if not isinstance(pending, dict):
399 pending = {}
400 state["pending_warnings"] = pending
401 for rule in rule_names:
402 pending[f"{file_path}:{rule}"] = True
403 with_locked_state(session_id, _record)
404
405def sweep_pending_warnings(session_id):
406 """
407 Stop-hook final sweep. Re-read every file in pending_warnings, re-check
408 patterns, and return (fixed, unresolved, unresolved_mask). Clears state.
409 A file that's been deleted counts as fixed — the dangerous code is gone.
410 Never raises — this is telemetry and must not break the Stop hook.
411 """
412 def _sweep(state):
413 try:
414 pending = state.get("pending_warnings")
415 if not isinstance(pending, dict) or not pending:
416 return 0, 0, 0
417
418 by_file = {}
419 for key in pending:
420 if not isinstance(key, str) or ":" not in key:
421 continue
422 fp, _, rule = key.rpartition(":")
423 by_file.setdefault(fp, set()).add(rule)
424
425 unresolved = []
426 fixed = 0
427 for fp, rules in by_file.items():
428 try:
429 with open(fp, "r", errors="replace") as f:
430 still_matching = {r for r, _ in check_patterns(fp, f.read())}
431 except (OSError, IOError):
432 still_matching = set()
433 for rule in rules:
434 if rule in still_matching:
435 unresolved.append(rule)
436 else:
437 fixed += 1
438
439 state["pending_warnings"] = {}
440 # Filter to known rules so a renamed/removed rule in old state
441 # doesn't KeyError rule_names_to_mask.
442 known = [r for r in unresolved if r in _RULE_NAME_TO_ID]
443 return fixed, len(unresolved), rule_names_to_mask(known)
444 except Exception as e:
445 debug_log(f"sweep_pending_warnings failed: {e}")
446 return 0, 0, 0
447
448 result = with_locked_state(session_id, _sweep)
449 return result if result is not None else (0, 0, 0)
450
451# =====================================================================
452# Git baseline management
453# =====================================================================
454
455# =====================================================================
456# Pattern matching
457# =====================================================================
458
459def check_patterns(file_path, content):
460 """Check if file path or content matches any security patterns. Returns ALL matches."""
461 normalized_path = file_path.lstrip("/")
462 matches = []
463
464 for pattern in list(SECURITY_PATTERNS) + extensibility.user_patterns():
465 # path_filter is a gate: when present, the rule only applies to
466 # matching paths. Distinct from path_check, which is itself a
467 # positive match condition (e.g. .github/workflows/).
468 if "path_filter" in pattern:
469 try:
470 if not pattern["path_filter"](normalized_path):
471 continue
472 except Exception:
473 continue
474
475 matched = False
476
477 if "path_check" in pattern:
478 try:
479 if pattern["path_check"](normalized_path):
480 matched = True
481 except Exception:
482 pass
483
484 if not matched and "substrings" in pattern and content:
485 for substring in pattern["substrings"]:
486 if substring in content:
487 matched = True
488 break
489
490 if not matched and "regex" in pattern and content:
491 try:
492 if re.search(pattern["regex"], content):
493 matched = True
494 except Exception:
495 pass
496
497 if matched:
498 matches.append((pattern["ruleName"], pattern["reminder"]))
499
500 return matches
501
502def extract_content_from_input(tool_name, tool_input):
503 """Extract content to check from tool input based on tool type."""
504 if tool_name == "Write":
505 return tool_input.get("content", "")
506 elif tool_name == "Edit":
507 return tool_input.get("new_string", "")
508 elif tool_name == "MultiEdit":
509 edits = tool_input.get("edits", [])
510 if edits:
511 return " ".join(edit.get("new_string", "") for edit in edits)
512 return ""
513 return ""
514
515# =====================================================================
516# Hook handlers
517# =====================================================================
518
519def handle_user_prompt_submit(input_data):
520 """
521 Handle UserPromptSubmit — capture git baseline SHA.
522 Called on every user prompt. Updates the baseline so the stop hook
523 only reviews changes made since the last prompt.
524
525 Does NOT reset touched_paths/fire_count/previous_findings — those are
526 consumed by Stop (consume_stop_state) and time-expired respectively.
527 UPS racing the asyncRewake Stop hook caused a meaningful share of reviews
528 to be lost when the wipe landed before Stop's state read.
529
530 """
531 cwd = input_data.get("cwd", "")
532 if not cwd:
533 debug_log("UPS: no cwd, skipping baseline capture")
534 sys.exit(0)
535
536 session_id = input_data.get("session_id", "default")
537 # stash-create and ls-files both walk the worktree (~2-5s each in a very
538 # large repo). Run them concurrently so UPS latency stays ≈ max(both).
539 import concurrent.futures as _cf
540 with _cf.ThreadPoolExecutor(max_workers=2) as _ex:
541 _f_sha = _ex.submit(capture_git_baseline, cwd)
542 _f_ut = _ex.submit(_list_untracked, cwd)
543 sha = _f_sha.result()
544 # Always capture the untracked snapshot. `git stash create` returns
545 # empty when there are no TRACKED changes, but pre-existing untracked
546 # files still need to be excluded from the next Stop's review_set —
547 # otherwise an untracked-only working tree gets every untracked file
548 # reviewed on every turn until something tracked is dirtied.
549 untracked_now = _f_ut.result() or {}
550 if not sha and not _git_toplevel(cwd):
551 hint = load_repo_hint(session_id)
552 if hint:
553 debug_log(f"UPS: cwd is not a git repo; using repo hint {hint!r}")
554 cwd = hint
555 with _cf.ThreadPoolExecutor(max_workers=2) as _ex:
556 _f_sha = _ex.submit(capture_git_baseline, cwd)
557 _f_ut = _ex.submit(_list_untracked, cwd)
558 sha = _f_sha.result()
559 untracked_now = _f_ut.result() or {}
560 head = _git_rev_parse_head(cwd)
561
562 # If the previous turn's Stop hook never ran (user interrupt, follow-up
563 # during work, tool-reject, model crash, maxTurns, PostToolUse block…),
564 # touched_paths is still populated because consume_stop_state is the only
565 # consumer and it runs under the state lock. Overwriting baseline_sha now
566 # would re-baseline *past* those unreviewed edits, making them permanently
567 # invisible to the next Stop. Preserve the old baseline so the next Stop
568 # diffs the aborted turn's edits plus the new turn's edits together.
569 preserved = {"value": False}
570
571 def _save(state):
572 # Only preserve if there's actually an old baseline to preserve.
573 # First UPS of a session can have touched_paths if PostToolUse
574 # somehow ran first (print mode, odd harnesses) — in that case
575 # we still need to capture a baseline.
576 if state.get("touched_paths") and state.get("baseline_sha"):
577 preserved["value"] = True
578 return
579 if sha:
580 state["baseline_sha"] = sha
581 state["head_at_capture"] = head
582 # untracked_at_baseline is independent of whether the stash produced
583 # a SHA — write it unconditionally so compute_v2_review_set's
584 # preexisting-untracked exclusion works in untracked-only trees.
585 state["untracked_at_baseline"] = untracked_now
586 with_locked_state(session_id, _save)
587
588 if preserved["value"]:
589 debug_log(
590 "UPS: preserving prior baseline — previous Stop hook never "
591 "consumed touched_paths (likely user interrupt / aborted turn)"
592 )
593 elif sha:
594 debug_log(f"Captured git baseline: {sha[:12]}")
595 else:
596 # Show cwd so the next reporter can immediately see when this isn't
597 # actually "not a git repo" but a path-encoding / permissions / git
598 # invocation failure. See #2099.
599 debug_log(f"Failed to capture git baseline (cwd={cwd!r}) — not a git repo, "
600 f"or git invocation failed (check log entries above)")
601
602 sys.exit(0)
603
604def _resolve_amend_pre_sha(repo_root, expected_post_sha=None):
605 """For a `git commit --amend` we just ran, return the pre-amend SHA via
606 reflog, or None if it can't be safely determined.
607
608 expected_post_sha: the post-amend SHA the caller parsed from bash stdout
609 (or reflog). If provided, HEAD@{0} of `repo_root` must match it (prefix
610 compare — bash stdout SHAs are abbreviated, reflog %H is 40 chars) before
611 we trust the reflog-derived pre-amend SHA. This guards against the
612 cross-repo case (`cd ../other && git commit --amend && cd -`) where
613 `repo_root` happens to have its own recent amend that's unrelated to
614 the bash command we're reviewing.
615
616 We require HEAD@{0}'s reflog subject to start with `commit (amend)` —
617 otherwise our `--amend` regex matched something that didn't actually
618 perform an amend (e.g., `git commit --amend --dry-run`, aliased commands,
619 aborted amends), and HEAD@{1} would be the wrong commit. Also requires
620 HEAD@{1} to NOT itself be an amend, since back-to-back amends would have
621 HEAD@{1} as the previous-amend's post state — the original commit we
622 want to compare against is then HEAD@{2}, but at that point we're
623 reaching and fall back to a full review.
624
625 Bytes + decode('utf-8', errors='replace'): reflog subjects embed commit
626 subjects, which git stores as raw bytes (commit messages may be latin-1
627 / cp1252 / etc.). text=True would raise UnicodeDecodeError (a
628 ValueError, not OSError) on non-UTF8 bytes and crash the hook.
629 """
630 if not repo_root:
631 return None
632 try:
633 r = subprocess.run(
634 [*GIT_CMD, "log", "-g", "-2", "--format=%H|%gs", "HEAD"],
635 cwd=repo_root, capture_output=True, timeout=5,
636 )
637 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
638 return None
639 if r.returncode != 0:
640 return None
641 stdout_text = r.stdout.decode("utf-8", errors="replace")
642 lines = [ln for ln in stdout_text.splitlines() if "|" in ln]
643 if len(lines) < 2:
644 return None
645 head0_sha, _, head0_subj = lines[0].partition("|")
646 head1_sha, _, head1_subj = lines[1].partition("|")
647 if not head0_subj.startswith("commit (amend)"):
648 return None
649 if head1_subj.startswith("commit (amend)"):
650 return None
651 # Cross-repo guard: the post-amend SHA the caller is about to review must
652 # match HEAD@{0} of repo_root. Otherwise the bash command was likely run
653 # in a different repo than repo_root, and the reflog we just read is
654 # unrelated. Prefix-compare: expected_post_sha is typically the 7-char
655 # abbreviated SHA captured from bash stdout by _COMMIT_SHA_RE (git's
656 # default core.abbrev floor), while head0_sha is the full 40-char %H —
657 # strict equality would always fail and silently disable the delta path.
658 if expected_post_sha and not head0_sha.startswith(expected_post_sha):
659 return None
660 return head1_sha or None
661
662# git-only signals that corroborate a real commit object — NOT emitted by
663# pre-commit / lint-staged / husky hook output, which can contain bracketed
664# labels like `[pre-commit abc1234]` that otherwise look like a commit line.
665_COMMIT_DIFFSTAT_PATTERNS = [
666 re.compile(r'\b\d+ files? changed'),
667 re.compile(r'^ create mode ', re.MULTILINE),
668 re.compile(r'^ delete mode ', re.MULTILINE),
669 re.compile(r'^ rename ', re.MULTILINE),
670]
671
672# Capture-group form of the [branch sha] pattern. Mirrors Claude Code's own
673# commit-id parsing, but tolerates spaces before the
674# sha (covers `[detached HEAD abc1234]`). 7–40 hex chars: git's abbrev floor
675# through full sha; the abbrev resolves fine with `git show`. Anchored to
676# line-start so a `[hex]` in the commit subject (`[main abc] Revert [e38]`)
677# or trailing hook output isn't picked up and fed to `git show`.
678_COMMIT_SHA_RE = re.compile(r'^\[[^\]]*?\b([0-9a-f]{7,40})\]', re.MULTILINE)
679
680# Regex matching `git commit` commands. Mirrors Claude Code's own commit
681# detection — it does NOT tolerate `git -c k=v commit` global options, which
682# keeps this hook aligned with CC's commit attribution on what counts as a
683# commit.
684#
685# Also matches `gt create` and `gt modify` — Graphite's stacked-PR wrapper
686# around git. `gt create` produces a new commit (mapped to git commit
687# semantics); `gt modify` amends the current commit (mapped to git commit
688# --amend, also flagged by _GIT_AMEND_RE below). The hooks.json matcher
689# widening for `gt create:*` / `gt modify:*` / `gt submit:*` ships in the
690# same change set — without that widening this regex change is dead code
691# because the hook subprocess never spawns for gt invocations. See #2048.
692_GIT_COMMIT_RE = re.compile(
693 # `git -C <path>` and `git -c key=val` global options are allowed between
694 # `git` and `commit` (mirrors the long-standing tolerance in
695 # _GIT_PUSH_RE). Without this, `git -C /repo commit` is silently dropped
696 # by the handler — see #2089's secondary finding. The gt branch has no
697 # global-option layer to worry about.
698 r'\bgit(?:\s+-[Cc]\s+(?:"[^"]*"|\'[^\']*\'|[^\s"\']\S*)|\s+--[^\s=]+=\S+'
699 r'|\s+--(?:git-dir|work-tree)\s+\S+)*\s+commit\b'
700 r'|\bgt\s+(?:create|modify)\b'
701)
702# Match either the `--amend` flag (with the leading whitespace boundary
703# preserved from the original) OR `gt modify` which is semantically an
704# amend. The handler treats matches as "find the pre-amend SHA via reflog
705# and diff against THAT, not against the post-amend HEAD's parent" — same
706# code path for both git --amend and gt modify.
707_GIT_AMEND_RE = re.compile(r'(?:\s--amend\b|\bgt\s+modify\b)')
708
709# Rolling-window cap on LLM commit-review calls. See atomic_check_rate_limit
710# docstring for the rationale that motivated the switch from a lifetime cap.
711# `MAX_COMMIT_REVIEWS_PER_SESSION` is read for backward-compat with users who
712# tuned it; the value is reinterpreted as per-hour.
713MAX_COMMIT_REVIEWS_PER_HOUR = int(
714 os.environ.get("MAX_COMMIT_REVIEWS_PER_HOUR")
715 or os.environ.get("MAX_COMMIT_REVIEWS_PER_SESSION", "20")
716)
717COMMIT_REVIEW_RATE_WINDOW_S = int(
718 os.environ.get("COMMIT_REVIEW_RATE_WINDOW_S", "3600")
719)
720
721# ─── push-sweep ─────────────────────────────────────────────────────────────
722#
723# Mirrors Claude Code's own push-command matching — tolerates `git -C <p>` /
724# `git -c k=v` global options. The hooks.json `Bash(git push:*)` matcher
725# (subcommand prefix) doesn't, but those forms are rare in practice
726# and the python only ever runs after CC's matcher fired, so this regex is a
727# defensive re-gate, not a widening — `git -C path push` won't reach python
728# unless chained with a plain `git push` in the same compound command.
729#
730# `gh pr create` is intentionally NOT a separate hooks.json matcher: gh runs
731# `git push` as a child process, which CC's matcher doesn't observe (it sees
732# only the top-level `gh pr create` argv). A separate `Bash(gh pr create:*)`
733# entry would buy minimal extra coverage (sessions that push only via gh) at
734# the cost of an extra python spawn on every `... && gh pr create` compound
735# (the common case). Those sessions are caught on their next standalone `git push`.
736# Matches `git push` (with optional `-c k=v` / `-C path` global options
737# CC's hooks.json matcher doesn't tolerate) OR `gt submit` — Graphite's
738# stacked-PR push command. gt submit forwards to `git push` internally,
739# but the bash hook fires on Claude's top-level command so we need to
740# recognize gt submit at the matcher level. See #2048.
741_GIT_PUSH_RE = re.compile(
742 r'(?:\bgit(?:\s+-[cC]\s+(?:"[^"]*"|\'[^\']*\'|[^\s"\']\S*)|\s+--[^\s=]+=\S+'
743 r'|\s+--(?:git-dir|work-tree)\s+\S+)*\s+push\b|\bgt\s+submit\b)'
744)
745
746# `git push` stdout: "abc1234..def5678 branch -> branch" (or `+abc..def` on
747# force, `* [new branch]` on first push). The left sha is where the remote
748# was BEFORE this push — exactly the base we need. Captures (old, new,
749# local-ref) so the handler can verify the pushed ref == HEAD before
750# diffing — `git push origin other` while on a different branch would
751# otherwise diff the wrong range.
752_PUSH_RANGE_RE = re.compile(
753 r'^\s*\+?\s*([0-9a-f]{7,40})\.\.\.?([0-9a-f]{7,40})\s+(\S+)\s+->\s+\S+',
754 re.MULTILINE,
755)
756
757MAX_PUSH_SWEEP_FILES = int(os.environ.get("SG_PUSH_SWEEP_MAX_FILES", "30"))
758MAX_PUSH_SWEEP_RANGE = int(os.environ.get("SG_PUSH_SWEEP_MAX_RANGE", "50"))
759PUSH_SWEEP_REPORT_CAP = int(os.environ.get("SG_PUSH_SWEEP_REPORT_CAP", "3"))
760
761def _claim_bash_hook_once(input_data):
762 """De-dupe across hooks.json `if` matchers firing for the same Bash call.
763
764 `git commit -m x && git push` matches both `Bash(git commit:*)` and
765 `Bash(git push:*)` `if` configs → CC spawns this script twice with the
766 SAME `tool_use_id`. The first spawn atomically creates a
767 sentinel under `.git/`; subsequent spawns see it and exit early. Avoids
768 redundant LLM calls (and the redundant asyncRewake) on compound commands.
769
770 Returns True if this spawn won the claim (or no de-dupe is possible),
771 False if another spawn already claimed it.
772
773 Sentinel is per-clone (`.git/sg-hook-once-<tool_use_id>`), not /tmp,
774 so concurrent CC sessions in *different* repos don't collide. Stale
775 sentinels (>5min) are GC'd opportunistically.
776 """
777 tuid = input_data.get("tool_use_id")
778 cwd = input_data.get("cwd")
779 if not tuid or not cwd:
780 return True
781 command = (input_data.get("tool_input") or {}).get("command", "") or ""
782 gd = _git_dir(
783 _git_toplevel(cwd)
784 or toplevel_from_command(command, cwd, COMMIT_SUBCOMMANDS | PUSH_SUBCOMMANDS)
785 or cwd
786 )
787 if not gd:
788 return True
789 # GC: best-effort sweep of stale sentinels so they don't accumulate.
790 import time as _time
791 now = _time.time()
792 try:
793 for name in os.listdir(gd):
794 if name.startswith("sg-hook-once-"):
795 p = os.path.join(gd, name)
796 try:
797 if now - os.path.getmtime(p) > 300:
798 os.unlink(p)
799 except OSError:
800 pass
801 except OSError:
802 pass
803 # Sanitize tuid into a filesystem-safe basename — defensive, the value is
804 # CC-generated (toolu_<b64ish>), but it ends up in a path.
805 safe = re.sub(r"[^A-Za-z0-9_-]", "_", tuid)[:80]
806 sentinel = os.path.join(gd, f"sg-hook-once-{safe}")
807 try:
808 fd = os.open(sentinel, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
809 os.close(fd)
810 return True
811 except FileExistsError:
812 return False
813 except OSError:
814 # Can't write sentinel (read-only fs, perms) — proceed rather than
815 # silently dropping the review.
816 return True
817
818def is_push_sweep_enabled():
819 """Gate for the push-sweep PostToolUse[Bash] hook.
820
821 Enabled by default. ENABLE_COMMIT_REVIEW=0 remains the unconditional
822 kill switch (push-sweep reuses the same review pipeline and budget).
823 SG_PUSH_SWEEP is the per-user override (=1/on or =0/off) checked
824 next so users can opt out.
825 """
826 if not ENABLE_COMMIT_REVIEW:
827 return False
828 v = os.environ.get("SG_PUSH_SWEEP", "").strip().lower()
829 if v in ("1", "on"):
830 return True
831 if v in ("0", "off"):
832 return False
833 return True
834
835PUSH_SWEEP_ENABLED = is_push_sweep_enabled()
836
837def _compute_push_sweep_base(prev_upstream, push_range, reviewed):
838 """Advance the diff base past the contiguous reviewed prefix.
839
840 Spec: review `git diff B..HEAD` where `B` is the newest commit such that
841 `prev_upstream..B` is entirely in `reviewed`. Returns (B, unreviewed_tail).
842 `B == None` means the whole range is reviewed (caller should skip).
843 `push_range` must be oldest→newest.
844
845 Examples (✓=reviewed, ✗=not):
846 [✓1, ✗2, ✓3] → B=1, tail=[2,3] (cannot trim suffix; Read is at HEAD)
847 [✓1, ✓2, ✓3] → B=None (all reviewed → skip)
848 [✗1, ✓2, ✗3] → B=prev_upstream, tail=[1,2,3]
849 [] → B=None
850 """
851 i = 0
852 while i < len(push_range) and push_range[i] in reviewed:
853 i += 1
854 if i == len(push_range):
855 return None, []
856 base = push_range[i - 1] if i > 0 else prev_upstream
857 return base, push_range[i:]
858
859def _push_section(bash_output):
860 """Return the slice of `bash_output` that contains the push's range lines.
861
862 `_PUSH_RANGE_RE` is not push-specific — `git fetch` and `git pull` print
863 range lines (`abc..def branch -> origin/branch`) in the same format. On
864 chained calls the Bash tool returns combined stdout+stderr, so a naive
865 `_PUSH_RANGE_RE.finditer(bash_output)` matches both sections and a
866 fetch+push compound trips the multi-ref skip.
867
868 `git push` prints `To <remote>` immediately before its range lines;
869 `git fetch`/`git pull` prints `From <remote>` before theirs. The slice
870 is symmetric: start at the LAST `To <remote>` header (strips fetch output
871 that ran *before* the push, e.g. `git fetch && git push`), and end at
872 the next `From <remote>` after that (strips fetch output that ran
873 *after* the push, e.g. `git push && git fetch`).
874
875 If no `To ` header is present (push failed before connecting, output
876 suppressed by `-q`) the full buffer is returned and the caller's
877 other guards handle it.
878 """
879 if not bash_output:
880 return ""
881 # Match line-anchored "To " — look for "\nTo " or "To " at start-of-string.
882 idx = bash_output.rfind("\nTo ")
883 if idx >= 0:
884 section = bash_output[idx:]
885 elif bash_output.startswith("To "):
886 section = bash_output
887 else:
888 return bash_output
889 # Strip a trailing fetch/pull `From <remote>` block (push && fetch /
890 # push && pull, or any wrapper that re-syncs after the push).
891 end = section.find("\nFrom ")
892 if end >= 0:
893 section = section[:end]
894 return section
895
896def _detect_prev_upstream(repo_root, bash_output):
897 """Where the remote was BEFORE this push.
898
899 Preference order:
900 1. Parse `abc..def` from push stdout — authoritative, exact.
901 2. `<branch>@{u}@{1}` — the remote-tracking ref's reflog position before
902 this push moved it. PostToolUse runs after `git push` completes, so
903 `@{u}` is already updated and `@{u}@{1}` is the prior value.
904 3. merge-base with the detected main branch — first push of a new
905 branch (`* [new branch]` in output, no upstream reflog yet).
906 Returns a resolvable ref/sha or None.
907 """
908 m = _PUSH_RANGE_RE.search(_push_section(bash_output or ""))
909 if m:
910 return m.group(1)
911 # @{u}@{1} — only meaningful if an upstream is configured.
912 for ref in ("@{u}@{1}", "@{push}@{1}"):
913 try:
914 # See #2099: stdout is a SHA but stderr can carry non-ASCII git
915 # warnings — keep bytes raw to avoid cp1252 reader-thread crash.
916 r = subprocess.run(
917 [*GIT_CMD, "rev-parse", "--verify", "-q", ref],
918 cwd=repo_root, capture_output=True, timeout=5,
919 )
920 sha = r.stdout.decode("utf-8", errors="replace").strip()
921 if r.returncode == 0 and sha:
922 return sha
923 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
924 pass
925 main = _detect_main_branch(repo_root)
926 if main:
927 try:
928 # See #2099: drop text=True; decode bytes manually so a
929 # cp1252-undefined byte in git's stderr doesn't crash the
930 # reader thread.
931 r = subprocess.run(
932 [*GIT_CMD, "merge-base", "HEAD", main],
933 cwd=repo_root, capture_output=True, timeout=5,
934 )
935 sha = r.stdout.decode("utf-8", errors="replace").strip()
936 if r.returncode == 0 and sha:
937 return sha
938 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
939 pass
940 return None
941
942def is_commit_review_enabled():
943 """Gate for the commit-review PostToolUse[Bash] hook.
944
945 Commit review is enabled by default; ENABLE_COMMIT_REVIEW=0 remains the
946 unconditional kill switch and SECURITY_GUIDANCE_COMMIT_REVIEW (on/off)
947 remains a legacy per-user override; everything else defaults on.
948 commit_review_on is still emitted in metrics for continuity.
949 """
950 if not ENABLE_COMMIT_REVIEW:
951 return False
952 override = os.environ.get("SECURITY_GUIDANCE_COMMIT_REVIEW", "").strip().lower()
953 if override in ("on", "off"):
954 return override == "on"
955 return True
956
957COMMIT_REVIEW_ENABLED = is_commit_review_enabled()
958
959def _agentic_review_with_race(
960 repo_root: str,
961 diff_files: List[Tuple[str, str]],
962 rel_touched: List[str],
963 previous_findings: List[Dict[str, Any]],
964) -> Tuple[Optional[str], List[Dict[str, Any]], Dict[str, Any]]:
965 """Race the agentic reviewer against a delayed single-shot fallback.
966
967 Agentic starts at t=0. After SG_AGENTIC_RACE_DELAY_S (default 180s), the
968 single-shot diff reviewer also starts. Whichever finishes first wins. If
969 agentic finishes before the delay elapses, the fallback never runs.
970
971 Metrics added:
972 race_winner : 1 = agentic won, 2 = fallback won (CC accepts only
973 bool/finite-number metric values — strings would discard the dict)
974 race_delay_s : the configured delay
975 race_started : 1 if the fallback was actually launched, else 0
976
977 Only the commit-review handler calls this — external harnesses invoke
978 agentic_review() directly and are unaffected. SG_AGENTIC_NO_RACE=1
979 disables the race for any other caller that wants pure agentic.
980 """
981 import queue as _queue
982 import threading as _th
983 import time as _t
984
985 if os.environ.get("SG_AGENTIC_NO_RACE") == "1":
986 return agentic_review(repo_root, diff_files, rel_touched)
987
988 delay_s = int(os.environ.get("SG_AGENTIC_RACE_DELAY_S", "180"))
989 q: "_queue.Queue[Tuple[str, Any]]" = _queue.Queue(maxsize=1)
990 fallback_started = _th.Event()
991
992 def _agentic() -> None:
993 try:
994 r = agentic_review(repo_root, diff_files, rel_touched)
995 except Exception as e: # pragma: no cover — crash → let fallback win
996 r = (None, [], {"agentic_fallback": f"race_crash:{type(e).__name__}"})
997 try:
998 q.put_nowait(("agentic", r))
999 except _queue.Full:
1000 pass
1001
1002 def _fallback() -> None:
1003 _t.sleep(delay_s)
1004 if not q.empty():
1005 return # agentic finished within the delay — never start fallback
1006 fallback_started.set()
1007 try:
1008 g, v = analyze_code_security(
1009 diff_files, is_diff=True, previous_findings=previous_findings
1010 )
1011 except Exception as e: # pragma: no cover
1012 g, v = None, []
1013 try:
1014 q.put_nowait(("fallback", (g, v, {"agentic": False})))
1015 except _queue.Full:
1016 pass
1017
1018 _th.Thread(target=_agentic, daemon=True).start()
1019 _th.Thread(target=_fallback, daemon=True).start()
1020
1021 winner, (g, v, m) = q.get()
1022 m = dict(m) # don't mutate the callee's metrics dict
1023 m["race_winner"] = 1 if winner == "agentic" else 2
1024 m["race_delay_s"] = delay_s
1025 m["race_started"] = 1 if fallback_started.is_set() else 0
1026 return g, v, m
1027
1028def handle_commit_review_posttooluse(input_data):
1029 """PostToolUse handler for Bash — reviews git commits for security issues.
1030
1031 Runs as asyncRewake: detects `git commit` in the Bash command, parses
1032 the resulting SHA(s) from the Bash stdout `[branch sha] msg` line, runs
1033 `git show -p <sha>` per SHA, sends the combined diff through
1034 analyze_code_security, and exits with code 2 (stderr findings) to wake
1035 the model. Deduplicates against the shared previous_findings state so
1036 the Stop hook won't re-flag the same (filePath, vulnerableCode) pair.
1037 """
1038 session_id = input_data.get("session_id", "default")
1039 tool_input = input_data.get("tool_input", {})
1040 tool_response = input_data.get("tool_response", {})
1041 cwd = input_data.get("cwd", "")
1042
1043 command = tool_input.get("command", "")
1044 if not isinstance(command, str) or not _GIT_COMMIT_RE.search(command):
1045 # Defensive only — hooks.json's `"if": "Bash(git commit:*)"` is the
1046 # real gate so CC never spawns python3 for ls/grep/etc. This catches
1047 # cases where CC's command matching fails open and spawns the hook anyway.
1048 sys.exit(0)
1049
1050 debug_log(f"Commit review: detected git commit in command")
1051
1052 # Bash tool_response has no exit_code field (only stdout, stderr,
1053 # interrupted), so success is inferred from the output text — the same
1054 # heuristic Claude Code itself uses.
1055 if not isinstance(tool_response, dict):
1056 tool_response = {}
1057 stdout = tool_response.get("stdout", "") or ""
1058 stderr = tool_response.get("stderr", "") or ""
1059 bash_output = stdout + "\n" + stderr
1060 interrupted = bool(tool_response.get("interrupted"))
1061
1062 # Require BOTH a line-anchored `[branch sha]` AND a git-only diffstat
1063 # signal before treating the tool call as a successful commit. The old
1064 # `any()` check false-positived on (a) pre-commit/husky/lint-staged hooks
1065 # emitting labels like `[pre-commit abc1234]`, and on (b) chained
1066 # `git commit || git log --stat` where `N files changed` appears in output
1067 # even though the commit itself failed.
1068 all_shas = _COMMIT_SHA_RE.findall(bash_output)
1069 commit_succeeded = (
1070 not interrupted
1071 and bool(all_shas)
1072 and any(p.search(bash_output) for p in _COMMIT_DIFFSTAT_PATTERNS)
1073 )
1074
1075 # commit_review_on emitted on every path so telemetry can filter on
1076 # commit_review and group by commit_review_on.
1077 _base = {"commit_review": True, "commit_review_on": COMMIT_REVIEW_ENABLED}
1078
1079 cwd_root = _git_toplevel(cwd) if cwd else None
1080 repo_root = cwd_root
1081 repo_res = RES_CWD if cwd_root else RES_NONE
1082 if cwd:
1083 _cmd_root = toplevel_from_command(command, cwd, COMMIT_SUBCOMMANDS, cwd_root)
1084 if _cmd_root and _cmd_root != cwd_root:
1085 repo_root, repo_res = _cmd_root, RES_COMMAND
1086 if not cwd_root:
1087 _base["cwd_is_repo"] = False
1088 if repo_res != RES_CWD:
1089 _base["repo_resolution"] = repo_res
1090
1091 # Reflog fallback for hidden stdout. Analysis of skip_reason=21 emissions
1092 # showed a large share were commits that DID succeed
1093 # but whose `[branch sha]` line was hidden by piping/redirection/-q
1094 # (e.g., `git commit -m ... 2>&1 | tail -3`). A HEAD@{0}
1095 # reflog check substantially reduced this skip; follow-up analysis found
1096 # the residual is dominated by (a) chained commands moving HEAD@{0} past
1097 # `commit:` (`git commit && git push`), and (b) the `_obvious_noop` guard
1098 # false-positiving on chained `git status` output after a successful -q
1099 # commit. Widening to the last-5-entries × 120s scan and dropping the noop
1100 # guard fixes both. The reviewed-shas dedup below prevents the wider window
1101 # from re-reviewing a prior Bash call's commit, and is the same file
1102 # push-sweep reads — so a SHA is reviewed at most once across both
1103 # surfaces. See _git_reflog_recent_commits docstring for cross-repo /
1104 # race safety.
1105 _reflog_shas: List[str] = []
1106 _skip_21_sub = 0
1107 if not commit_succeeded and not interrupted and cwd:
1108 if not repo_root:
1109 repo_root = load_repo_hint(session_id)
1110 if repo_root:
1111 repo_res = RES_HINT
1112 _base["repo_resolution"] = repo_res
1113 _root = repo_root
1114 _fresh, _stale = _git_reflog_recent_commits(_root)
1115 if _fresh:
1116 _already = _load_reviewed_shas(_root)
1117 _reflog_shas = [s for s in _fresh if s not in _already]
1118 if _reflog_shas:
1119 commit_succeeded = True
1120 debug_log(
1121 f"Commit review: stdout had no `[branch sha]`; reflog "
1122 f"shows {len(_reflog_shas)} fresh unreviewed commit(s) "
1123 f"({_reflog_shas[0][:12]}...)"
1124 )
1125 else:
1126 # Fresh commit(s) in reflog but all already in
1127 # sg-reviewed-shas — likely a Bash retry or the commit was
1128 # reviewed via a prior fire. Correct to skip; sub=2 lets telemetry
1129 # split this from genuine fails.
1130 _skip_21_sub = 2
1131 elif _stale:
1132 _skip_21_sub = 3 # commit entries exist but all >120s old
1133 else:
1134 _skip_21_sub = 4 # no commit-action entries — genuine fail
1135
1136 if not commit_succeeded:
1137 debug_log("Commit review: commit did not succeed, skipping")
1138 emit_metrics({"skipped": True, "skip_reason": 21, **_base,
1139 **({"skip_21_sub": 1} if interrupted
1140 else {"skip_21_sub": _skip_21_sub} if _skip_21_sub
1141 else {})})
1142 sys.exit(0)
1143
1144 if not COMMIT_REVIEW_ENABLED:
1145 debug_log("Commit review: disabled, skipping")
1146 emit_metrics({"skipped": True, "skip_reason": 32, **_base})
1147 sys.exit(0)
1148
1149 if not ENABLE_CODE_SECURITY_REVIEW or not HAS_API_CREDENTIALS:
1150 debug_log("Commit review: LLM review disabled or no API credentials")
1151 emit_metrics({"skipped": True, "skip_reason": 22, **_base})
1152 sys.exit(0)
1153
1154 if not ensure_anthropic_reachable():
1155 debug_log("Commit review: api.anthropic.com unreachable")
1156 emit_metrics({"skipped": True, "skip_reason": 24, **_base})
1157 sys.exit(0)
1158
1159 if not cwd:
1160 debug_log("Commit review: no cwd")
1161 emit_metrics({"skipped": True, "skip_reason": 25, **_base})
1162 sys.exit(0)
1163
1164 if not repo_root and all_shas and not _reflog_shas:
1165 repo_root = repo_containing_commit(all_shas[-1], scan_roots(cwd))
1166 if repo_root:
1167 repo_res = RES_SHA_SCAN
1168 if not repo_root:
1169 repo_root = load_repo_hint(session_id)
1170 if repo_root:
1171 repo_res = RES_HINT
1172 if repo_res != RES_CWD:
1173 _base["repo_resolution"] = repo_res
1174 if not repo_root:
1175 debug_log("Commit review: not in a git repo")
1176 emit_metrics({"skipped": True, "skip_reason": 26, **_base})
1177 sys.exit(0)
1178 if repo_res != RES_CWD:
1179 debug_log(f"Commit review: repo resolved via {repo_res} -> {repo_root!r}")
1180 if repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
1181 save_repo_hint(session_id, repo_root)
1182
1183 # Pin the review to the exact SHA the Bash command produced, parsed from
1184 # its stdout. Reviewing HEAD instead is wrong when the commit was made in
1185 # a different repo than the hook's cwd (`cd ../other && git commit && cd -`,
1186 # subshells), or when a second commit lands before this async hook reaches
1187 # `git show` — both would review an unrelated commit. The reflog-action
1188 # fallback above is the narrow exception: it only fires when output gave
1189 # us nothing AND the cwd repo's own reflog confirms a `commit:` just
1190 # happened there, which rules out the cross-repo case.
1191 #
1192 # Take only the LAST match: pre-commit/husky hooks can print bracketed
1193 # labels like `[pre-commit abc1234]` that precede the real `[branch sha]`
1194 # line; chained commands like `git commit && git commit` produce multiple
1195 # real SHAs and we want the most recent. The real commit line is always
1196 # last in git's own output — the earlier matches are either decoys or
1197 # superseded commits.
1198 if _reflog_shas:
1199 # Output-based detection already failed above; the reflog SHAs are the
1200 # authoritative ones. Don't re-parse bash_output here — any bracketed
1201 # token it contains is by construction NOT the `[branch sha]` line
1202 # (or commit_succeeded would have been True via the fast path). The
1203 # list is newest-first and may contain >1 entry when a single Bash
1204 # call made multiple commits (`git commit -m a && git commit -m b`);
1205 # all are reviewed.
1206 shas = _reflog_shas
1207 else:
1208 shas = [all_shas[-1]] if all_shas else []
1209 if not shas:
1210 debug_log("Commit review: no SHA in commit output")
1211 emit_metrics({"skipped": True, "skip_reason": 33, **_base})
1212 sys.exit(0)
1213 if _reflog_shas:
1214 # Observability: track how often the fallback path is hit so
1215 # future analysis can split on it.
1216 # `reflog_shas_n` lets telemetry measure how often the widened scan picked
1217 # up >1 commit (i.e., chained `git commit && git commit`).
1218 _base = {**_base, "sha_via_reflog": True,
1219 "reflog_shas_n": len(_reflog_shas)}
1220
1221 # `git commit --amend`: review only the delta added by the amend
1222 # (pre-amend..post-amend) instead of the full amended commit. Without this,
1223 # the amend re-reviews the entire commit including code already reviewed
1224 # on the original commit, costing 30-60s of LLM time and re-flagging
1225 # findings the user may have just amended IN ORDER TO fix. Pre-amend
1226 # SHA comes from the reflog and is validated to be an amend (see
1227 # _resolve_amend_pre_sha) — otherwise we fall back to full-commit review.
1228 #
1229 # Three guards skip the delta path and fall back to full `git show`
1230 # review. All three close variants of "chained `git commit && git commit
1231 # --amend` in one Bash call", which would otherwise enter the delta path,
1232 # see an empty `git diff sha_wip sha_amend`, emit skip_reason=35, and
1233 # silently drop the first commit's content from review (no prior
1234 # PostToolUse fired for it — same Bash call):
1235 #
1236 # 1. `not _reflog_shas`: reflog fallback path was taken (both commits'
1237 # bash output suppressed via -q / pipe / redirect). The multi-SHA scan
1238 # already populates `shas` with every fresh commit (amend + any
1239 # pre-amend WIP) and the loop below `git show`s each, so coverage is
1240 # correct without delta — and the delta path doesn't compose with a
1241 # multi-SHA `shas` list (it would diff every entry against the same
1242 # pre-amend SHA). Losing the 30-60s saving on the reflog-fallback
1243 # fraction is an acceptable trade.
1244 #
1245 # 2. `len(all_shas) <= 1`: both commits visible (no -q). Two `[branch
1246 # sha]` lines in bash_output → all_shas len 2. Only defined on the
1247 # bash-output path; short-circuit ordering keeps it unevaluated when
1248 # `_reflog_shas` is non-empty.
1249 #
1250 # 3. `commit_invocations <= 1`: asymmetric — first commit -q, amend
1251 # visible. Fast-path fires on the amend's `[branch sha]` line (so
1252 # `_reflog_shas` stays empty), all_shas = [sha_amend] (len 1) — guards
1253 # 1 and 2 both pass. The command string itself is the only remaining
1254 # signal that two commits happened. False-positives (e.g.
1255 # `git commit --amend -m "fix git commit bug"`) are safe — they fall
1256 # back to full review.
1257 is_amend = bool(_GIT_AMEND_RE.search(command))
1258 commit_invocations = len(_GIT_COMMIT_RE.findall(command))
1259 pre_amend_sha = None
1260 if (is_amend and not _reflog_shas and len(all_shas) <= 1
1261 and commit_invocations <= 1):
1262 pre_amend_sha = _resolve_amend_pre_sha(repo_root, expected_post_sha=shas[0])
1263 if is_amend and pre_amend_sha:
1264 _base = {**_base, "amend_delta_review": True}
1265 debug_log(
1266 f"Commit review: --amend detected; reviewing delta "
1267 f"{pre_amend_sha[:12]}..{shas[-1][:12]}"
1268 )
1269
1270 # --no-color: `color.ui=always` would emit ANSI escapes that corrupt
1271 # parse_diff_into_files' header match. Bytes + errors='replace': commits
1272 # can contain non-UTF8 source (latin-1, cp1252) and text=True would raise
1273 # UnicodeDecodeError outside the except clause.
1274 diff_files = []
1275 resolved = 0
1276 for sha in shas:
1277 try:
1278 # core.quotePath=false: emit raw UTF-8 in `diff --git a/... b/...`
1279 # headers so non-ASCII paths aren't C-quoted past the downstream
1280 # parse_diff_into_files regex (sibling of #2056 / #2075). See #2082.
1281 # core.quotePath=false comes from GIT_CMD globally (see gitutil.py).
1282 if pre_amend_sha:
1283 # Delta review: pre-amend → post-amend. `git diff` (not show)
1284 # so the output is a pure unified diff with no commit header.
1285 result = subprocess.run(
1286 [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv",
1287 pre_amend_sha, sha, "--"],
1288 cwd=repo_root, capture_output=True, timeout=15
1289 )
1290 else:
1291 result = subprocess.run(
1292 [*GIT_CMD, "show", "-p", "--no-color", "--no-ext-diff", "--no-textconv", sha, "--"],
1293 cwd=repo_root, capture_output=True, timeout=15
1294 )
1295 except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
1296 _cmd = "git diff" if pre_amend_sha else "git show"
1297 debug_log(f"Commit review: {_cmd} {sha} error: {e}")
1298 continue
1299 if result.returncode != 0:
1300 # SHA not in this repo (cross-repo commit) or already gc'd. Better
1301 # to skip than to fall back to HEAD and review the wrong commit.
1302 _cmd = "git diff" if pre_amend_sha else "git show"
1303 debug_log(f"Commit review: {_cmd} {sha} rc={result.returncode}")
1304 continue
1305 resolved += 1
1306 diff_files.extend(parse_diff_into_files(
1307 result.stdout.decode("utf-8", errors="replace")))
1308
1309 # Dedup by path. The widened reflog scan can return >1 SHA (e.g.
1310 # `git commit && git commit --amend` within 120s); a path that appears in
1311 # both diffs would consume two MAX_DIFF_FILES slots and be re-analyzed.
1312 # `shas` is newest-first so the first occurrence is the most recent
1313 # version of the file — keep it.
1314 if len(shas) > 1:
1315 _seen = set()
1316 diff_files = [
1317 (fp, c) for fp, c in diff_files
1318 if not (fp in _seen or _seen.add(fp))
1319 ]
1320
1321 if resolved == 0:
1322 debug_log("Commit review: no parsed SHA resolved in cwd repo")
1323 emit_metrics({"skipped": True, "skip_reason": 28, **_base,
1324 "shas_found": len(shas)})
1325 sys.exit(0)
1326
1327 # Empty amend delta = message-only amend (or whitespace-only that the
1328 # diff already collapses). No code to review; skip cleanly. skip_reason=35.
1329 # Gated on resolved > 0 so subprocess failures (caught with `continue`
1330 # above) don't get mislabeled as message-only — they fall through to
1331 # skip_reason=28 correctly.
1332 if pre_amend_sha and not diff_files:
1333 debug_log("Commit review: --amend produced empty delta (message-only?), skipping")
1334 emit_metrics({"skipped": True, "skip_reason": 35, **_base,
1335 "files_reviewed": 0})
1336 sys.exit(0)
1337
1338 debug_log(f"Commit review: {resolved}/{len(shas)} sha(s) resolved, "
1339 f"{len(diff_files)} files")
1340 if not diff_files:
1341 debug_log("Commit review: no reviewable source files in commit")
1342 emit_metrics({"skipped": True, "skip_reason": 30, **_base})
1343 sys.exit(0)
1344
1345 # Large commits (initial scaffolds, big refactors) used to bail here with
1346 # skip_reason=31. Large multi-file changes are exactly where
1347 # cross-file source→sink vulns hide. Reviewing nothing is
1348 # worse than reviewing the riskiest 30 — _cap_files_for_prompt already
1349 # bounds total bytes downstream so this can't blow context.
1350 # `diff_files_dropped` lets telemetry measure how often the prioritizer engages
1351 # and how much it drops; skip_reason=31 is now reserved for the truly
1352 # pathological case (e.g. >300 source files — almost certainly a bad
1353 # baseline, not a real commit).
1354 if len(diff_files) > 10 * MAX_DIFF_FILES:
1355 debug_log(f"Commit review: pathological diff ({len(diff_files)} files), skipping")
1356 emit_metrics({"skipped": True, "skip_reason": 31, **_base,
1357 "diff_files_count": len(diff_files)})
1358 sys.exit(0)
1359 diff_files, _dropped = _prioritize_diff_files(diff_files, MAX_DIFF_FILES)
1360 if _dropped:
1361 debug_log(f"Commit review: prioritized to {len(diff_files)} files "
1362 f"(dropped {_dropped} lower-risk)")
1363 _base = {**_base, "diff_files_dropped": _dropped}
1364
1365 # Rolling-hour rate limit on LLM spend, so only burn a slot once we know
1366 # we'll actually call analyze_code_security — skip 28/30/31/33 above are
1367 # free. `rate_count` is emitted on every fire (not just rejections) so
1368 # telemetry can show how close to the cap sessions run.
1369 _allowed, _rate_n = atomic_check_rate_limit(
1370 session_id, "CommitReview",
1371 MAX_COMMIT_REVIEWS_PER_HOUR, COMMIT_REVIEW_RATE_WINDOW_S)
1372 _base = {**_base, "rate_count": _rate_n}
1373 if not _allowed:
1374 debug_log("Commit review: hourly rate limit reached, skipping")
1375 emit_metrics({"skipped": True, "skip_reason": 23, **_base})
1376 sys.exit(0)
1377
1378 # Read previous_findings for dedup (shared with Stop hook)
1379 import time as _time
1380 now = _time.time()
1381
1382 def _read_previous(state):
1383 findings_ts = state.get("previous_findings_ts", 0)
1384 if (now - findings_ts) > PREVIOUS_FINDINGS_TTL_SEC:
1385 return []
1386 return list(state.get("previous_findings", []))
1387
1388 previous_findings = with_locked_state(session_id, _read_previous) or []
1389
1390 review_start = _time.time()
1391
1392 agentic_metrics: Dict[str, Any] = {}
1393 if _agentic_commit_review_enabled():
1394 rel_touched = [fp for fp, _ in diff_files]
1395 concrete_guidance, vulns, _am = _agentic_review_with_race(
1396 repo_root, diff_files, rel_touched, previous_findings
1397 )
1398 agentic_metrics.update(_am)
1399 # Fall back to single-shot only on agentic FAILURE (SDK/investigate
1400 # crash). If agentic completed and returned 0 findings, trust that.
1401 if agentic_metrics.get("agentic_fallback"):
1402 concrete_guidance, vulns = analyze_code_security(
1403 diff_files, is_diff=True, previous_findings=previous_findings
1404 )
1405 else:
1406 concrete_guidance, vulns = analyze_code_security(
1407 diff_files, is_diff=True, previous_findings=previous_findings
1408 )
1409
1410 # push-sweep state: record this commit as reviewed (full 40-hex sha) so a
1411 # later `git push` can advance its diff base past it. Recorded here — after
1412 # the review ran but before any exit path — so it's marked regardless of
1413 # whether findings were emitted. `shas` holds abbreviated refs from
1414 # `[branch sha]`; resolve to full so set-membership in the push-sweep is
1415 # exact. Best-effort; failures here never block the review result.
1416 try:
1417 full_shas = []
1418 for s in shas:
1419 # See #2099: drop text=True; decode manually for cp1252 safety.
1420 r = subprocess.run(
1421 [*GIT_CMD, "rev-parse", "--verify", "-q", s],
1422 cwd=repo_root, capture_output=True, timeout=5,
1423 )
1424 if r.returncode == 0:
1425 full_shas.append(r.stdout.decode("utf-8", errors="replace").strip())
1426 _append_reviewed_shas(repo_root, full_shas, vulns_found=len(vulns or []))
1427 except Exception:
1428 pass
1429
1430 review_ms = int((_time.time() - review_start) * 1000)
1431 # `survived` is the raw self-refute count BEFORE the high/critical-only
1432 # severity filter; `survived_after_sev` is the count the user actually
1433 # sees. Include `survived_after_sev` ONLY when the filter actually
1434 # dropped candidates — otherwise it's redundant with `survived` and eats
1435 # into CC's 10-key emit cap, pushing files_reviewed/review_ms out of the
1436 # emitted metrics.
1437 #
1438 # CC accepts only booleans and finite numbers as metric values.
1439 # A null or string value makes CC discard the ENTIRE dict, so:
1440 # - candidates/survived are omitted when None (early-return at
1441 # candidates==0, or any fallback path)
1442 # - agentic_fallback is mapped to an int reason code; the string detail
1443 # stays in debug_log for diagnosis
1444 _sev_raw = agentic_metrics.get("survived")
1445 _sev_post = agentic_metrics.get("survived_after_sev")
1446 _cand = agentic_metrics.get("candidates")
1447 _fb = agentic_metrics.get("agentic_fallback")
1448 # 1 = SDK import failed (claude_agent_sdk not installed)
1449 # 2 = investigate stage failed (CLI/network/model error or schema-retry exhausted)
1450 _fb_code = (1 if _fb and _fb.startswith("import:") else 2) if _fb else None
1451 _race = agentic_metrics.get("race_winner")
1452 _agentic_m = (
1453 # `agentic` = which path produced the result, not which was attempted.
1454 # On race-loss the _fallback() metrics dict has agentic=False — emitting
1455 # True there blends the high-find-rate single-shot race-loss bucket into
1456 # `agentic=true` queries and overstates agentic yield.
1457 {"agentic": bool(agentic_metrics.get("agentic")),
1458 **({"candidates": _cand} if _cand is not None else {}),
1459 **({"survived": _sev_raw} if _sev_raw is not None else {}),
1460 **({"survived_after_sev": _sev_post}
1461 if _sev_post is not None and _sev_post != _sev_raw else {}),
1462 **({"agentic_fallback": _fb_code} if _fb_code is not None else {}),
1463 # 1 = agentic won, 2 = single-shot fallback won. review_ms already
1464 # captures timing; race_winner lets telemetry segment recall by which path
1465 # actually produced the result.
1466 **({"race_winner": _race} if _race is not None else {})}
1467 if agentic_metrics.get("agentic") or _fb or _race is not None
1468 else {}
1469 )
1470
1471 if not concrete_guidance:
1472 debug_log("Commit review: no security issues found")
1473 emit_metrics({
1474 "vulns_found": 0, **_base, **_agentic_m,
1475 "files_reviewed": len(diff_files), "review_ms": review_ms,
1476 **({
1477 "api_error": llm._last_call_claude_http_error
1478 } if llm._last_call_claude_http_error is not None else {}),
1479 })
1480 sys.exit(0)
1481
1482 # Late dedup: drop only what a concurrent Stop hook wrote while our LLM
1483 # ran. Anything in `previous_findings` (the pre-LLM snapshot) that the
1484 # LLM chose to re-flag is an intentional "fix incomplete" verdict.
1485 new_vulns, n_deduped = _dedup_against_state(
1486 session_id, vulns, prompted=_finding_keys(previous_findings)
1487 )
1488
1489 if not new_vulns:
1490 debug_log("Commit review: all findings already known, skipping")
1491 emit_metrics({
1492 "vulns_found": 0, **_base, **_agentic_m, "deduped": n_deduped,
1493 "files_reviewed": len(diff_files), "review_ms": review_ms,
1494 })
1495 sys.exit(0)
1496
1497 # Record new findings into shared state. Key on (filePath, category) —
1498 # vulnerableCode bytes drift between fires (diff context lines shift) so
1499 # matching on it under-dedupes; this aligns with Stop's _record_fire.
1500 finding_snapshots = [
1501 {
1502 "filePath": v.get("filePath", ""),
1503 "category": v.get("category", "Unknown"),
1504 "vulnerableCode": v.get("vulnerableCode", ""),
1505 }
1506 for v in new_vulns
1507 ]
1508
1509 def _record_findings(state):
1510 existing = [f for f in state.get("previous_findings", []) if isinstance(f, dict)]
1511 seen = {(f.get("filePath", ""), f.get("category", "")) for f in existing}
1512 for f in finding_snapshots:
1513 key = (f["filePath"], f["category"])
1514 if key not in seen:
1515 seen.add(key)
1516 existing.append(f)
1517 state["previous_findings"] = existing
1518 state["previous_findings_ts"] = _time.time()
1519 with_locked_state(session_id, _record_findings)
1520
1521 sev = {"critical": 0, "high": 0, "medium": 0}
1522 for v in new_vulns:
1523 s = v.get("severity", "medium")
1524 if s in sev:
1525 sev[s] += 1
1526
1527 # Rebuild guidance from new_vulns only — concrete_guidance from the LLM
1528 # still lists deduped entries. Pass via additional_context so CC surfaces
1529 # the reason via hookSpecificOutput.additionalContext instead of empty
1530 # stdout (#1783) / stderr-only "json output validation failed" (#1375).
1531 _commit_guidance = (PROVENANCE_BANNER + "\n\n"
1532 + _format_vulns_guidance(new_vulns)
1533 + CONTINUATION_SUFFIX + "\n")
1534 emit_metrics({
1535 "vulns_found": len(new_vulns), **_base, **_agentic_m,
1536 "critical_count": sev["critical"], "high_count": sev["high"],
1537 "files_reviewed": len(diff_files), "review_ms": review_ms,
1538 **({"deduped": n_deduped} if n_deduped else {}),
1539 }, rewake_summary=_format_vulns_summary(new_vulns, prefix="Commit security review found"),
1540 additional_context=_commit_guidance,
1541 hook_event_name="PostToolUse")
1542
1543 # exit(2) is preserved per the asyncRewake protocol — it's what CC
1544 # uses as the "force fix" signal that triggers the rewakeMessage flow.
1545 # The stderr.write was removed; additional_context above now carries
1546 # the same text via the modern JSON channel. See #1358/#1375/#1783.
1547 sys.exit(2)
1548
1549def handle_push_sweep_posttooluse(input_data):
1550 """Review the just-pushed range as one diff, advancing the base past the
1551 contiguous prefix of already-per-commit-reviewed shas.
1552
1553 Spec: review `git diff B..HEAD` where `B` is the newest commit such that
1554 `prev_upstream..B` is entirely in `.git/sg-reviewed-shas`. Skip if
1555 `B == HEAD`. Mark `B..HEAD` reviewed afterward.
1556
1557 Diff and Read are both at HEAD (push doesn't move the working tree), so the
1558 agentic reviewer sees a consistent view — a vuln introduced in commit A and
1559 removed in commit B is absent from the net diff by construction. Any
1560 reviewed commits in the tail (after the first unreviewed one) are included
1561 in the diff; their findings are dropped by `_dedup_against_state` against
1562 `previous_findings` the per-commit hook already recorded.
1563
1564 Metrics: `push_sweep: True` is the telemetry splitter; `pushed`/`unreviewed`/
1565 `prefix_advanced` give the funnel; skip_reasons 40-49 are reserved for
1566 this surface.
1567 """
1568 tool_input = input_data.get("tool_input", {}) or {}
1569 tool_response = input_data.get("tool_response", {}) or {}
1570 command = tool_input.get("command", "") or ""
1571 cwd = input_data.get("cwd")
1572 session_id = input_data.get("session_id", "")
1573 bash_output = (
1574 (tool_response.get("stdout", "") or "")
1575 + "\n"
1576 + (tool_response.get("stderr", "") or "")
1577 )
1578 interrupted = tool_response.get("interrupted", False)
1579
1580 # Re-gate: hooks.json `if` matched, but confirm with the broader regex
1581 # (defensive — `git -C`/`-c` forms won't reach here via the hooks.json
1582 # prefix matcher alone, but a compound with a plain `git push` would).
1583 if not _GIT_PUSH_RE.search(command):
1584 sys.exit(0)
1585
1586 _base = {"push_sweep": True, "push_sweep_on": PUSH_SWEEP_ENABLED}
1587
1588 if not PUSH_SWEEP_ENABLED:
1589 emit_metrics({"skipped": True, "skip_reason": 40, **_base})
1590 sys.exit(0)
1591 if interrupted:
1592 emit_metrics({"skipped": True, "skip_reason": 21, **_base})
1593 sys.exit(0)
1594 if not ENABLE_CODE_SECURITY_REVIEW or not HAS_API_CREDENTIALS:
1595 emit_metrics({"skipped": True, "skip_reason": 22, **_base})
1596 sys.exit(0)
1597 if not cwd:
1598 emit_metrics({"skipped": True, "skip_reason": 25, **_base})
1599 sys.exit(0)
1600 _tip_m = _PUSH_RANGE_RE.search(_push_section(bash_output))
1601 cwd_root = _git_toplevel(cwd)
1602 repo_root, repo_res = resolve_repo_root(
1603 cwd, command, PUSH_SUBCOMMANDS,
1604 sha=_tip_m.group(2) if _tip_m else None,
1605 session_id=session_id, cwd_root=cwd_root,
1606 )
1607 if not cwd_root:
1608 _base["cwd_is_repo"] = False
1609 if repo_res != RES_CWD:
1610 _base["repo_resolution"] = repo_res
1611 if not repo_root:
1612 emit_metrics({"skipped": True, "skip_reason": 26, **_base})
1613 sys.exit(0)
1614 if repo_res != RES_CWD:
1615 debug_log(f"Push sweep: repo resolved via {repo_res} -> {repo_root!r}")
1616 if repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
1617 save_repo_hint(session_id, repo_root)
1618
1619 # Guard: the sweep diffs `base..HEAD` and the agent Reads the working
1620 # tree, so the pushed ref MUST be HEAD or the review is of the wrong
1621 # range. `git push origin other` while checked out elsewhere, or a
1622 # multi-ref push, are skipped (skip_reason 44). Check the new-tip from
1623 # the `abc..def local -> remote` line against HEAD.
1624 #
1625 # Scope range-line detection to the push section of bash_output: a chained
1626 # `git fetch && git push` produces fetch range lines that the regex would
1627 # otherwise match too, false-tripping multi-ref. `_push_section` slices
1628 # forward from the last `To <remote>` header.
1629 #
1630 # If there are no range lines, we MUST also see a positive push-success
1631 # signal (`* [new branch]` or `Everything up-to-date`) AND verify the
1632 # pushed local ref resolves to HEAD before falling through to the
1633 # @{u}@{1}/merge-base detection. Without this, two real cases misdirect
1634 # the sweep: `git push origin feature2` while on `feature1` (no range
1635 # line, no HEAD check → reviews wrong branch and poisons reviewed-shas),
1636 # and rejected pushes (no range line, no `interrupted` signal → reviews
1637 # unpushed local commits and marks them reviewed). skip_reason=46 covers
1638 # both.
1639 head = None
1640 try:
1641 # See #2099: drop text=True; decode manually for cp1252 safety.
1642 r = subprocess.run([*GIT_CMD, "rev-parse", "HEAD"], cwd=repo_root,
1643 capture_output=True, timeout=5)
1644 head = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else None
1645 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1646 pass
1647 push_section = _push_section(bash_output or "")
1648 range_matches = list(_PUSH_RANGE_RE.finditer(push_section))
1649 if range_matches and head:
1650 # Multi-ref push (multiple range lines) or pushed-tip ≠ HEAD → skip.
1651 if len(range_matches) > 1:
1652 emit_metrics({"skipped": True, "skip_reason": 44, **_base})
1653 sys.exit(0)
1654 new_tip = range_matches[0].group(2)
1655 if not head.startswith(new_tip):
1656 debug_log(f"Push sweep: pushed tip {new_tip} != HEAD {head[:12]}")
1657 emit_metrics({"skipped": True, "skip_reason": 44, **_base})
1658 sys.exit(0)
1659 elif head:
1660 # No range lines. Need a positive push-success signal — otherwise
1661 # the push may have failed and we'd review unpushed local commits.
1662 new_branch_matches = re.findall(
1663 r"^\s*\*\s+\[new branch\]\s+(\S+)\s+->\s+\S+",
1664 push_section, re.M)
1665 up_to_date = "Everything up-to-date" in push_section
1666 # `git push -q` suppresses all output on success. Distinguish quiet-
1667 # success from a failed push (which has error text) by checking the
1668 # upstream's reflog: a successful push leaves @{u}@{1} (the prior
1669 # value) different from @{u} (now equal to HEAD). A rejected push
1670 # would not advance @{u}, so this signal is push-specific.
1671 quiet_success = False
1672 if not (bash_output or "").strip() and not interrupted:
1673 try:
1674 # See #2099: drop text=True; decode manually for cp1252 safety.
1675 r_cur = subprocess.run(
1676 [*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}"],
1677 cwd=repo_root, capture_output=True, timeout=5)
1678 r_prev = subprocess.run(
1679 [*GIT_CMD, "rev-parse", "--verify", "-q", "@{u}@{1}"],
1680 cwd=repo_root, capture_output=True, timeout=5)
1681 cur = r_cur.stdout.decode("utf-8", errors="replace").strip() if r_cur.returncode == 0 else ""
1682 prev_u = r_prev.stdout.decode("utf-8", errors="replace").strip() if r_prev.returncode == 0 else ""
1683 quiet_success = bool(cur and prev_u and cur == head and prev_u != cur)
1684 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1685 pass
1686 if not (new_branch_matches or up_to_date or quiet_success):
1687 debug_log("Push sweep: no push-success signal in bash output")
1688 emit_metrics({"skipped": True, "skip_reason": 46, **_base})
1689 sys.exit(0)
1690 # `* [new branch] local -> remote`: verify the pushed local ref
1691 # resolves to HEAD. `git push origin feature2` while on feature1
1692 # would otherwise review feature1's commits and poison its
1693 # reviewed-shas state.
1694 for local_ref in new_branch_matches:
1695 try:
1696 # See #2099: drop text=True; decode manually for cp1252 safety.
1697 r = subprocess.run(
1698 [*GIT_CMD, "rev-parse", "--verify", "-q", local_ref],
1699 cwd=repo_root, capture_output=True, timeout=5,
1700 )
1701 local_sha = r.stdout.decode("utf-8", errors="replace").strip() if r.returncode == 0 else ""
1702 except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
1703 local_sha = ""
1704 if local_sha and local_sha != head:
1705 debug_log(f"Push sweep: new-branch {local_ref} ({local_sha[:12]}) != HEAD {head[:12]}")
1706 emit_metrics({"skipped": True, "skip_reason": 44, **_base})
1707 sys.exit(0)
1708
1709 prev_upstream = _detect_prev_upstream(repo_root, bash_output)
1710 if not prev_upstream:
1711 debug_log("Push sweep: could not determine prev_upstream")
1712 emit_metrics({"skipped": True, "skip_reason": 41, **_base})
1713 sys.exit(0)
1714
1715 push_range = _git_rev_list_range(repo_root, prev_upstream, "HEAD")
1716 if not push_range:
1717 emit_metrics({"skipped": True, "skip_reason": 42, **_base, "pushed": 0})
1718 sys.exit(0)
1719 if len(push_range) > MAX_PUSH_SWEEP_RANGE:
1720 # Huge first-push of a long-lived branch — Stop hook is the backstop.
1721 emit_metrics({"skipped": True, "skip_reason": 43, **_base,
1722 "pushed": len(push_range)})
1723 sys.exit(0)
1724
1725 reviewed = _load_reviewed_shas(repo_root)
1726 base, tail = _compute_push_sweep_base(prev_upstream, push_range, reviewed)
1727 prefix_advanced = len(push_range) - len(tail)
1728 if base is None:
1729 debug_log("Push sweep: every pushed commit already reviewed")
1730 emit_metrics({**_base, "pushed": len(push_range), "unreviewed": 0,
1731 "prefix_advanced": prefix_advanced})
1732 sys.exit(0)
1733
1734 debug_log(f"Push sweep: range={len(push_range)} prefix_advanced="
1735 f"{prefix_advanced} base={base[:12]} tail={len(tail)}")
1736
1737 diff_text = _git_diff_range(repo_root, base, "HEAD")
1738 if diff_text is None:
1739 # Diff failed (non-zero exit / 30s timeout / git missing). Do NOT
1740 # mark `tail` reviewed — we did not actually review it. Marking
1741 # them would silently advance the prefix past unreviewed commits
1742 # forever (the whole point of push-sweep is to catch outside-CC
1743 # commits, and a 50-commit range over large files can hit the
1744 # 30s timeout). skip_reason=45 lets a retry / smaller subsequent
1745 # push still cover them, mirroring how skip_reason=31 handles
1746 # too-many-files without recording the tail.
1747 emit_metrics({**_base, "pushed": len(push_range),
1748 "unreviewed": len(tail), "skip_reason": 45})
1749 sys.exit(0)
1750 diff_files = parse_diff_into_files(diff_text)
1751 if not diff_files:
1752 emit_metrics({**_base, "pushed": len(push_range),
1753 "unreviewed": len(tail), "skip_reason": 30})
1754 # Still mark tail reviewed — there's nothing to review.
1755 _append_reviewed_shas(repo_root, tail, vulns_found=0)
1756 sys.exit(0)
1757 # Same prioritize-don't-bail logic as commit-review (see comment there).
1758 # push-sweep ranges are net diffs over many commits so they hit the cap
1759 # more often; reviewing the riskiest MAX_PUSH_SWEEP_FILES is strictly
1760 # better than reviewing none. We still mark `tail` reviewed afterward —
1761 # the dropped files are by construction the low-risk ones (config, .gen,
1762 # tests, migrations), and NOT advancing the base would make the next
1763 # push re-hit the same overflow with an even larger range. Per-commit
1764 # review remains the primary surface for those files. The 10×
1765 # pathological guard stays so a 500-file vendored-dir push doesn't burn
1766 # a counter slot.
1767 if len(diff_files) > 10 * MAX_PUSH_SWEEP_FILES:
1768 emit_metrics({**_base, "pushed": len(push_range),
1769 "unreviewed": len(tail), "skip_reason": 31,
1770 "diff_files_count": len(diff_files)})
1771 sys.exit(0)
1772 diff_files, _dropped = _prioritize_diff_files(diff_files, MAX_PUSH_SWEEP_FILES)
1773 if _dropped:
1774 _base = {**_base, "diff_files_dropped": _dropped}
1775
1776 _allowed, _rate_n = atomic_check_rate_limit(
1777 session_id, "PushSweep",
1778 MAX_COMMIT_REVIEWS_PER_HOUR, COMMIT_REVIEW_RATE_WINDOW_S)
1779 _base = {**_base, "rate_count": _rate_n}
1780 if not _allowed:
1781 emit_metrics({"skipped": True, "skip_reason": 23, **_base})
1782 sys.exit(0)
1783
1784 import time as _time
1785 now = _time.time()
1786 previous_findings = with_locked_state(
1787 session_id,
1788 lambda s: list(s.get("previous_findings", []))
1789 if (now - s.get("previous_findings_ts", 0)) <= PREVIOUS_FINDINGS_TTL_SEC
1790 else []
1791 ) or []
1792
1793 review_start = _time.time()
1794 rel_touched = [fp for fp, _ in diff_files]
1795 if _agentic_commit_review_enabled():
1796 concrete_guidance, vulns, agentic_metrics = _agentic_review_with_race(
1797 repo_root, diff_files, rel_touched, previous_findings
1798 )
1799 if agentic_metrics.get("agentic_fallback"):
1800 concrete_guidance, vulns = analyze_code_security(
1801 diff_files, is_diff=True, previous_findings=previous_findings
1802 )
1803 else:
1804 concrete_guidance, vulns = analyze_code_security(
1805 diff_files, is_diff=True, previous_findings=previous_findings
1806 )
1807 agentic_metrics = {}
1808 review_ms = int((_time.time() - review_start) * 1000)
1809
1810 # The tail is now covered by this net-diff review.
1811 _append_reviewed_shas(repo_root, tail, vulns_found=len(vulns or []))
1812
1813 new_vulns, n_deduped = _dedup_against_state(
1814 session_id, vulns or [], prompted=_finding_keys(previous_findings)
1815 )
1816
1817 # Metrics — keep within the 10-key cap; agentic sub-metrics are dropped
1818 # here in favour of the push-sweep funnel keys (telemetry can join on session_id
1819 # to the per-commit fires for agentic detail). rewake_summary must ride
1820 # this line (CC reads only the first {-prefixed stdout line); the emit
1821 # is deferred to the two exit points below so the with-vulns path can
1822 # also pass additional_context in the same JSON line (#1375/#1783) —
1823 # the by-design "CC keeps only the first JSON line" constraint means
1824 # we can't emit twice. Builds the shared metrics dict here; vulns path
1825 # adds additional_context, no-vulns path emits as-is.
1826 _push_metrics = {
1827 **_base, "pushed": len(push_range), "unreviewed": len(tail),
1828 "prefix_advanced": prefix_advanced, "vulns_found": len(new_vulns),
1829 "files_reviewed": len(diff_files), "review_ms": review_ms,
1830 **({"deduped": n_deduped} if n_deduped else {}),
1831 }
1832 _push_rewake_summary = _format_vulns_summary(new_vulns, prefix="Push security review found")
1833
1834 if not new_vulns:
1835 debug_log("Push sweep: no new findings")
1836 emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary)
1837 sys.exit(0)
1838
1839 # First-push of a big branch can surface many findings at once across
1840 # week-old code. Report only the top-N by severity so the asyncRewake
1841 # isn't a wall of text; the rest go to telemetry (vulns_found is the
1842 # full count) and into previous_findings so Stop / next commit-review
1843 # don't re-flag them. Stable sort: severity, then category for
1844 # determinism in tests.
1845 _sev_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
1846 new_vulns.sort(key=lambda v: (_sev_rank.get(v.get("severity", "medium"), 2),
1847 v.get("category", "")))
1848 reported = new_vulns[:PUSH_SWEEP_REPORT_CAP]
1849 n_suppressed = len(new_vulns) - len(reported)
1850
1851 # Record only the REPORTED findings into shared state. previous_findings
1852 # means "the user was told about this — don't repeat it"; suppressed
1853 # findings were NOT told, so recording them would silently bury them
1854 # against any future commit-review/Stop that touches the same code. The
1855 # range is marked reviewed in `.git/sg-reviewed-shas` regardless, so the
1856 # push-sweep itself won't re-find them; leaving them out of
1857 # previous_findings keeps the door open for the per-commit hook to
1858 # surface them later if the code is touched again.
1859 snapshots = [
1860 {"filePath": v.get("filePath", ""),
1861 "category": v.get("category", "Unknown"),
1862 "vulnerableCode": v.get("vulnerableCode", "")}
1863 for v in reported
1864 ]
1865 def _record(state):
1866 existing = [f for f in state.get("previous_findings", [])
1867 if isinstance(f, dict)]
1868 seen = {(f.get("filePath", ""), f.get("category", "")) for f in existing}
1869 for f in snapshots:
1870 k = (f["filePath"], f["category"])
1871 if k not in seen:
1872 seen.add(k); existing.append(f)
1873 state["previous_findings"] = existing
1874 state["previous_findings_ts"] = _time.time()
1875 with_locked_state(session_id, _record)
1876
1877 # Prefer the LLM's formatted guidance (richer context, fix suggestions)
1878 # when NOTHING was dropped from the LLM's full vuln list; fall back to
1879 # re-formatting from `reported` whenever either the cap suppressed
1880 # findings OR `_dedup_against_state` dropped findings the user has
1881 # already been shown. concrete_guidance is built against the LLM's
1882 # full pre-dedup list, so leaking it past dedup re-surfaces findings
1883 # the per-commit hook already reported (the [✓1, ✗2, ✓3] case where
1884 # the tail reviewed commits' findings are in previous_findings).
1885 if n_suppressed or n_deduped:
1886 guidance = _format_vulns_guidance(reported) or ""
1887 else:
1888 guidance = concrete_guidance or _format_vulns_guidance(reported) or ""
1889 # Emit metrics + additional_context together — single JSON line is the
1890 # contract CC's hook parser expects. exit(2) preserved as the asyncRewake
1891 # "force fix" trigger (see comment near handle_commit_review_posttooluse).
1892 # See #1358 / #1375 / #1783.
1893 emit_metrics(_push_metrics, rewake_summary=_push_rewake_summary,
1894 additional_context=(PROVENANCE_BANNER + "\n\n"
1895 + guidance + CONTINUATION_SUFFIX + "\n"),
1896 hook_event_name="PostToolUse")
1897 sys.exit(2)
1898
1899def handle_stop_hook(input_data):
1900 """
1901 Handle the Stop hook — final security check using git diff.
1902 Diffs against the baseline SHA captured at UserPromptSubmit to review
1903 only code changed during this turn. Runs two Haiku analyses and
1904 exits with code 2 to force Claude to continue and fix issues.
1905
1906 Also sweeps pending pattern warnings to emit a session-level
1907 fixed/unresolved tally; the sweep needs no LLM and measures
1908 pattern-rule efficacy.
1909 """
1910 session_id = input_data.get("session_id", "default")
1911 stop_hook_active = input_data.get("stop_hook_active", False)
1912 cwd = input_data.get("cwd", "")
1913 hook_event_name = input_data.get("hook_event_name") or "Stop"
1914 is_subagent = hook_event_name == "SubagentStop"
1915
1916 # Recursion guard FIRST — consume_stop_state clears touched_paths, and CC
1917 # sets stop_hook_active session-wide while any asyncRewake Stop is in
1918 # flight, so a concurrent active=True fire winning the lock would discard
1919 # paths the concurrent active=False fire needs.
1920 if stop_hook_active:
1921 debug_log("Stop hook: stop_hook_active=True, skipping to avoid recursion")
1922 emit_metrics({"skipped": True, "skip_reason": 1, "diff_strategy_v2": True})
1923 sys.exit(0)
1924
1925 # Snapshot all state under one lock BEFORE any slow work (sweep file I/O,
1926 # git, network). asyncRewake Stop runs in the background; the next turn's
1927 # UPS/PostToolUse can fire while we're still here. The snapshot is immune
1928 # to those writes — they affect the NEXT Stop fire's snapshot.
1929 snap = (consume_stop_state(session_id, clear=False) if is_subagent
1930 else consume_stop_state(session_id))
1931 fire_count = snap["fire_count"]
1932 touched_paths = snap["touched_paths"]
1933 baseline_sha = snap["baseline_sha"]
1934 snap_baseline = baseline_sha # pre-reassignment value for restore-on-transient-skip
1935 head_at_capture = snap["head_at_capture"]
1936 untracked_at_baseline = snap.get("untracked_at_baseline") or {}
1937 previous_findings = snap["previous_findings"]
1938
1939 # Sweep pattern-warning outcomes (pure local work; stop_hook_active is
1940 # already guaranteed False here so no double-count guard needed).
1941 sweep = {}
1942 warn_fixed, warn_unresolved, warn_unresolved_mask = sweep_pending_warnings(session_id)
1943 if warn_fixed or warn_unresolved:
1944 sweep = {
1945 "warn_fixed": warn_fixed,
1946 "warn_unresolved": warn_unresolved,
1947 "warn_unresolved_mask": warn_unresolved_mask,
1948 }
1949
1950 v2_metrics = {}
1951
1952 def _skip(reason, restore=False, **extra):
1953 if restore:
1954 restore_unreviewed_stop_state(session_id, touched_paths, snap_baseline)
1955 # CC truncates metrics to 10 keys by
1956 # insertion order. v2_metrics (3) must precede sweep (3) so the v2
1957 # diagnostics survive when extra adds touched_paths_count + ip_* keys.
1958 emit_metrics({
1959 "skipped": True, "skip_reason": reason, "fire_index": fire_count + 1,
1960 "diff_strategy_v2": True,
1961 **v2_metrics, **extra, **sweep,
1962 })
1963 sys.exit(0)
1964
1965 # Limit stop hook firings per asyncRewake loop to prevent infinite loops.
1966 # fire_count auto-expires after STOP_LOOP_STATE_TTL_SEC so a stale count
1967 # from a prior turn doesn't block this one.
1968 if MAX_STOP_HOOK_FIRINGS > 0 and fire_count >= MAX_STOP_HOOK_FIRINGS:
1969 debug_log(f"Stop hook: already fired {fire_count} times (max {MAX_STOP_HOOK_FIRINGS}), skipping")
1970 _skip(2)
1971
1972 if not ENABLE_CODE_SECURITY_REVIEW or not HAS_API_CREDENTIALS:
1973 debug_log("Stop hook: LLM review disabled or no API credentials")
1974 _skip(3)
1975
1976 # Stop-hook-only kill switch — placed after consume_stop_state so
1977 # touched_paths is still cleared each turn (a disabled Stop hook that
1978 # never consumed state would accumulate stale paths) and after the sweep
1979 # so pattern-warning efficacy metrics still emit. The commit/push reviews
1980 # have their own gates (ENABLE_COMMIT_REVIEW / ENABLE_CODE_SECURITY_REVIEW).
1981 if not ENABLE_STOP_REVIEW:
1982 debug_log("Stop hook: ENABLE_STOP_REVIEW=0")
1983 # 50+ for opt-out skips that aren't push-sweep (which owns 40-49).
1984 _skip(50)
1985
1986 if not ensure_anthropic_reachable():
1987 debug_log("Stop hook: api.anthropic.com unreachable")
1988 _skip(10, restore=True)
1989
1990 if not cwd:
1991 debug_log("Stop hook: no cwd")
1992 _skip(4)
1993
1994 repo_cwd, repo_res = resolve_repo_root(
1995 cwd, touched_paths=touched_paths, session_id=session_id)
1996 res_metrics = ({} if repo_res == RES_CWD
1997 else {"cwd_is_repo": False, "repo_resolution": repo_res})
1998 if is_subagent and repo_cwd:
1999 _pd = os.environ.get("CLAUDE_PROJECT_DIR")
2000 _pd_root = _git_toplevel(_pd) if _pd and os.path.isdir(_pd) else None
2001 if _pd_root and _pd_root != repo_cwd:
2002 debug_log(f"Stop hook: SubagentStop in {repo_cwd!r}, session repo is {_pd_root!r}")
2003 v2_metrics = dict(res_metrics)
2004 _skip(11)
2005 if repo_res != RES_CWD and repo_cwd:
2006 debug_log(f"Stop hook: repo resolved via {repo_res} -> {repo_cwd!r}")
2007 if not is_subagent and repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
2008 save_repo_hint(session_id, repo_cwd)
2009 cwd = repo_cwd
2010
2011 review_paths, diff_base, repo_root, untracked, v2_metrics = compute_v2_review_set(
2012 cwd, baseline_sha, head_at_capture, untracked_at_baseline
2013 )
2014 v2_metrics = {**res_metrics, **v2_metrics}
2015 if not review_paths:
2016 debug_log("Stop hook: empty review set")
2017 _skip(9, touched_paths_count=len(touched_paths))
2018 debug_log(f"Stop hook: review_set={len(review_paths)} base={diff_base[:12]} dirty_now={v2_metrics['dirty_now_count']} changed_since={v2_metrics['changed_since_count']}")
2019 # Run from repo_root so the toplevel-relative review_paths resolve.
2020 # Diff CONTENT against the turn-start stash (baseline_sha) so the LLM
2021 # sees only this-turn edits — diffing against HEAD includes the user's
2022 # pre-turn uncommitted WIP, which inflates review_ms and can re-flag
2023 # the same pre-existing pattern every turn. The file LIST still comes
2024 # from git state (compute_v2_review_set), so Bash/subagent edits are
2025 # caught either way. Fall back to diff_base (HEAD/head_at_capture)
2026 # when the stash is missing or pruned.
2027 content_base = baseline_sha or diff_base
2028 diff_output = get_git_diff(repo_root, content_base, full_context=False,
2029 paths=review_paths, untracked_paths=untracked)
2030 if diff_output is None and content_base != diff_base:
2031 debug_log(f"Stop hook: diff against {content_base[:12]} failed — falling back to {diff_base}")
2032 diff_output = get_git_diff(repo_root, diff_base, full_context=False,
2033 paths=review_paths, untracked_paths=untracked)
2034 # filter_preexisting_from_diff needs a resolvable pre-turn ref; fall
2035 # back to HEAD when UPS never captured a baseline (print mode).
2036 if not baseline_sha:
2037 baseline_sha = "HEAD"
2038
2039 if not diff_output or not diff_output.strip():
2040 debug_log("Stop hook: no changes since baseline")
2041 _skip(6)
2042
2043 diff_hash = hashlib.sha256(diff_output.encode("utf-8", "replace")).hexdigest()
2044 if snap.get("reviewed_diff_hash") == diff_hash:
2045 debug_log("Stop hook: diff already reviewed by SubagentStop")
2046 _skip(12)
2047
2048 # Parse diff into per-file content
2049 diff_files = parse_diff_into_files(diff_output)
2050 if not diff_files:
2051 debug_log("Stop hook: no source code files in diff")
2052 _skip(7)
2053
2054 # Mirror commit-review: hard-bail only on pathological diffs (>300 files,
2055 # usually a bad baseline), otherwise prioritize by security-risk path
2056 # tokens and review the top MAX_DIFF_FILES. Stop is the only surface for
2057 # uncommitted edits; the old hard-skip at >30 files dropped the 31-300
2058 # bucket entirely, which is where cross-file source→sink vulns hide.
2059 # _cap_files_for_prompt already bounds bytes downstream.
2060 _stop_dropped = 0
2061 if len(diff_files) > 10 * MAX_DIFF_FILES:
2062 debug_log(f"Stop hook: pathological diff ({len(diff_files)} files > "
2063 f"{10 * MAX_DIFF_FILES}), skipping")
2064 _skip(8, diff_files_count=len(diff_files))
2065 if len(diff_files) > MAX_DIFF_FILES:
2066 diff_files, _stop_dropped = _prioritize_diff_files(
2067 diff_files, MAX_DIFF_FILES)
2068 debug_log(f"Stop hook: prioritized to {len(diff_files)} files "
2069 f"(dropped {_stop_dropped} lower-risk)")
2070
2071 # Filter out pre-existing content from file rewrites
2072 diff_files = filter_preexisting_from_diff(diff_files, cwd, baseline_sha)
2073
2074 debug_log(f"Stop hook: reviewing {len(diff_files)} changed files (standard diff)")
2075
2076 import time as _time
2077 stop_review_start = _time.time()
2078
2079 # Stop hook is single-shot only. Agentic review is wired into
2080 # handle_commit_review_posttooluse (PostToolUse on `git commit`) — commits
2081 # are slower-OK and benefit from the deeper context-reading loop.
2082 concrete_guidance, vulns = analyze_code_security(
2083 diff_files, is_diff=True, previous_findings=previous_findings
2084 )
2085 # NOTE: analyze_security_concerns disabled — it produces too many false positives
2086 # on pre-existing patterns in starter code. The concrete vulnerability analysis
2087 # is more precise and has severity filtering (high/critical only).
2088
2089 stop_review_elapsed = _time.time() - stop_review_start
2090 debug_log(f"Stop hook: LLM reviews took {stop_review_elapsed:.1f}s total")
2091
2092 review_ms = int(stop_review_elapsed * 1000)
2093 fire_index = fire_count + 1
2094
2095 # Late dedup: drop only what a concurrent commit-review wrote while our
2096 # LLM ran. Anything already in `previous_findings` (the consume_stop_state
2097 # snapshot) that the LLM re-flagged is an intentional "fix incomplete"
2098 # verdict and passes through.
2099 if vulns:
2100 vulns, n_deduped = _dedup_against_state(
2101 session_id, vulns, prompted=_finding_keys(previous_findings)
2102 )
2103 if n_deduped and not vulns:
2104 debug_log("Stop hook: all findings already delivered by commit-review")
2105 _skip(35, deduped=n_deduped, review_ms=review_ms)
2106 concrete_guidance = _format_vulns_guidance(vulns)
2107
2108 if concrete_guidance:
2109 finding_snapshots = [
2110 {
2111 "filePath": v.get("filePath", ""),
2112 "category": v.get("category", "Unknown"),
2113 "vulnerableCode": v.get("vulnerableCode", ""),
2114 }
2115 for v in vulns
2116 ]
2117 # Update baseline so next stop hook iteration only sees new changes
2118 new_sha = None if is_subagent else capture_git_baseline(cwd)
2119 new_untracked_baseline = _list_untracked(cwd) if new_sha else None
2120
2121 def _record_fire(state):
2122 if is_subagent:
2123 state["reviewed_diff_hash"] = diff_hash
2124 else:
2125 state["stop_hook_fire_count"] = fire_index
2126 state["stop_hook_fire_count_ts"] = _time.time()
2127 # Re-read under lock — the commit-review PostToolUse hook may have
2128 # appended findings since consume_stop_state snapshotted.
2129 # Dedupe on (filePath, category) — vulnerableCode includes diff
2130 # context lines that drift between fires, so byte-identical
2131 # matching let the same finding accumulate as "new" each fire.
2132 existing = [f for f in state.get("previous_findings", []) if isinstance(f, dict)]
2133 seen = {(f.get("filePath", ""), f.get("category", "")) for f in existing}
2134 for f in finding_snapshots:
2135 key = (f["filePath"], f["category"])
2136 if key not in seen:
2137 seen.add(key)
2138 existing.append(f)
2139 state["previous_findings"] = existing
2140 state["previous_findings_ts"] = _time.time()
2141 if new_sha:
2142 state["baseline_sha"] = new_sha
2143 state["untracked_at_baseline"] = new_untracked_baseline
2144 with_locked_state(session_id, _record_fire)
2145
2146 if new_sha:
2147 debug_log(f"Updated git baseline after stop hook: {new_sha[:12]}")
2148
2149 sev = {"critical": 0, "high": 0, "medium": 0}
2150 for v in vulns:
2151 s = v.get("severity", "medium")
2152 if s in sev:
2153 sev[s] += 1
2154 # 8 base keys + at most 2 sweep keys = 10 (cap). Drop the mask here.
2155 # untracked_baseline_n is the signal for whether the UPS-time
2156 # untracked-snapshot capture actually ran.
2157 sweep_trimmed = {k: v for k, v in sweep.items() if k != "warn_unresolved_mask"}
2158 # Pass guidance via additional_context so CC surfaces the findings via
2159 # hookSpecificOutput.additionalContext instead of stderr-only (which
2160 # was the cause of "json output validation failed" / empty-reason UI in
2161 # #1375 / #1783). exit(2) preserved as the asyncRewake "force fix"
2162 # signal — that's the documented mechanism. See #1358 / #1375 / #1783.
2163 emit_metrics({
2164 "vulns_found": len(vulns),
2165 "untracked_baseline_n": len(untracked_at_baseline),
2166 "diff_strategy_v2": True,
2167 "critical_count": sev["critical"],
2168 "high_count": sev["high"],
2169 "files_reviewed": len(diff_files),
2170 "touched_paths_count": len(touched_paths),
2171 "review_ms": review_ms,
2172 "fire_index": fire_index,
2173 **({"diff_truncated": llm._last_review_truncated_bytes}
2174 if llm._last_review_truncated_bytes else {}),
2175 **({"repo_resolution": repo_res} if res_metrics else {}),
2176 **sweep_trimmed,
2177 }, rewake_summary=_format_vulns_summary(vulns),
2178 additional_context=(PROVENANCE_BANNER + "\n\n"
2179 + concrete_guidance + CONTINUATION_SUFFIX + "\n"),
2180 hook_event_name=hook_event_name)
2181 sys.exit(2)
2182
2183 if llm._last_call_claude_http_error is not None:
2184 debug_log(f"Stop hook: API call failed with status {llm._last_call_claude_http_error}")
2185 restore_unreviewed_stop_state(session_id, touched_paths, snap_baseline)
2186 else:
2187 debug_log("Stop hook: no security issues found")
2188 if is_subagent:
2189 record_reviewed_diff(session_id, diff_hash)
2190 # CC truncates metrics to 10 keys by
2191 # insertion order. The previous **sweep,**v2_metrics tail meant the 3
2192 # v2_metrics keys were always sliced off this most-common path, so the
2193 # diff-strategy diagnostics never reached telemetry. Drop sweep here (it's
2194 # PostToolUse-warning state, orthogonal to diff-strategy comparison).
2195 # 6 base + optional api_error + 3 v2_metrics = ≤10.
2196 emit_metrics({
2197 "vulns_found": 0,
2198 "diff_strategy_v2": True,
2199 "files_reviewed": len(diff_files),
2200 "touched_paths_count": len(touched_paths),
2201 "review_ms": review_ms,
2202 "fire_index": fire_index,
2203 **({"api_error": llm._last_call_claude_http_error} if llm._last_call_claude_http_error is not None else {}),
2204 **({"diff_truncated": llm._last_review_truncated_bytes}
2205 if llm._last_review_truncated_bytes else {}),
2206 **v2_metrics,
2207 })
2208 sys.exit(0)
2209
2210_SDK_BOOTSTRAP_THROTTLE = os.path.join(_resolve_state_dir(), ".sdk_bootstrap_spawned")
2211
2212def _maybe_bootstrap_agent_sdk_async():
2213 """Fire-and-forget SDK bootstrap, for remote-pod environments.
2214
2215 Under CLAUDE_CODE_SYNC_PLUGIN_INSTALL=true (CCR-style remote pods),
2216 plugins are synced *after* SessionStart fires, so the SessionStart
2217 `ensure_agent_sdk.py` hook never runs and the agentic commit reviewer
2218 falls back 100% of the time. A PostToolUse hook firing is itself proof
2219 the plugin is now registered, so re-trigger the bootstrap here.
2220 Detached, so the ~17s venv build never blocks the hook — the first
2221 1-2 commits of a remote session still fall back while it builds, then
2222 every subsequent commit gets the agentic path. ensure_agent_sdk.py
2223 is idempotent and O_EXCL-locked, so concurrent/repeat spawns are safe;
2224 the throttle file only avoids spawning dozens of subprocesses during
2225 the build window. No-ops in ~10ms on local installs (SDK already
2226 importable).
2227 """
2228 try:
2229 import importlib.util
2230 if importlib.util.find_spec("claude_agent_sdk") is not None:
2231 return
2232 import time as _t
2233 try:
2234 if _t.time() - os.path.getmtime(_SDK_BOOTSTRAP_THROTTLE) < 300:
2235 return
2236 except OSError:
2237 pass
2238 os.makedirs(os.path.dirname(_SDK_BOOTSTRAP_THROTTLE), exist_ok=True)
2239 # Touch the throttle BEFORE spawning so a burst of PostToolUse
2240 # fires in the same second don't each spawn a subprocess.
2241 open(_SDK_BOOTSTRAP_THROTTLE, "w").close()
2242 script = os.path.join(
2243 os.path.dirname(os.path.abspath(__file__)), "ensure_agent_sdk.py")
2244 subprocess.Popen(
2245 [sys.executable, script],
2246 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
2247 stdin=subprocess.DEVNULL, start_new_session=True,
2248 )
2249 except Exception:
2250 pass # best-effort; never break the hook over a bootstrap attempt
2251
2252def main():
2253 """Main hook function."""
2254 debug_log(f"Hook called with args: {sys.argv}")
2255 apply_safe_git_env()
2256 remove_copies_on_termination()
2257
2258 # Master kill switch — honors ENABLE_SECURITY_REMINDER=0 (legacy) and
2259 # SECURITY_GUIDANCE_DISABLE=1 (clearer name, no double negative). Emit
2260 # empty metrics so asyncRewake hooks (Stop) don't hang waiting for stdout
2261 # output that never comes.
2262 if SECURITY_GUIDANCE_DISABLED:
2263 emit_metrics({"skipped": True, "skip_reason": -1})
2264 sys.exit(0)
2265
2266 # Periodically clean up old state files (10% chance per run)
2267 if random.random() < 0.1:
2268 cleanup_old_state_files()
2269
2270 # Read input from stdin
2271 try:
2272 raw_input = sys.stdin.read()
2273 input_data = json.loads(raw_input)
2274 except json.JSONDecodeError as e:
2275 debug_log(f"JSON decode error: {e}")
2276 emit_metrics({"skipped": True, "skip_reason": -2})
2277 sys.exit(0)
2278
2279 session_id = input_data.get("session_id", "default")
2280 tool_name = input_data.get("tool_name", "")
2281 tool_input = input_data.get("tool_input", {})
2282 hook_event_name = input_data.get("hook_event_name", "")
2283 debug_log(f"Processing: hook_event={hook_event_name}, tool={tool_name}")
2284
2285 # Load project-specific security guidance and custom patterns once
2286 # per invocation. Failures are non-fatal (debug-logged) so a malformed
2287 # config never prevents the built-in checks from running.
2288 extensibility.load_for_session(input_data.get("cwd"))
2289
2290 # Remote-pod SDK-bootstrap rescue: PostToolUse is the earliest hook event
2291 # that is guaranteed to fire *after* async plugin sync (its firing proves
2292 # the plugin is registered), so it's where we recover the SessionStart
2293 # bootstrap that remote pods miss under CLAUDE_CODE_SYNC_PLUGIN_INSTALL.
2294 # Fires on Edit/Write too (not just Bash), so the venv is usually built
2295 # before the first `git commit`.
2296 if hook_event_name == "PostToolUse":
2297 _maybe_bootstrap_agent_sdk_async()
2298
2299 # Handle UserPromptSubmit — capture git baseline
2300 if hook_event_name == "UserPromptSubmit":
2301 handle_user_prompt_submit(input_data)
2302 return
2303
2304 # Handle Stop hook — final security check
2305 if hook_event_name in ("Stop", "SubagentStop"):
2306 handle_stop_hook(input_data)
2307 return
2308
2309 # Handle PostToolUse[Bash] — commit review or push sweep (asyncRewake).
2310 #
2311 # hooks.json has two `if` configs under the Bash matcher (`git commit:*`
2312 # and `git push:*`). CC evaluates each `if` independently and spawns this
2313 # script ONCE PER MATCH — so `git commit -m x && git push` spawns python
2314 # twice with the same command string and the same tool_use_id. The python
2315 # cannot tell which `if` fired it.
2316 #
2317 # Routing therefore MUST check commit FIRST so that compound commit+push
2318 # commands continue to hit commit-review (the pre-existing behaviour) on
2319 # the commit-matcher invocation. The push-matcher invocation of the SAME
2320 # compound command is deduped by `_claim_bash_hook_once` below: the second
2321 # spawn loses the tool_use_id sentinel race and exits early with
2322 # `bash_hook_dedup`, so commit-review runs exactly once. The alternative —
2323 # checking push first — would silently DROP commit-review
2324 # on `git commit && git push`, which is a regression.
2325 #
2326 # The push-sweep does NOT run on the compound call. That's acceptable: the
2327 # just-made commit is recorded by commit-review, so the next standalone
2328 # push sees it as reviewed and the sweep base advances past it. Older
2329 # unreviewed commits in the range are caught on that next push.
2330 if tool_name == "Bash" and hook_event_name == "PostToolUse":
2331 cmd = (input_data.get("tool_input") or {}).get("command", "") or ""
2332 if not (_GIT_COMMIT_RE.search(cmd) or _GIT_PUSH_RE.search(cmd)):
2333 return
2334 if not _claim_bash_hook_once(input_data):
2335 # Another spawn for this same tool_use_id already claimed the
2336 # work (compound matched multiple `if` configs). Emit a single
2337 # metric so telemetry can count how often the de-dupe kicks in.
2338 print(json.dumps({"metrics": {"bash_hook_dedup": True}}), flush=True)
2339 sys.exit(0)
2340 if _GIT_COMMIT_RE.search(cmd):
2341 handle_commit_review_posttooluse(input_data)
2342 elif _GIT_PUSH_RE.search(cmd):
2343 handle_push_sweep_posttooluse(input_data)
2344 return
2345
2346 # Handle PostToolUse — pattern-based checks only (no LLM review per-edit)
2347 if tool_name in ["Edit", "Write", "MultiEdit", "NotebookEdit"]:
2348 file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or ""
2349 if not file_path:
2350 sys.exit(0)
2351
2352 # Skip plan files
2353 plans_dir = os.path.expanduser("~/.claude/plans")
2354 if file_path.startswith(plans_dir):
2355 sys.exit(0)
2356
2357 record_touched_path(session_id, file_path)
2358
2359 content = extract_content_from_input(tool_name, tool_input)
2360
2361 all_guidance = []
2362 raw_pattern_matches = []
2363 if ENABLE_PATTERN_RULES:
2364 pattern_matches = check_patterns(file_path, content)
2365 raw_pattern_matches = pattern_matches
2366 if pattern_matches:
2367 debug_log(f"Pattern matches for {file_path}: {[r for r, _ in pattern_matches]}")
2368
2369 # For Write tool, filter out patterns that existed in the baseline version
2370 # This prevents flagging pre-existing insecure patterns when Claude rewrites a file
2371 if tool_name == "Write" and pattern_matches:
2372 cwd = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
2373 baseline_content = get_baseline_file_content(session_id, file_path, cwd)
2374 if baseline_content is not None:
2375 baseline_matches = set(r for r, _ in check_patterns(file_path, baseline_content))
2376 pattern_matches = [(r, msg) for r, msg in pattern_matches if r not in baseline_matches]
2377 if pattern_matches:
2378 debug_log(f"New patterns (not in baseline): {[r for r, _ in pattern_matches]}")
2379 else:
2380 debug_log("All patterns existed in baseline, skipping")
2381
2382 for rule_name, reminder in pattern_matches:
2383 warning_key = f"{file_path}-{rule_name}"
2384 if atomic_check_and_mark_warning(session_id, warning_key):
2385 all_guidance.append(reminder)
2386
2387 # Record matched rules as pending so the Stop-hook sweep can
2388 # later tally fixed vs unresolved. Only runs when patterns match.
2389 if pattern_matches:
2390 record_pending_warnings(session_id, file_path,
2391 [r for r, _ in pattern_matches])
2392
2393 # Emit metrics when raw patterns matched (even if all were baseline-suppressed
2394 # or dedup'd — pattern_hits reflects warnings actually shown, may be 0).
2395 # Gate on raw matches so clean edits don't flood the metrics event.
2396 # rule_id: RuleId of the first raw match (values stay small/enumerable in telemetry)
2397 # rule_mask: bitmask of ALL raw matches — POPCOUNT gives raw hit count,
2398 # (mask >> N) & 1 tests for a specific rule
2399 if raw_pattern_matches:
2400 raw_names = [r for r, _ in raw_pattern_matches]
2401 output = {"metrics": {
2402 "pattern_hits": len(all_guidance),
2403 # User-defined patterns (rule_name="user:*") have no static
2404 # RuleId; emit -1 so the metrics pipeline can distinguish.
2405 "rule_id": int(_RULE_NAME_TO_ID.get(raw_names[0], -1)),
2406 "rule_mask": rule_names_to_mask(raw_names),
2407 **({"pv": _PV} if _PV else {}),
2408 }}
2409 if all_guidance:
2410 output["hookSpecificOutput"] = {
2411 "hookEventName": "PostToolUse",
2412 "additionalContext": PROVENANCE_TAG + "\n\n" + "\n\n".join(all_guidance),
2413 }
2414 print(json.dumps(output))
2415 elif all_guidance:
2416 # Defensive: pattern rules disabled but guidance somehow set (shouldn't happen)
2417 print(json.dumps({
2418 "hookSpecificOutput": {
2419 "hookEventName": "PostToolUse",
2420 "additionalContext": PROVENANCE_TAG + "\n\n" + "\n\n".join(all_guidance),
2421 }
2422 }))
2423
2424 sys.exit(0)
2425
2426if __name__ == "__main__":
2427 main()