Sfoglia il codice sorgente

Adjust SubagentStop handling and repository precedence

- SubagentStop reviews without consuming or advancing the session's
  Stop state; an identical diff is not reviewed twice.
- SubagentStop from a different working tree than the project is skipped.
- An explicit `git -C <dir>` / `cd <dir>` naming another repository takes
  precedence over the hook cwd.
- Narrows the `git -C … commit` matcher; keeps Windows path separators
  when tokenizing; adds `--no-textconv` to diff/show invocations.
- Adds tests.

:house: Remote-Dev: homespace
Octavian Guzu 19 ore fa
parent
commit
f03d41e2c9

+ 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.
 

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

@@ -238,7 +238,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 +481,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

+ 1 - 1
plugins/security-guidance/hooks/hooks.json

@@ -45,7 +45,7 @@
           {
             "type": "command",
             "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh\" \"${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py\"",
-            "if": "Bash(git -C * commit*)",
+            "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"

+ 16 - 7
plugins/security-guidance/hooks/reporesolve.py

@@ -47,6 +47,8 @@ def _is_sep(tok):
 
 
 def _tokenize(command):
+    if os.sep == "\\":
+        command = command.replace("\\", "\\\\")
     try:
         lex = shlex.shlex(command, posix=True, punctuation_chars=";&|()")
         lex.whitespace_split = True
@@ -140,8 +142,11 @@ def dirs_from_command(command, cwd, subcommands=None):
     return list(dict.fromkeys(d for d in out if d))
 
 
-def toplevel_from_command(command, cwd, subcommands=None):
+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)
@@ -274,15 +279,19 @@ def load_repo_hint(session_id):
     return None
 
 
+_UNSET = object()
+
+
 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
+                      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)
-        if top:
+        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:

+ 41 - 28
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
@@ -112,7 +113,7 @@ 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,
@@ -1075,14 +1076,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:
+    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
@@ -1279,13 +1282,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:
@@ -1594,13 +1597,15 @@ def handle_push_sweep_posttooluse(input_data):
         emit_metrics({"skipped": True, "skip_reason": 25, **_base})
         sys.exit(0)
     _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,
+        session_id=session_id, cwd_root=cwd_root,
     )
-    if repo_res != RES_CWD:
+    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})
@@ -1904,6 +1909,7 @@ def handle_stop_hook(input_data):
     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
@@ -1918,7 +1924,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"]
@@ -1991,6 +1998,13 @@ def handle_stop_hook(input_data):
             debug_log(f"Stop hook: repo resolved via {repo_res} -> {repo_cwd!r}")
             save_repo_hint(session_id, repo_cwd)
             cwd = repo_cwd
+    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)
 
     review_paths, diff_base, repo_root, untracked, v2_metrics = compute_v2_review_set(
         cwd, baseline_sha, head_at_capture, untracked_at_baseline
@@ -2024,6 +2038,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:
@@ -2094,12 +2113,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
@@ -2148,7 +2170,7 @@ 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,
+            **({"repo_resolution": repo_res} if res_metrics else {}),
             **sweep_trimmed,
         }, rewake_summary=_format_vulns_summary(vulns),
            additional_context=(PROVENANCE_BANNER + "\n\n"
@@ -2161,17 +2183,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 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)
+        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

+ 14 - 1
plugins/security-guidance/tests/conftest.py

@@ -4,6 +4,7 @@ import os
 import subprocess
 import sys
 import threading
+import time
 from pathlib import Path
 
 import pytest
@@ -78,12 +79,15 @@ class _Stub(http.server.BaseHTTPRequestHandler):
         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": False, "vulnerabilities": []})}],
+                    {"hasVulnerabilities": bool(vulns), "vulnerabilities": vulns})}],
                 "usage": {"input_tokens": 1, "output_tokens": 1},
             }).encode()
         else:
@@ -105,6 +109,8 @@ 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:
@@ -145,6 +151,13 @@ def run_hook(payload, env, python=sys.executable):
     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()

+ 158 - 5
plugins/security-guidance/tests/test_repo_resolution.py

@@ -1,10 +1,12 @@
 import json
 import os
+import threading
+import time
 
 import pytest
 
 from conftest import (
-    HOOKS_DIR, VULN_PY, bash_payload, commit_file, edit_payload, git,
+    HOOKS_DIR, STUB_VULN, VULN_PY, bash_payload, commit_file, edit_payload, git,
     make_repo, metrics_of, run_hook, stop_payload, ups_payload,
 )
 
@@ -86,6 +88,14 @@ class TestDirsFromCommand:
         (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):
@@ -142,6 +152,22 @@ class TestResolveRepoRoot:
         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")
@@ -189,7 +215,7 @@ class TestHooksJson:
             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*)",
+        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
 
@@ -214,6 +240,28 @@ class TestCommitReview:
         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)
@@ -320,6 +368,22 @@ class TestPushSweep:
         assert m.get("skip_reason") != 26
         assert m.get("pushed") == 1
 
+    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"):
@@ -347,20 +411,109 @@ class TestStop:
         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):
+    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")
         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 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_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