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