Explorar el Código

security-guidance: resolve repository from command and edited paths (2.0.7 → 2.0.8)

- Adds repository resolution from `git -C <dir>` / `cd <dir>` in the hook
  command, from the commit SHA, and from edited file paths when the hook
  cwd is not a git repository.
- Adds PostToolUse matchers for `git -C … commit|push`.
- Registers SubagentStop.
- Adds `repo_resolution` / `cwd_is_repo` metric fields.
- Adds tests.

:house: Remote-Dev: homespace
Octavian Guzu hace 18 horas
padre
commit
0b2f3e30a9

+ 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",

+ 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"
+          }
+        ]
+      }
     ]
   }
 }

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

@@ -0,0 +1,297 @@
+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):
+    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):
+    for d in dirs_from_command(command, cwd, subcommands):
+        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
+
+
+def resolve_repo_root(cwd, command=None, subcommands=None, sha=None,
+                      touched_paths=None, session_id=None):
+    cwd_root = _git_toplevel(cwd) if cwd else None
+    if cwd_root:
+        return cwd_root, RES_CWD
+    if command:
+        top = toplevel_from_command(command, cwd, subcommands)
+        if top:
+            return top, RES_COMMAND
+    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

+ 95 - 10
plugins/security-guidance/hooks/security_reminder_hook.py

@@ -118,6 +118,12 @@ from diffstate import (  # noqa: E402,F401
     _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 +546,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 +694,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+'
+    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 +738,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+'
+    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 +777,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 +1064,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 +1075,16 @@ 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}
 
+    repo_root = _git_toplevel(cwd) if cwd else None
+    repo_res = RES_CWD if repo_root else RES_NONE
+    if not repo_root and cwd:
+        repo_root = toplevel_from_command(command, cwd, COMMIT_SUBCOMMANDS)
+        if repo_root:
+            repo_res = RES_COMMAND
+    if repo_res != RES_CWD:
+        _base["cwd_is_repo"] = False
+        _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 +1102,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 +1158,23 @@ 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}")
+        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 +1201,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")
@@ -1543,10 +1593,21 @@ 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))
+    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,
+    )
+    if repo_res != RES_CWD:
+        _base["cwd_is_repo"] = False
+        _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}")
+        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 +1903,7 @@ 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"
 
     # Recursion guard FIRST — consume_stop_state clears touched_paths, and CC
     # sets stop_hook_active session-wide while any asyncRewake Stop is in
@@ -1920,9 +1982,20 @@ 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:
+        res_metrics = {"cwd_is_repo": False, "repo_resolution": repo_res}
+        if repo_cwd:
+            debug_log(f"Stop hook: repo resolved via {repo_res} -> {repo_cwd!r}")
+            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))
@@ -2075,11 +2148,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 {}),
+            **res_metrics,
             **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 +2161,17 @@ 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 hook_event_name == "SubagentStop":
+            new_sha = capture_git_baseline(cwd)
+            if new_sha:
+                new_untracked_baseline = _list_untracked(cwd)
+                new_head = _git_rev_parse_head(cwd)
+
+                def _advance(state):
+                    state["baseline_sha"] = new_sha
+                    state["untracked_at_baseline"] = new_untracked_baseline
+                    state["head_at_capture"] = new_head
+                with_locked_state(session_id, _advance)
     # 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
@@ -2200,7 +2285,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
 

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

@@ -0,0 +1,201 @@
+import http.server
+import json
+import os
+import subprocess
+import sys
+import threading
+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.status == 200:
+            body = json.dumps({
+                "id": "msg_stub", "type": "message", "role": "assistant",
+                "model": "stub", "stop_reason": "end_turn",
+                "content": [{"type": "text", "text": json.dumps(
+                    {"hasVulnerabilities": False, "vulnerabilities": []})}],
+                "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
+    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
+
+
+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),
+    }

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

@@ -0,0 +1,381 @@
+import json
+import os
+
+import pytest
+
+from conftest import (
+    HOOKS_DIR, VULN_PY, bash_payload, commit_file, edit_payload, git,
+    make_repo, metrics_of, run_hook, stop_payload, ups_payload,
+)
+
+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)
+
+
+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_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)
+
+
+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)
+
+
+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_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
+
+    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
+
+
+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_is_handled_and_advances_baseline(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, 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
+        n_calls = len(stub_api.calls)
+        rc, so, se = run_hook(stop_payload(ws), hook_env)
+        m2 = metrics_of(so)
+        assert m2["skip_reason"] in (6, 9), m2
+        assert m2["repo_resolution"] == rr.RES_HINT
+        assert len(stub_api.calls) == n_calls
+
+    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, event="SubagentStop"), 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