Browse Source

Merge pull request #5982 from anthropics/sg-resolve-repo-root

security-guidance: resolve repository from command and edited paths
Octavian Guzu 6 hours ago
parent
commit
3ea32df27b

+ 1 - 1
plugins/security-guidance/.claude-plugin/plugin.json

@@ -1,6 +1,6 @@
 {
   "name": "security-guidance",
-  "version": "2.0.7",
+  "version": "2.0.8",
   "description": "Security review for Claude-generated code. Pattern-based warnings on edits, LLM-powered diff review on Stop, and an agentic commit reviewer that catches injection, XSS, SSRF, hardcoded secrets, and 25+ other vulnerability classes.",
   "author": {
     "name": "David Dworken",

+ 12 - 2
plugins/security-guidance/hooks/diffstate.py

@@ -71,7 +71,7 @@ def record_touched_path(session_id, file_path):
     with_locked_state(session_id, _record)
 
 
-def consume_stop_state(session_id):
+def consume_stop_state(session_id, clear=True):
     """Atomically snapshot all state the Stop hook needs and clear touched_paths.
 
     The Stop hook is asyncRewake — it runs in the background after Claude's
@@ -102,17 +102,27 @@ def consume_stop_state(session_id):
             "fire_count": 0 if expired else state.get("stop_hook_fire_count", 0),
             "fire_count_expired": expired and state.get("stop_hook_fire_count", 0) > 0,
             "previous_findings": [] if findings_expired else list(state.get("previous_findings", [])),
+            "reviewed_diff_hash": state.get("reviewed_diff_hash"),
         }
-        state["touched_paths"] = []
+        if clear:
+            state["touched_paths"] = []
+            state.pop("reviewed_diff_hash", None)
         return snap
 
     return with_locked_state(session_id, _snap) or {
         "touched_paths": [], "baseline_sha": None, "head_at_capture": None,
         "untracked_at_baseline": {},
         "fire_count": 0, "fire_count_expired": False, "previous_findings": [],
+        "reviewed_diff_hash": None,
     }
 
 
+def record_reviewed_diff(session_id, diff_hash):
+    def _save(state):
+        state["reviewed_diff_hash"] = diff_hash
+    with_locked_state(session_id, _save)
+
+
 def restore_unreviewed_stop_state(session_id, paths, baseline_sha):
     """Put consumed touched_paths back so the next Stop reviews them.
 

+ 25 - 2
plugins/security-guidance/hooks/gitutil.py

@@ -39,6 +39,29 @@ GIT_CMD = [
     "-c", "core.quotePath=false",
 ]
 
+SAFE_GIT_CONFIG = (
+    ("core.fsmonitor", "false"),
+    ("core.hooksPath", "/dev/null"),
+)
+
+
+def git_config_env(pairs, base=None):
+    base = os.environ if base is None else base
+    try:
+        n = max(0, int(base.get("GIT_CONFIG_COUNT") or 0))
+    except (TypeError, ValueError):
+        n = 0
+    env = {}
+    for i, (k, v) in enumerate(pairs, start=n):
+        env[f"GIT_CONFIG_KEY_{i}"] = k
+        env[f"GIT_CONFIG_VALUE_{i}"] = v
+    env["GIT_CONFIG_COUNT"] = str(n + len(pairs))
+    return env
+
+
+def apply_safe_git_env():
+    os.environ.update(git_config_env(SAFE_GIT_CONFIG))
+
 
 def _git_rev_parse_head(cwd):
     """Return the current HEAD SHA, or None if not a git repo / no commits."""
@@ -238,7 +261,7 @@ def _git_diff_range(repo_root, base, head="HEAD"):
         # raw UTF-8, not C-quoted. Required by the downstream
         # parse_diff_into_files / extract_file_paths_from_diff regex.
         r = subprocess.run(
-            [*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", base, head],
+            [*GIT_CMD, "diff", "-p", "--no-color", "--no-ext-diff", "--no-textconv", base, head],
             cwd=repo_root, capture_output=True, timeout=30,
         )
         if r.returncode != 0:
@@ -481,7 +504,7 @@ def get_git_diff(cwd, baseline_sha, full_context=False, paths=None, untracked_pa
         return ""
 
     # core.quotePath=false comes from GIT_CMD globally (see definition).
-    cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
+    cmd = [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv", baseline_sha] + (["--unified=99999"] if full_context else []) + pathspec
     try:
         with _temp_index(cwd, untracked_paths) as env:
             # env is None when no index could be found (bare repo / not a

+ 29 - 0
plugins/security-guidance/hooks/hooks.json

@@ -42,6 +42,14 @@
             "rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
             "rewakeSummary": "Commit security review found issues"
           },
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
+            "if": "Bash(git -C * commit *)",
+            "asyncRewake": true,
+            "rewakeMessage": "Background security review of commit — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
+            "rewakeSummary": "Commit security review found issues"
+          },
           {
             "type": "command",
             "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
@@ -50,6 +58,14 @@
             "rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
             "rewakeSummary": "Push security review found issues"
           },
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
+            "if": "Bash(git -C * push*)",
+            "asyncRewake": true,
+            "rewakeMessage": "Background security review of pushed commits not yet reviewed — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply:",
+            "rewakeSummary": "Push security review found issues"
+          },
           {
             "type": "command",
             "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
@@ -90,6 +106,19 @@
           }
         ]
       }
+    ],
+    "SubagentStop": [
+      {
+        "hooks": [
+          {
+            "type": "command",
+            "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
+            "asyncRewake": true,
+            "rewakeMessage": "Background security review feedback — address or acknowledge the findings below, then continue with the user's original request or continue waiting for their reply. This is supplementary, not a replacement for your previous response:",
+            "rewakeSummary": "Background security review found issues"
+          }
+        ]
+      }
     ]
   }
 }

+ 2 - 0
plugins/security-guidance/hooks/llm.py

@@ -29,6 +29,7 @@ import extensibility
 import review_api
 from _base import debug_log, _record_usage, _record_http_error, _PV, PROVENANCE_TAG, state_dir as _resolve_state_dir  # noqa: F401
 from session_state import with_locked_state
+from gitutil import git_config_env, SAFE_GIT_CONFIG
 
 
 def _inject_agent_sdk_venv_into_syspath(state_dir):
@@ -1123,6 +1124,7 @@ def _agentic_spawn_env() -> Dict[str, str]:
         "GIT_SSH_COMMAND": "/bin/false",
         "GIT_TERMINAL_PROMPT": "0",
         "GIT_OPTIONAL_LOCKS": "0",
+        **git_config_env(SAFE_GIT_CONFIG),
     }
     if os.environ.get("ANTHROPIC_API_KEY"):
         # API key present → blank the OAuth token so API-key auth wins.

+ 306 - 0
plugins/security-guidance/hooks/reporesolve.py

@@ -0,0 +1,306 @@
+import os
+import re
+import shlex
+import subprocess
+import time
+
+from gitutil import GIT_CMD, _git_toplevel
+from session_state import with_locked_state
+
+
+RES_NONE = 0
+RES_CWD = 1
+RES_COMMAND = 2
+RES_SHA_SCAN = 3
+RES_TOUCHED_PATHS = 4
+RES_HINT = 5
+
+COMMIT_SUBCOMMANDS = {("git", "commit"), ("gt", "create"), ("gt", "modify")}
+PUSH_SUBCOMMANDS = {("git", "push"), ("gt", "submit")}
+
+SCAN_SKIP_DIRS = {
+    "node_modules", ".venv", "venv", "__pycache__", ".tox", "dist",
+    "build", "target", ".cache", ".git", "vendor", "site-packages",
+}
+SCAN_MAX_DEPTH = int(os.environ.get("SG_REPO_SCAN_MAX_DEPTH", "3"))
+SCAN_MAX_REPOS = int(os.environ.get("SG_REPO_SCAN_MAX_REPOS", "64"))
+SCAN_BUDGET_S = float(os.environ.get("SG_REPO_SCAN_BUDGET_S", "4"))
+
+_SEPARATORS = frozenset(";&|()\n")
+_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$")
+_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
+_PREFIX_WORDS = frozenset(("env", "time", "exec", "command", "nohup"))
+
+
+def _abs(base, p):
+    try:
+        p = os.path.expanduser(p)
+        if not os.path.isabs(p):
+            p = os.path.join(base or os.getcwd(), p)
+        return os.path.normpath(p)
+    except (TypeError, ValueError, OSError):
+        return None
+
+
+def _is_sep(tok):
+    return bool(tok) and set(tok) <= _SEPARATORS
+
+
+def _tokenize(command):
+    if os.sep == "\\":
+        command = command.replace("\\", "\\\\")
+    try:
+        lex = shlex.shlex(command, posix=True, punctuation_chars=";&|()")
+        lex.whitespace_split = True
+        return list(lex)
+    except ValueError:
+        try:
+            return command.replace("&&", " && ").replace(";", " ; ").split()
+        except Exception:
+            return []
+
+
+def dirs_from_command(command, cwd, subcommands=None):
+    if not isinstance(command, str) or not command.strip():
+        return []
+    tokens = _tokenize(command)
+    out = []
+    cur = cwd or ""
+    i = 0
+    n = len(tokens)
+    at_start = True
+    while i < n:
+        t = tokens[i]
+        if _is_sep(t):
+            at_start = True
+            i += 1
+            continue
+        if at_start and (_ENV_ASSIGN_RE.match(t) or t in _PREFIX_WORDS):
+            i += 1
+            continue
+        if at_start and t in ("cd", "pushd"):
+            if i + 1 < n and not _is_sep(tokens[i + 1]) and tokens[i + 1] not in ("-",) \
+                    and not tokens[i + 1].startswith("-"):
+                nxt = _abs(cur, tokens[i + 1])
+                if nxt:
+                    cur = nxt
+                i += 2
+            else:
+                i += 1
+            at_start = False
+            continue
+        prog = os.path.basename(t) if t else t
+        if at_start and prog in ("git", "gt"):
+            j = i + 1
+            cdir = cur
+            gdir = None
+            wtree = None
+            if prog == "git":
+                while j < n and not _is_sep(tokens[j]):
+                    a = tokens[j]
+                    if a == "-C" and j + 1 < n:
+                        cdir = _abs(cdir, tokens[j + 1]) or cdir
+                        j += 2
+                        continue
+                    if a == "-c" and j + 1 < n:
+                        j += 2
+                        continue
+                    if a.startswith("--git-dir="):
+                        gdir = _abs(cdir, a.split("=", 1)[1])
+                        j += 1
+                        continue
+                    if a == "--git-dir" and j + 1 < n:
+                        gdir = _abs(cdir, tokens[j + 1])
+                        j += 2
+                        continue
+                    if a.startswith("--work-tree="):
+                        wtree = _abs(cdir, a.split("=", 1)[1])
+                        j += 1
+                        continue
+                    if a == "--work-tree" and j + 1 < n:
+                        wtree = _abs(cdir, tokens[j + 1])
+                        j += 2
+                        continue
+                    if a.startswith("-"):
+                        j += 1
+                        continue
+                    break
+            sub = tokens[j] if j < n and not _is_sep(tokens[j]) else None
+            if subcommands is None or (prog, sub) in subcommands:
+                cand = wtree
+                if not cand and gdir:
+                    cand = os.path.dirname(gdir) if os.path.basename(gdir) == ".git" else gdir
+                if not cand:
+                    cand = cdir
+                if cand:
+                    out.append(cand)
+            i = j + 1 if j < n else j
+            at_start = False
+            continue
+        at_start = False
+        i += 1
+    return list(dict.fromkeys(d for d in out if d))
+
+
+def toplevel_from_command(command, cwd, subcommands=None, cwd_root=None):
+    cwd_abs = _abs(None, cwd) if cwd else None
+    for d in dirs_from_command(command, cwd, subcommands):
+        if cwd_root and d == cwd_abs:
+            return cwd_root
+        try:
+            if os.path.isdir(d):
+                top = _git_toplevel(d)
+                if top:
+                    return top
+        except OSError:
+            continue
+    return None
+
+
+def scan_roots(cwd):
+    roots = []
+    for r in (cwd, os.environ.get("CLAUDE_PROJECT_DIR")):
+        if r and os.path.isdir(r):
+            a = os.path.abspath(r)
+            if a not in roots:
+                roots.append(a)
+    return roots
+
+
+def iter_git_repos(roots, max_depth=None, max_repos=None, deadline=None):
+    max_depth = SCAN_MAX_DEPTH if max_depth is None else max_depth
+    max_repos = SCAN_MAX_REPOS if max_repos is None else max_repos
+    seen = set()
+    for root in roots or []:
+        try:
+            if not root or not os.path.isdir(root):
+                continue
+            root = os.path.abspath(root)
+        except OSError:
+            continue
+        base_depth = root.rstrip(os.sep).count(os.sep)
+        for dirpath, dirnames, filenames in os.walk(root):
+            if deadline is not None and time.monotonic() > deadline:
+                return
+            if ".git" in dirnames or ".git" in filenames:
+                try:
+                    key = os.path.realpath(dirpath)
+                except OSError:
+                    key = dirpath
+                if key not in seen:
+                    seen.add(key)
+                    yield dirpath
+                    if len(seen) >= max_repos:
+                        return
+            depth = dirpath.rstrip(os.sep).count(os.sep) - base_depth
+            if depth >= max_depth:
+                dirnames[:] = []
+            else:
+                dirnames[:] = [
+                    d for d in dirnames
+                    if d not in SCAN_SKIP_DIRS and not d.startswith(".")
+                ]
+
+
+def repo_containing_commit(sha, roots, budget_s=None):
+    if not isinstance(sha, str) or not _SHA_RE.match(sha):
+        return None
+    budget_s = SCAN_BUDGET_S if budget_s is None else budget_s
+    deadline = time.monotonic() + budget_s
+    for repo in iter_git_repos(roots, deadline=deadline):
+        try:
+            r = subprocess.run(
+                [*GIT_CMD, "cat-file", "-e", f"{sha}^{{commit}}"],
+                cwd=repo, capture_output=True, timeout=3,
+            )
+        except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
+            continue
+        if r.returncode == 0:
+            return _git_toplevel(repo) or repo
+    return None
+
+
+def repos_from_paths(paths, cwd=None, limit=200):
+    counts = {}
+    order = []
+    cache = {}
+    for p in list(paths or [])[:limit]:
+        if not isinstance(p, str) or not p:
+            continue
+        ap = p if os.path.isabs(p) else _abs(cwd, p)
+        if not ap:
+            continue
+        d = os.path.dirname(ap)
+        while d and not os.path.isdir(d):
+            parent = os.path.dirname(d)
+            if parent == d:
+                break
+            d = parent
+        if not d:
+            continue
+        if d in cache:
+            top = cache[d]
+        else:
+            try:
+                top = _git_toplevel(d) if os.path.isdir(d) else None
+            except OSError:
+                top = None
+            cache[d] = top
+        if top:
+            if top not in counts:
+                order.append(top)
+            counts[top] = counts.get(top, 0) + 1
+    return sorted(order, key=lambda t: (-counts[t], order.index(t)))
+
+
+def save_repo_hint(session_id, repo_root):
+    if not session_id or not repo_root:
+        return
+
+    def _save(state):
+        state["repo_root_hint"] = repo_root
+    try:
+        with_locked_state(session_id, _save)
+    except Exception:
+        pass
+
+
+def load_repo_hint(session_id):
+    if not session_id:
+        return None
+    try:
+        hint = with_locked_state(session_id, lambda s: s.get("repo_root_hint"))
+    except Exception:
+        return None
+    if isinstance(hint, str) and hint and os.path.isdir(hint):
+        top = _git_toplevel(hint)
+        if top:
+            return top
+    return None
+
+
+_UNSET = object()
+
+
+def resolve_repo_root(cwd, command=None, subcommands=None, sha=None,
+                      touched_paths=None, session_id=None, cwd_root=_UNSET):
+    if cwd_root is _UNSET:
+        cwd_root = _git_toplevel(cwd) if cwd else None
+    if command:
+        top = toplevel_from_command(command, cwd, subcommands, cwd_root)
+        if top and top != cwd_root:
+            return top, RES_COMMAND
+    if cwd_root:
+        return cwd_root, RES_CWD
+    if touched_paths:
+        tops = repos_from_paths(touched_paths, cwd)
+        if tops:
+            return tops[0], RES_TOUCHED_PATHS
+    if sha:
+        top = repo_containing_commit(sha, scan_roots(cwd))
+        if top:
+            return top, RES_SHA_SCAN
+    hint = load_repo_hint(session_id)
+    if hint:
+        return hint, RES_HINT
+    return None, RES_NONE

+ 119 - 18
plugins/security-guidance/hooks/security_reminder_hook.py

@@ -58,6 +58,7 @@ except ImportError:
     fcntl = None
 import contextlib
 import glob
+import hashlib
 import json
 import os
 import random
@@ -96,7 +97,7 @@ from session_state import (  # noqa: E402,F401
     load_state, save_state, with_locked_state,
 )
 from gitutil import (  # noqa: E402,F401
-    GIT_CMD,
+    GIT_CMD, apply_safe_git_env,
     _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,
@@ -112,12 +113,18 @@ from gitutil import (  # noqa: E402,F401
 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,
+    consume_stop_state, restore_unreviewed_stop_state, record_reviewed_diff,
     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,
 )
+from reporesolve import (  # noqa: E402,F401
+    RES_NONE, RES_CWD, RES_COMMAND, RES_SHA_SCAN, RES_TOUCHED_PATHS, RES_HINT,
+    COMMIT_SUBCOMMANDS, PUSH_SUBCOMMANDS,
+    toplevel_from_command, repo_containing_commit, scan_roots,
+    resolve_repo_root, save_repo_hint, load_repo_hint,
+)
 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,
@@ -540,6 +547,16 @@ def handle_user_prompt_submit(input_data):
         # 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 {}
+    if not sha and not _git_toplevel(cwd):
+        hint = load_repo_hint(session_id)
+        if hint:
+            debug_log(f"UPS: cwd is not a git repo; using repo hint {hint!r}")
+            cwd = hint
+            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()
+                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
@@ -678,7 +695,8 @@ _GIT_COMMIT_RE = re.compile(
     # _GIT_PUSH_RE). Without this, `git -C /repo commit` is silently dropped
     # by the handler — see #2089's secondary finding. The gt branch has no
     # global-option layer to worry about.
-    r'\bgit(?:\s+-[Cc]\s+\S+|\s+--\S+=\S+)*\s+commit\b'
+    r'\bgit(?:\s+-[Cc]\s+(?:"[^"]*"|\'[^\']*\'|[^\s"\']\S*)|\s+--[^\s=]+=\S+'
+    r'|\s+--(?:git-dir|work-tree)\s+\S+)*\s+commit\b'
     r'|\bgt\s+(?:create|modify)\b'
 )
 # Match either the `--amend` flag (with the leading whitespace boundary
@@ -721,7 +739,8 @@ COMMIT_REVIEW_RATE_WINDOW_S = int(
 # but the bash hook fires on Claude's top-level command so we need to
 # recognize gt submit at the matcher level. See #2048.
 _GIT_PUSH_RE = re.compile(
-    r'(?:\bgit(?:\s+-[cC]\s+\S+|\s+--\S+=\S+)*\s+push\b|\bgt\s+submit\b)'
+    r'(?:\bgit(?:\s+-[cC]\s+(?:"[^"]*"|\'[^\']*\'|[^\s"\']\S*)|\s+--[^\s=]+=\S+'
+    r'|\s+--(?:git-dir|work-tree)\s+\S+)*\s+push\b|\bgt\s+submit\b)'
 )
 
 # `git push` stdout: "abc1234..def5678  branch -> branch" (or `+abc..def` on
@@ -759,7 +778,12 @@ def _claim_bash_hook_once(input_data):
     cwd = input_data.get("cwd")
     if not tuid or not cwd:
         return True
-    gd = _git_dir(_git_toplevel(cwd) or cwd)
+    command = (input_data.get("tool_input") or {}).get("command", "") or ""
+    gd = _git_dir(
+        _git_toplevel(cwd)
+        or toplevel_from_command(command, cwd, COMMIT_SUBCOMMANDS | PUSH_SUBCOMMANDS)
+        or cwd
+    )
     if not gd:
         return True
     # GC: best-effort sweep of stale sentinels so they don't accumulate.
@@ -1041,9 +1065,10 @@ def handle_commit_review_posttooluse(input_data):
     # 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.
+    all_shas = _COMMIT_SHA_RE.findall(bash_output)
     commit_succeeded = (
         not interrupted
-        and _COMMIT_SHA_RE.search(bash_output) is not None
+        and bool(all_shas)
         and any(p.search(bash_output) for p in _COMMIT_DIFFSTAT_PATTERNS)
     )
 
@@ -1051,6 +1076,18 @@ def handle_commit_review_posttooluse(input_data):
     # commit_review and group by commit_review_on.
     _base = {"commit_review": True, "commit_review_on": COMMIT_REVIEW_ENABLED}
 
+    cwd_root = _git_toplevel(cwd) if cwd else None
+    repo_root = cwd_root
+    repo_res = RES_CWD if cwd_root else RES_NONE
+    if cwd:
+        _cmd_root = toplevel_from_command(command, cwd, COMMIT_SUBCOMMANDS, cwd_root)
+        if _cmd_root and _cmd_root != cwd_root:
+            repo_root, repo_res = _cmd_root, RES_COMMAND
+    if not cwd_root:
+        _base["cwd_is_repo"] = False
+    if repo_res != RES_CWD:
+        _base["repo_resolution"] = repo_res
+
     # 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
@@ -1068,7 +1105,12 @@ def handle_commit_review_posttooluse(input_data):
     _reflog_shas: List[str] = []
     _skip_21_sub = 0
     if not commit_succeeded and not interrupted and cwd:
-        _root = _git_toplevel(cwd)
+        if not repo_root:
+            repo_root = load_repo_hint(session_id)
+            if repo_root:
+                repo_res = RES_HINT
+                _base["repo_resolution"] = repo_res
+        _root = repo_root
         _fresh, _stale = _git_reflog_recent_commits(_root)
         if _fresh:
             _already = _load_reviewed_shas(_root)
@@ -1119,11 +1161,24 @@ def handle_commit_review_posttooluse(input_data):
         emit_metrics({"skipped": True, "skip_reason": 25, **_base})
         sys.exit(0)
 
-    repo_root = _git_toplevel(cwd)
+    if not repo_root and all_shas and not _reflog_shas:
+        repo_root = repo_containing_commit(all_shas[-1], scan_roots(cwd))
+        if repo_root:
+            repo_res = RES_SHA_SCAN
+    if not repo_root:
+        repo_root = load_repo_hint(session_id)
+        if repo_root:
+            repo_res = RES_HINT
+    if repo_res != RES_CWD:
+        _base["repo_resolution"] = repo_res
     if not repo_root:
         debug_log("Commit review: not in a git repo")
         emit_metrics({"skipped": True, "skip_reason": 26, **_base})
         sys.exit(0)
+    if repo_res != RES_CWD:
+        debug_log(f"Commit review: repo resolved via {repo_res} -> {repo_root!r}")
+        if repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
+            save_repo_hint(session_id, repo_root)
 
     # 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
@@ -1150,7 +1205,6 @@ def handle_commit_review_posttooluse(input_data):
         # 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")
@@ -1229,13 +1283,13 @@ def handle_commit_review_posttooluse(input_data):
                 # 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",
+                    [*GIT_CMD, "diff", "--no-color", "--no-ext-diff", "--no-textconv",
                      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, "--"],
+                    [*GIT_CMD, "show", "-p", "--no-color", "--no-ext-diff", "--no-textconv", sha, "--"],
                     cwd=repo_root, capture_output=True, timeout=15
                 )
         except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
@@ -1543,10 +1597,24 @@ def handle_push_sweep_posttooluse(input_data):
     if not cwd:
         emit_metrics({"skipped": True, "skip_reason": 25, **_base})
         sys.exit(0)
-    repo_root = _git_toplevel(cwd)
+    _tip_m = _PUSH_RANGE_RE.search(_push_section(bash_output))
+    cwd_root = _git_toplevel(cwd)
+    repo_root, repo_res = resolve_repo_root(
+        cwd, command, PUSH_SUBCOMMANDS,
+        sha=_tip_m.group(2) if _tip_m else None,
+        session_id=session_id, cwd_root=cwd_root,
+    )
+    if not cwd_root:
+        _base["cwd_is_repo"] = False
+    if repo_res != RES_CWD:
+        _base["repo_resolution"] = repo_res
     if not repo_root:
         emit_metrics({"skipped": True, "skip_reason": 26, **_base})
         sys.exit(0)
+    if repo_res != RES_CWD:
+        debug_log(f"Push sweep: repo resolved via {repo_res} -> {repo_root!r}")
+        if repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
+            save_repo_hint(session_id, repo_root)
 
     # 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
@@ -1842,6 +1910,8 @@ def handle_stop_hook(input_data):
     session_id = input_data.get("session_id", "default")
     stop_hook_active = input_data.get("stop_hook_active", False)
     cwd = input_data.get("cwd", "")
+    hook_event_name = input_data.get("hook_event_name") or "Stop"
+    is_subagent = hook_event_name == "SubagentStop"
 
     # Recursion guard FIRST — consume_stop_state clears touched_paths, and CC
     # sets stop_hook_active session-wide while any asyncRewake Stop is in
@@ -1856,7 +1926,8 @@ def handle_stop_hook(input_data):
     # 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)
+    snap = (consume_stop_state(session_id, clear=False) if is_subagent
+            else consume_stop_state(session_id))
     fire_count = snap["fire_count"]
     touched_paths = snap["touched_paths"]
     baseline_sha = snap["baseline_sha"]
@@ -1920,9 +1991,27 @@ def handle_stop_hook(input_data):
         debug_log("Stop hook: no cwd")
         _skip(4)
 
+    repo_cwd, repo_res = resolve_repo_root(
+        cwd, touched_paths=touched_paths, session_id=session_id)
+    res_metrics = ({} if repo_res == RES_CWD
+                   else {"cwd_is_repo": False, "repo_resolution": repo_res})
+    if is_subagent and repo_cwd:
+        _pd = os.environ.get("CLAUDE_PROJECT_DIR")
+        _pd_root = _git_toplevel(_pd) if _pd and os.path.isdir(_pd) else None
+        if _pd_root and _pd_root != repo_cwd:
+            debug_log(f"Stop hook: SubagentStop in {repo_cwd!r}, session repo is {_pd_root!r}")
+            v2_metrics = dict(res_metrics)
+            _skip(11)
+    if repo_res != RES_CWD and repo_cwd:
+        debug_log(f"Stop hook: repo resolved via {repo_res} -> {repo_cwd!r}")
+        if not is_subagent and repo_res in (RES_COMMAND, RES_TOUCHED_PATHS):
+            save_repo_hint(session_id, repo_cwd)
+        cwd = repo_cwd
+
     review_paths, diff_base, repo_root, untracked, v2_metrics = compute_v2_review_set(
         cwd, baseline_sha, head_at_capture, untracked_at_baseline
     )
+    v2_metrics = {**res_metrics, **v2_metrics}
     if not review_paths:
         debug_log("Stop hook: empty review set")
         _skip(9, touched_paths_count=len(touched_paths))
@@ -1951,6 +2040,11 @@ def handle_stop_hook(input_data):
         debug_log("Stop hook: no changes since baseline")
         _skip(6)
 
+    diff_hash = hashlib.sha256(diff_output.encode("utf-8", "replace")).hexdigest()
+    if snap.get("reviewed_diff_hash") == diff_hash:
+        debug_log("Stop hook: diff already reviewed by SubagentStop")
+        _skip(12)
+
     # Parse diff into per-file content
     diff_files = parse_diff_into_files(diff_output)
     if not diff_files:
@@ -2021,12 +2115,15 @@ def handle_stop_hook(input_data):
             for v in vulns
         ]
         # Update baseline so next stop hook iteration only sees new changes
-        new_sha = capture_git_baseline(cwd)
+        new_sha = None if is_subagent else 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()
+            if is_subagent:
+                state["reviewed_diff_hash"] = diff_hash
+            else:
+                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
@@ -2075,11 +2172,12 @@ def handle_stop_hook(input_data):
             "fire_index": fire_index,
             **({"diff_truncated": llm._last_review_truncated_bytes}
                if llm._last_review_truncated_bytes else {}),
+            **({"repo_resolution": repo_res} if res_metrics else {}),
             **sweep_trimmed,
         }, rewake_summary=_format_vulns_summary(vulns),
            additional_context=(PROVENANCE_BANNER + "\n\n"
                                + concrete_guidance + CONTINUATION_SUFFIX + "\n"),
-           hook_event_name="Stop")
+           hook_event_name=hook_event_name)
         sys.exit(2)
 
     if llm._last_call_claude_http_error is not None:
@@ -2087,6 +2185,8 @@ def handle_stop_hook(input_data):
         restore_unreviewed_stop_state(session_id, touched_paths, snap_baseline)
     else:
         debug_log("Stop hook: no security issues found")
+        if is_subagent:
+            record_reviewed_diff(session_id, diff_hash)
     # 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
@@ -2152,6 +2252,7 @@ def _maybe_bootstrap_agent_sdk_async():
 def main():
     """Main hook function."""
     debug_log(f"Hook called with args: {sys.argv}")
+    apply_safe_git_env()
 
     # Master kill switch — honors ENABLE_SECURITY_REMINDER=0 (legacy) and
     # SECURITY_GUIDANCE_DISABLE=1 (clearer name, no double negative). Emit
@@ -2200,7 +2301,7 @@ def main():
         return
 
     # Handle Stop hook — final security check
-    if hook_event_name == "Stop":
+    if hook_event_name in ("Stop", "SubagentStop"):
         handle_stop_hook(input_data)
         return
 

+ 214 - 0
plugins/security-guidance/tests/conftest.py

@@ -0,0 +1,214 @@
+import http.server
+import json
+import os
+import subprocess
+import sys
+import threading
+import time
+from pathlib import Path
+
+import pytest
+
+HOOKS_DIR = Path(__file__).resolve().parent.parent / "hooks"
+HOOK_SCRIPT = HOOKS_DIR / "security_reminder_hook.py"
+
+sys.path.insert(0, str(HOOKS_DIR))
+
+GIT_ENV = {
+    "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
+    "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com",
+    "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull,
+}
+
+
+def git(cwd, *args):
+    r = subprocess.run(
+        ["git", *args], cwd=cwd, capture_output=True, text=True,
+        env={**os.environ, **GIT_ENV},
+    )
+    assert r.returncode == 0, r.stderr
+    return r.stdout
+
+
+def make_repo(path, files=None):
+    path.mkdir(parents=True, exist_ok=True)
+    git(path, "init", "-q", "-b", "main")
+    git(path, "config", "commit.gpgsign", "false")
+    for name, content in (files or {"README.md": "init\n"}).items():
+        p = path / name
+        p.parent.mkdir(parents=True, exist_ok=True)
+        p.write_text(content)
+    git(path, "add", "-A")
+    git(path, "commit", "-q", "-m", "init")
+    return path
+
+
+def commit_file(repo, name, content, msg="change"):
+    p = repo / name
+    p.parent.mkdir(parents=True, exist_ok=True)
+    p.write_text(content)
+    git(repo, "add", "-A")
+    out = subprocess.run(
+        ["git", "commit", "-m", msg], cwd=repo, capture_output=True, text=True,
+        env={**os.environ, **GIT_ENV},
+    )
+    assert out.returncode == 0, out.stderr
+    sha = git(repo, "rev-parse", "HEAD").strip()
+    return sha, out.stdout + out.stderr
+
+
+VULN_PY = (
+    "import subprocess\n"
+    "def run(user):\n"
+    "    subprocess.call('ls ' + user, shell=True)\n"
+)
+
+
+@pytest.fixture
+def workspace(tmp_path):
+    ws = tmp_path / "ws"
+    ws.mkdir()
+    repo = make_repo(ws / "sub", {"app.py": "print('hi')\n"})
+    (ws / "node_modules" / "junk").mkdir(parents=True)
+    return ws, repo
+
+
+class _Stub(http.server.BaseHTTPRequestHandler):
+    def do_POST(self):
+        n = int(self.headers.get("Content-Length") or 0)
+        if n:
+            self.rfile.read(n)
+        self.server.calls.append(self.path)
+        if self.server.delay:
+            time.sleep(self.server.delay)
+        if self.server.status == 200:
+            vulns = list(self.server.vulns)
+            body = json.dumps({
+                "id": "msg_stub", "type": "message", "role": "assistant",
+                "model": "stub", "stop_reason": "end_turn",
+                "content": [{"type": "text", "text": json.dumps(
+                    {"hasVulnerabilities": bool(vulns), "vulnerabilities": vulns})}],
+                "usage": {"input_tokens": 1, "output_tokens": 1},
+            }).encode()
+        else:
+            body = b'{"type":"error","error":{"type":"invalid_request_error","message":"stub"}}'
+        self.send_response(self.server.status)
+        self.send_header("Content-Type", "application/json")
+        self.send_header("Content-Length", str(len(body)))
+        self.end_headers()
+        self.wfile.write(body)
+
+    do_HEAD = do_GET = do_POST
+
+    def log_message(self, *a):
+        pass
+
+
+@pytest.fixture
+def stub_api():
+    srv = http.server.HTTPServer(("127.0.0.1", 0), _Stub)
+    srv.calls = []
+    srv.status = 200
+    srv.delay = 0
+    srv.vulns = []
+    t = threading.Thread(target=srv.serve_forever, daemon=True)
+    t.start()
+    try:
+        yield srv
+    finally:
+        srv.shutdown()
+
+
+@pytest.fixture
+def hook_env(tmp_path, stub_api):
+    state = tmp_path / "state"
+    state.mkdir()
+    env = {k: v for k, v in os.environ.items()
+           if k not in ("ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_REMOTE",
+                        "CLAUDE_PROJECT_DIR", "HTTP_PROXY", "HTTPS_PROXY",
+                        "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy",
+                        "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX",
+                        "CLAUDE_CODE_USE_FOUNDRY")}
+    env.update(GIT_ENV)
+    env.update({
+        "SECURITY_WARNINGS_STATE_DIR": str(state),
+        "ANTHROPIC_API_KEY": "test-key",
+        "ANTHROPIC_BASE_URL": f"http://127.0.0.1:{stub_api.server_port}",
+        "NO_PROXY": "*", "no_proxy": "*",
+        "SG_AGENTIC_COMMIT_REVIEW": "0",
+        "SECURITY_GUIDANCE_COMMIT_REVIEW": "on",
+        "SG_PUSH_SWEEP": "on",
+        "PYTHONDONTWRITEBYTECODE": "1",
+    })
+    return env
+
+
+def run_hook(payload, env, python=sys.executable):
+    r = subprocess.run(
+        [python, str(HOOK_SCRIPT)], input=json.dumps(payload),
+        capture_output=True, text=True, env=env, timeout=120,
+    )
+    return r.returncode, r.stdout, r.stderr
+
+
+STUB_VULN = {
+    "filePath": "app.py", "category": "command_injection", "severity": "high",
+    "vulnerableCode": "subprocess.call('ls ' + user, shell=True)",
+    "description": "user input reaches a shell", "recommendation": "pass an argv list",
+}
+
+
+def metrics_of(stdout):
+    for line in stdout.splitlines():
+        line = line.strip()
+        if line.startswith("{"):
+            try:
+                m = json.loads(line).get("metrics")
+            except json.JSONDecodeError:
+                continue
+            if m is not None:
+                return m
+    return None
+
+
+def bash_payload(cwd, command, stdout="", stderr="", session_id="s1", tool_use_id=None):
+    p = {
+        "session_id": session_id,
+        "hook_event_name": "PostToolUse",
+        "tool_name": "Bash",
+        "tool_input": {"command": command},
+        "tool_response": {"stdout": stdout, "stderr": stderr, "interrupted": False},
+        "cwd": str(cwd),
+    }
+    if tool_use_id:
+        p["tool_use_id"] = tool_use_id
+    return p
+
+
+def edit_payload(cwd, file_path, new_string="x", session_id="s1"):
+    return {
+        "session_id": session_id,
+        "hook_event_name": "PostToolUse",
+        "tool_name": "Edit",
+        "tool_input": {"file_path": str(file_path), "old_string": "", "new_string": new_string},
+        "tool_response": {},
+        "cwd": str(cwd),
+    }
+
+
+def stop_payload(cwd, event="Stop", session_id="s1"):
+    return {
+        "session_id": session_id,
+        "hook_event_name": event,
+        "stop_hook_active": False,
+        "cwd": str(cwd),
+    }
+
+
+def ups_payload(cwd, session_id="s1"):
+    return {
+        "session_id": session_id,
+        "hook_event_name": "UserPromptSubmit",
+        "prompt": "hi",
+        "cwd": str(cwd),
+    }

+ 619 - 0
plugins/security-guidance/tests/test_repo_resolution.py

@@ -0,0 +1,619 @@
+import json
+import os
+import subprocess
+import threading
+import time
+
+import pytest
+
+from conftest import (
+    GIT_ENV, HOOKS_DIR, STUB_VULN, VULN_PY, bash_payload, commit_file, edit_payload,
+    git, make_repo, metrics_of, run_hook, stop_payload, ups_payload,
+)
+
+import gitutil
+import reporesolve as rr
+import security_reminder_hook as hook
+
+
+
+class TestDirsFromCommand:
+    def test_cd_and_and(self, workspace):
+        ws, repo = workspace
+        assert rr.dirs_from_command("cd sub && git commit -m x", str(ws)) == [str(repo)]
+
+    def test_cd_semicolon_subshell(self, workspace):
+        ws, repo = workspace
+        assert rr.dirs_from_command("(cd sub; git commit -m x)", str(ws)) == [str(repo)]
+
+    def test_git_dash_C(self, workspace):
+        ws, repo = workspace
+        assert rr.dirs_from_command("git -C sub commit -q -m x", str(ws)) == [str(repo)]
+
+    def test_git_dash_C_absolute_and_quoted(self, tmp_path):
+        d = tmp_path / "my repo"
+        d.mkdir()
+        assert rr.dirs_from_command(f'git -C "{d}" commit -m "a b"', "/nonexistent") == [str(d)]
+
+    def test_env_prefix_and_config_opts(self, workspace):
+        ws, repo = workspace
+        cmd = "FOO=1 git -c user.name=x -C sub commit -m x"
+        assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
+
+    def test_git_dir_work_tree(self, workspace):
+        ws, repo = workspace
+        cmd = "git --git-dir=sub/.git --work-tree=sub commit -m x"
+        assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
+        cmd = "git --git-dir sub/.git commit -m x"
+        assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
+
+    def test_subcommand_filter(self, workspace):
+        ws, repo = workspace
+        cmd = "git -C other status && git -C sub commit -m x && git -C third push"
+        assert rr.dirs_from_command(cmd, str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
+        assert rr.dirs_from_command(cmd, str(ws), rr.PUSH_SUBCOMMANDS) == [str(ws / "third")]
+
+    def test_gt(self, workspace):
+        ws, repo = workspace
+        assert rr.dirs_from_command("cd sub && gt create -m x", str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
+
+    def test_pushd_and_relative_chain(self, workspace):
+        ws, repo = workspace
+        cmd = "pushd sub && cd .. && cd ./sub && git commit -m x"
+        assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
+
+    @pytest.mark.parametrize("cmd", [
+        'git -C "unterminated commit -m x',
+        "git -C=sub commit -m x",
+        "cd && git commit",
+        "cd - && git commit -m x",
+        "git",
+        "git -C",
+        "cd /definitely/not/here && git commit -m x",
+        "echo 'git commit' | cat",
+        "git commit -m \"$(cat <<'EOF'\nmsg\nEOF\n)\"",
+        "",
+        None,
+    ])
+    def test_odd_inputs_do_not_raise(self, workspace, cmd):
+        ws, _ = workspace
+        out = rr.dirs_from_command(cmd, str(ws))
+        assert isinstance(out, list)
+        assert rr.toplevel_from_command(cmd, str(ws)) in (None, str(ws / "sub"))
+
+    def test_toplevel_from_command_nonexistent_dir(self, workspace):
+        ws, _ = workspace
+        assert rr.toplevel_from_command("cd nope && git commit -m x", str(ws)) is None
+
+    def test_toplevel_from_subdir_of_repo(self, workspace):
+        ws, repo = workspace
+        (repo / "pkg").mkdir()
+        assert rr.toplevel_from_command("cd sub/pkg && git commit -m x", str(ws)) == str(repo)
+
+    def test_windows_backslash_paths_survive_tokenizing(self, workspace, monkeypatch):
+        ws, _ = workspace
+        monkeypatch.setattr(rr.os, "sep", "\\")
+        toks = rr._tokenize(r'git -C C:\Users\me\repo commit -m x && git -C "D:\a b\r" push')
+        assert r"C:\Users\me\repo" in toks and r"D:\a b\r" in toks
+        toks = rr._tokenize(r"git -C \\srv\share\repo commit -m x")
+        assert r"\\srv\share\repo" in toks
+
+
+class TestShaScan:
+    def test_finds_repo_containing_commit(self, workspace):
+        ws, repo = workspace
+        sha, _ = commit_file(repo, "app.py", VULN_PY)
+        assert rr.repo_containing_commit(sha[:7], [str(ws)]) == str(repo)
+        assert rr.repo_containing_commit(sha, [str(ws)]) == str(repo)
+
+    def test_unknown_sha(self, workspace):
+        ws, _ = workspace
+        assert rr.repo_containing_commit("deadbeefdeadbeef", [str(ws)]) is None
+
+    def test_invalid_sha(self, workspace):
+        ws, _ = workspace
+        assert rr.repo_containing_commit("HEAD; rm -rf x", [str(ws)]) is None
+        assert rr.repo_containing_commit(None, [str(ws)]) is None
+
+    def test_skips_ignored_dirs_and_respects_depth(self, tmp_path):
+        ws = tmp_path / "ws"
+        deep = make_repo(ws / "a" / "b" / "c" / "d" / "repo")
+        nm = make_repo(ws / "node_modules" / "pkg")
+        found = list(rr.iter_git_repos([str(ws)], max_depth=3))
+        assert str(deep) not in found and str(nm) not in found
+        found = list(rr.iter_git_repos([str(ws)], max_depth=6))
+        assert str(deep) in found and str(nm) not in found
+
+    def test_max_repos_cap(self, tmp_path):
+        ws = tmp_path / "ws"
+        for i in range(4):
+            make_repo(ws / f"r{i}")
+        assert len(list(rr.iter_git_repos([str(ws)], max_repos=2))) == 2
+
+
+class TestReposFromPaths:
+    def test_groups_by_toplevel_and_orders_by_count(self, tmp_path):
+        ws = tmp_path / "ws"
+        a = make_repo(ws / "a")
+        b = make_repo(ws / "b")
+        paths = [str(b / "x.py"), str(a / "one.py"), str(a / "pkg" / "two.py"),
+                 str(ws / "loose.txt"), "", None, 42]
+        assert rr.repos_from_paths(paths, str(ws)) == [str(a), str(b)]
+
+    def test_relative_paths_resolve_against_cwd(self, workspace):
+        ws, repo = workspace
+        assert rr.repos_from_paths(["sub/app.py"], str(ws)) == [str(repo)]
+
+    def test_missing_parent_dirs(self, workspace):
+        ws, repo = workspace
+        assert rr.repos_from_paths([str(repo / "new" / "deeper" / "f.py")], str(ws)) == [str(repo)]
+
+
+class TestResolveRepoRoot:
+    def test_cwd_is_repo(self, workspace):
+        ws, repo = workspace
+        assert rr.resolve_repo_root(str(repo), "cd /tmp && git commit") == (str(repo), rr.RES_CWD)
+
+    def test_cwd_is_repo_same_repo_in_command_stays_cwd(self, workspace):
+        ws, repo = workspace
+        (repo / "pkg").mkdir()
+        for cmd in ("git commit -m x", "cd pkg && git commit -m x", f"git -C {repo} commit -m x",
+                    "git -C pkg commit -m x", "git -C nope commit -m x"):
+            assert rr.resolve_repo_root(str(repo), cmd, rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD), cmd
+
+    def test_explicit_other_repo_in_command_beats_cwd(self, workspace):
+        ws, repo = workspace
+        other = make_repo(ws / "other")
+        assert rr.resolve_repo_root(str(repo), f"git -C {other} commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
+        assert rr.resolve_repo_root(str(repo), "git -C ../other push", rr.PUSH_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
+        assert rr.resolve_repo_root(str(repo), "cd ../other && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
+        assert rr.resolve_repo_root(str(repo), "git --git-dir=../other/.git --work-tree=../other commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
+        assert rr.resolve_repo_root(str(repo), f"git -C {other} status && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD)
+
+    def test_order_command_then_paths_then_sha(self, workspace):
+        ws, repo = workspace
+        other = make_repo(ws / "other")
+        sha, _ = commit_file(repo, "app.py", VULN_PY)
+        assert rr.resolve_repo_root(str(ws), "git -C other commit", rr.COMMIT_SUBCOMMANDS,
+                                    sha=sha, touched_paths=[str(repo / "app.py")]) == (str(other), rr.RES_COMMAND)
+        assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
+                                    sha=sha, touched_paths=[str(repo / "app.py")]) == (str(repo), rr.RES_TOUCHED_PATHS)
+        assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
+                                    sha=sha) == (str(repo), rr.RES_SHA_SCAN)
+        assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
+
+
+class TestRegexes:
+    @pytest.mark.parametrize("cmd", [
+        "git commit -m x",
+        "git -C sub commit -m x",
+        'git -C "a b" commit -m x',
+        "git -c core.editor=true -C sub commit --amend",
+        "git --git-dir=x/.git --work-tree=x commit -m x",
+        "git --git-dir x/.git commit -m x",
+        "cd sub && git commit -m x",
+        "gt create -m x",
+    ])
+    def test_commit_re(self, cmd):
+        assert hook._GIT_COMMIT_RE.search(cmd)
+
+    @pytest.mark.parametrize("cmd", [
+        "git push", "git -C sub push origin main", 'git -C "a b" push',
+        "git --work-tree x push -u origin HEAD", "gt submit",
+    ])
+    def test_push_re(self, cmd):
+        assert hook._GIT_PUSH_RE.search(cmd)
+
+    @pytest.mark.parametrize("cmd", ["git status", "git log --oneline", "echo commit"])
+    def test_commit_re_negative(self, cmd):
+        assert not hook._GIT_COMMIT_RE.search(cmd)
+
+    @pytest.mark.parametrize("unit", ['-c "a"', "-c 'a'", "--no-a=b", "--a=b=c=d", "--git-dir x"])
+    @pytest.mark.parametrize("regex,verb", [("_GIT_COMMIT_RE", "commit"), ("_GIT_PUSH_RE", "push")])
+    def test_global_option_prefix_is_linear(self, regex, verb, unit):
+        cre = getattr(hook, regex)
+        prefix = "git " + " ".join([unit] * 40)
+        t0 = time.perf_counter()
+        assert not cre.search(prefix + " " + verb + "foo")
+        assert time.perf_counter() - t0 < 0.05
+        assert cre.search(prefix + " " + verb + " -m x")
+
+
+class TestHooksJson:
+    def test_matchers_and_events(self):
+        cfg = json.loads((HOOKS_DIR / "hooks.json").read_text())
+        assert "SubagentStop" in cfg["hooks"]
+        assert cfg["hooks"]["SubagentStop"][0]["hooks"][0]["command"] == \
+            cfg["hooks"]["Stop"][0]["hooks"][0]["command"]
+        bash = [g for g in cfg["hooks"]["PostToolUse"] if g.get("matcher") == "Bash"][0]
+        ifs = {h.get("if") for h in bash["hooks"]}
+        assert {"Bash(git commit:*)", "Bash(git push:*)", "Bash(git -C * commit *)",
+                "Bash(git -C * push*)", "Bash(gt create:*)", "Bash(gt modify:*)",
+                "Bash(gt submit:*)"} <= ifs
+
+
+
+def _read_state(env):
+    d = env["SECURITY_WARNINGS_STATE_DIR"]
+    files = [e.path for e in os.scandir(d) if e.name.endswith(".json")]
+    assert files, os.listdir(d)
+    with open(files[0]) as f:
+        return json.load(f)
+
+
+def _read_state_or_empty(env):
+    d = env["SECURITY_WARNINGS_STATE_DIR"]
+    if not any(e.name.endswith(".json") for e in os.scandir(d)):
+        return {}
+    return _read_state(env)
+
+
+class TestCommitReview:
+    def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(repo, "git commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert m["commit_review"] is True
+        assert "cwd_is_repo" not in m and "repo_resolution" not in m
+        assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
+        assert stub_api.calls
+
+    def test_cwd_is_repo_cd_subdir_unchanged(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        (repo / "pkg").mkdir()
+        sha, out = commit_file(repo, "pkg/app.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(repo, "cd pkg && git commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert "cwd_is_repo" not in m and "repo_resolution" not in m
+        assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
+
+    def test_cwd_is_repo_dash_C_other_repo_reviews_other_repo(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        other = make_repo(ws / "other")
+        sha, out = commit_file(other, "srv.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(repo, f"git -C {other} commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert "cwd_is_repo" not in m and m["repo_resolution"] == rr.RES_COMMAND
+        assert m["files_reviewed"] == 1
+        assert stub_api.calls
+        assert (other / ".git" / "sg-reviewed-shas").exists()
+        assert not (repo / ".git" / "sg-reviewed-shas").exists()
+
+    def test_workspace_cwd_cd_sub(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
+        assert m["files_reviewed"] == 1
+        assert stub_api.calls
+
+    def test_workspace_cwd_git_dash_C_quiet_uses_reflog(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        sha, _ = commit_file(repo, "app.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(ws, "git -C sub commit -q -m change", ""), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["repo_resolution"] == rr.RES_COMMAND and m["sha_via_reflog"] is True
+        assert m["files_reviewed"] == 1
+
+    def test_workspace_cwd_sha_scan(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        rc, so, se = run_hook(bash_payload(ws, "git commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["repo_resolution"] == rr.RES_SHA_SCAN and m["files_reviewed"] == 1
+        assert "repo_root_hint" not in _read_state_or_empty(hook_env)
+
+    def test_workspace_cwd_sha_scan_via_project_dir(self, workspace, hook_env, tmp_path):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        elsewhere = tmp_path / "elsewhere"
+        elsewhere.mkdir()
+        env = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
+        rc, so, se = run_hook(bash_payload(elsewhere, "git commit -m change", out), env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["repo_resolution"] == rr.RES_SHA_SCAN
+
+    def test_hint_saved_and_used(self, workspace, hook_env):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
+        state = _read_state(hook_env)
+        assert state.get("repo_root_hint") == str(repo)
+        assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
+        os.environ["SECURITY_WARNINGS_STATE_DIR"] = hook_env["SECURITY_WARNINGS_STATE_DIR"]
+        try:
+            assert rr.load_repo_hint("s1") == str(repo)
+        finally:
+            os.environ.pop("SECURITY_WARNINGS_STATE_DIR", None)
+
+    def test_hint_used_for_quiet_commit_without_dir(self, workspace, hook_env):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
+        commit_file(repo, "app.py", VULN_PY + "# 2\n")
+        rc, so, se = run_hook(bash_payload(ws, "git commit -q -m change", ""), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["repo_resolution"] == rr.RES_HINT and m["sha_via_reflog"] is True
+
+    def test_unresolvable_still_skips_26(self, workspace, hook_env, tmp_path):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        empty = tmp_path / "empty"
+        empty.mkdir()
+        rc, so, se = run_hook(bash_payload(empty, "git commit -m change", out), hook_env)
+        m = metrics_of(so)
+        assert m["skip_reason"] == 26 and m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_NONE
+
+    def test_no_credentials_gate_precedes_repo_resolution(self, workspace, hook_env):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        env = {k: v for k, v in hook_env.items() if k != "ANTHROPIC_API_KEY"}
+        rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), env)
+        m = metrics_of(so)
+        assert m["skip_reason"] == 22 and m["repo_resolution"] == rr.RES_COMMAND
+
+    def test_dedup_sentinel_uses_resolved_repo(self, workspace, hook_env):
+        ws, repo = workspace
+        sha, out = commit_file(repo, "app.py", VULN_PY)
+        p = bash_payload(ws, "git -C sub commit -m change && git -C sub push", out, tool_use_id="toolu_1")
+        rc1, so1, _ = run_hook(p, hook_env)
+        rc2, so2, _ = run_hook(p, hook_env)
+        assert metrics_of(so1).get("commit_review") is True
+        assert metrics_of(so2) == {"bash_hook_dedup": True}
+
+
+class TestPushSweep:
+    def test_workspace_cwd_git_dash_C_push(self, workspace, hook_env, tmp_path):
+        ws, repo = workspace
+        remote = tmp_path / "remote.git"
+        git(ws, "init", "-q", "--bare", str(remote))
+        git(repo, "remote", "add", "origin", str(remote))
+        git(repo, "push", "-q", "-u", "origin", "main")
+        base = git(repo, "rev-parse", "HEAD").strip()
+        sha, _ = commit_file(repo, "app.py", VULN_PY)
+        out = git(repo, "push", "--porcelain", "origin", "main")
+        push_stdout = f"To {remote}\n   {base[:7]}..{sha[:7]}  main -> main\n"
+        rc, so, se = run_hook(bash_payload(ws, "git -C sub push origin main", push_stdout), hook_env)
+        m = metrics_of(so)
+        assert m["push_sweep"] is True
+        assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
+        assert m.get("skip_reason") != 26
+        assert m.get("pushed") == 1
+
+    def test_workspace_cwd_sha_scan_push_leaves_no_hint(self, workspace, hook_env, tmp_path):
+        ws, repo = workspace
+        remote = tmp_path / "remote.git"
+        git(ws, "init", "-q", "--bare", str(remote))
+        git(repo, "remote", "add", "origin", str(remote))
+        git(repo, "push", "-q", "-u", "origin", "main")
+        base = git(repo, "rev-parse", "HEAD").strip()
+        sha, _ = commit_file(repo, "app.py", VULN_PY)
+        git(repo, "push", "-q", "origin", "main")
+        push_stdout = f"To {remote}\n   {base[:7]}..{sha[:7]}  main -> main\n"
+        rc, so, se = run_hook(bash_payload(ws, "git push origin main", push_stdout), hook_env)
+        m = metrics_of(so)
+        assert m["push_sweep"] is True and m["repo_resolution"] == rr.RES_SHA_SCAN, m
+        assert m.get("skip_reason") != 26
+        assert "repo_root_hint" not in _read_state_or_empty(hook_env)
+
+    def test_cwd_is_repo_dash_C_other_repo(self, workspace, hook_env, tmp_path):
+        ws, repo = workspace
+        other = make_repo(ws / "other")
+        remote = tmp_path / "remote.git"
+        git(ws, "init", "-q", "--bare", str(remote))
+        git(other, "remote", "add", "origin", str(remote))
+        git(other, "push", "-q", "-u", "origin", "main")
+        base = git(other, "rev-parse", "HEAD").strip()
+        sha, _ = commit_file(other, "srv.py", VULN_PY)
+        git(other, "push", "-q", "origin", "main")
+        push_stdout = f"To {remote}\n   {base[:7]}..{sha[:7]}  main -> main\n"
+        rc, so, se = run_hook(bash_payload(repo, f"git -C {other} push origin main", push_stdout), hook_env)
+        m = metrics_of(so)
+        assert m["push_sweep"] is True and "cwd_is_repo" not in m
+        assert m["repo_resolution"] == rr.RES_COMMAND and m.get("pushed") == 1
+
+
+class TestStop:
+    def _touch(self, ws, repo, hook_env, session_id="s1"):
+        (repo / "app.py").write_text(VULN_PY)
+        run_hook(edit_payload(ws, repo / "app.py", VULN_PY, session_id=session_id), hook_env)
+
+    def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(repo), hook_env)
+        self._touch(repo, repo, hook_env)
+        rc, so, se = run_hook(stop_payload(repo), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert "repo_resolution" not in m and m["files_reviewed"] == 1
+        assert stub_api.calls
+
+    def test_workspace_cwd_resolves_from_touched_paths(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(ws), hook_env)
+        self._touch(ws, repo, hook_env)
+        rc, so, se = run_hook(stop_payload(ws), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_TOUCHED_PATHS
+        assert m["files_reviewed"] == 1 and m["review_set_count"] == 1
+        assert stub_api.calls
+
+    def test_subagent_stop_reviews_without_consuming_session_state(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(ws), hook_env)
+        self._touch(ws, repo, hook_env)
+        before = _read_state(hook_env)
+        rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["repo_resolution"] == rr.RES_TOUCHED_PATHS and m["files_reviewed"] == 1
+        after = _read_state(hook_env)
+        assert after["touched_paths"] == before["touched_paths"] != []
+        assert after.get("baseline_sha") == before.get("baseline_sha")
+        assert after.get("reviewed_diff_hash")
+        assert "repo_root_hint" not in after
+        n_calls = len(stub_api.calls)
+        rc, so, se = run_hook(stop_payload(ws), hook_env)
+        m2 = metrics_of(so)
+        assert m2["skip_reason"] == 12, m2
+        assert m2["repo_resolution"] == rr.RES_TOUCHED_PATHS
+        assert len(stub_api.calls) == n_calls
+        final = _read_state(hook_env)
+        assert final["touched_paths"] == [] and "reviewed_diff_hash" not in final
+
+    def test_main_edits_during_subagent_review_are_reviewed_at_stop(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(repo), hook_env)
+        self._touch(repo, repo, hook_env)
+        stub_api.delay = 3
+        res = {}
+        t = threading.Thread(target=lambda: res.update(
+            sub=run_hook(stop_payload(repo, event="SubagentStop"), hook_env)))
+        t.start()
+        time.sleep(1.5)
+        (repo / "b.py").write_text("import os\nos.system(input())\n")
+        run_hook(edit_payload(repo, repo / "b.py", "x"), hook_env)
+        t.join()
+        stub_api.delay = 0
+        m_sub = metrics_of(res["sub"][1])
+        assert m_sub.get("skip_reason") is None and m_sub["files_reviewed"] == 1, m_sub
+        n_calls = len(stub_api.calls)
+        rc, so, se = run_hook(stop_payload(repo), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None, m
+        assert m["files_reviewed"] == 2 and m["touched_paths_count"] == 2
+        assert len(stub_api.calls) > n_calls
+
+    def test_subagent_stop_findings_do_not_advance_baseline_or_fire_count(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(repo), hook_env)
+        self._touch(repo, repo, hook_env)
+        before = _read_state(hook_env)
+        stub_api.vulns = [STUB_VULN]
+        rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), hook_env)
+        m = metrics_of(so)
+        assert rc == 2 and m["vulns_found"] == 1, (rc, m)
+        assert "repo_resolution" not in m and "cwd_is_repo" not in m
+        after = _read_state(hook_env)
+        assert after.get("baseline_sha") == before.get("baseline_sha")
+        assert not after.get("stop_hook_fire_count")
+        assert after["touched_paths"] == before["touched_paths"]
+        assert len(after.get("previous_findings", [])) == 1 and after.get("reviewed_diff_hash")
+        rc, so, se = run_hook(stop_payload(repo), hook_env)
+        assert metrics_of(so)["skip_reason"] == 12
+        (repo / "app.py").write_text(VULN_PY + "x = 1\n")
+        run_hook(edit_payload(repo, repo / "app.py", "x"), hook_env)
+        rc, so, se = run_hook(stop_payload(repo), hook_env)
+        m3 = metrics_of(so)
+        assert rc == 2 and m3.get("skip_reason") is None and m3["files_reviewed"] == 1, m3
+        assert _read_state(hook_env)["stop_hook_fire_count"] == 1
+
+    def test_subagent_stop_in_other_worktree_is_skipped(self, workspace, hook_env, stub_api, tmp_path):
+        ws, repo = workspace
+        wt = tmp_path / "wt"
+        git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
+        run_hook(ups_payload(repo), hook_env)
+        self._touch(repo, repo, hook_env)
+        before = _read_state(hook_env)
+        (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
+        run_hook(edit_payload(wt, wt / "new.py", "x"), hook_env)
+        env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
+        rc, so, se = run_hook(stop_payload(wt, event="SubagentStop"), env)
+        m = metrics_of(so)
+        assert m["skip_reason"] == 11, m
+        assert not stub_api.calls
+        after = _read_state(hook_env)
+        assert after.get("baseline_sha") == before.get("baseline_sha")
+        assert after.get("head_at_capture") == before.get("head_at_capture")
+        rc, so, se = run_hook(stop_payload(repo), env)
+        m2 = metrics_of(so)
+        assert m2.get("skip_reason") is None and m2["files_reviewed"] == 1, m2
+
+    def test_subagent_stop_in_other_worktree_from_workspace_cwd(self, workspace, hook_env, stub_api, tmp_path):
+        ws, repo = workspace
+        wt = tmp_path / "wt"
+        git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
+        run_hook(ups_payload(ws), hook_env)
+        (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
+        run_hook(edit_payload(ws, wt / "new.py", "x"), hook_env)
+        env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
+        rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), env)
+        m = metrics_of(so)
+        assert m["skip_reason"] == 11 and m["repo_resolution"] == rr.RES_TOUCHED_PATHS, m
+        assert not stub_api.calls
+        assert "repo_root_hint" not in _read_state(hook_env)
+
+    def test_repository_config_cannot_run_programs(self, workspace, hook_env, stub_api, tmp_path):
+        ws, repo = workspace
+        marker = tmp_path / "marker"
+        mon = tmp_path / "mon.sh"
+        mon.write_text(f"#!/bin/sh\necho ran >> '{marker}'\n")
+        mon.chmod(0o755)
+        git(repo, "config", "core.fsmonitor", str(mon))
+        clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")}
+        subprocess.run(["git", "status"], cwd=repo, env={**clean, **GIT_ENV}, capture_output=True)
+        assert marker.exists()
+        marker.unlink()
+        run_hook(ups_payload(ws), hook_env)
+        self._touch(ws, repo, hook_env)
+        rc, so, se = run_hook(stop_payload(ws), hook_env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
+        assert not marker.exists()
+        sha, out = commit_file(repo, "app.py", VULN_PY + "z = 3\n")
+        marker.unlink(missing_ok=True)
+        rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
+        assert metrics_of(so).get("files_reviewed") == 1
+        assert not marker.exists()
+
+    def test_safe_git_env_extends_existing_config_count(self):
+        base = {"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "a.b", "GIT_CONFIG_VALUE_0": "1",
+                "GIT_CONFIG_KEY_1": "c.d", "GIT_CONFIG_VALUE_1": "2"}
+        env = gitutil.git_config_env(gitutil.SAFE_GIT_CONFIG, base=base)
+        assert env["GIT_CONFIG_COUNT"] == str(2 + len(gitutil.SAFE_GIT_CONFIG))
+        assert "GIT_CONFIG_KEY_0" not in env and "GIT_CONFIG_KEY_1" not in env
+        got = {env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"]
+               for i in range(2, int(env["GIT_CONFIG_COUNT"]))}
+        assert got == dict(gitutil.SAFE_GIT_CONFIG)
+        assert gitutil.git_config_env((("x.y", "z"),), base={"GIT_CONFIG_COUNT": "junk"})["GIT_CONFIG_COUNT"] == "1"
+
+    def test_subagent_stop_same_repo_with_project_dir_reviews(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        (repo / "pkg").mkdir()
+        run_hook(ups_payload(repo), hook_env)
+        self._touch(repo, repo, hook_env)
+        env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
+        rc, so, se = run_hook(stop_payload(repo / "pkg", event="SubagentStop"), env)
+        m = metrics_of(so)
+        assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
+        env_ws = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
+        (repo / "app.py").write_text(VULN_PY + "y = 2\n")
+        rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), env_ws)
+        assert metrics_of(so).get("skip_reason") is None
+
+    def test_ups_uses_hint_for_baseline(self, workspace, hook_env):
+        ws, repo = workspace
+        run_hook(ups_payload(ws), hook_env)
+        self._touch(ws, repo, hook_env)
+        run_hook(stop_payload(ws), hook_env)
+        (repo / "app.py").write_text(VULN_PY + "\n# more\n")
+        run_hook(ups_payload(ws, session_id="s1"), hook_env)
+        state = _read_state(hook_env)
+        assert state.get("baseline_sha") and state.get("repo_root_hint") == str(repo)
+
+    def test_workspace_cwd_nothing_touched_skips(self, workspace, hook_env, stub_api):
+        ws, repo = workspace
+        run_hook(ups_payload(ws), hook_env)
+        rc, so, se = run_hook(stop_payload(ws), hook_env)
+        m = metrics_of(so)
+        assert m["skip_reason"] == 9 and m["repo_resolution"] == rr.RES_NONE
+        assert not stub_api.calls