spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

claude-code / plugins / security-guidance / tests / test_repo_resolution.py
621 lines · 29.1 KBRaw
1import json
2import os
3import subprocess
4import threading
5import time
6
7import pytest
8
9from conftest import (
10 GIT_ENV, HOOKS_DIR, STUB_VULN, VULN_PY, bash_payload, commit_file, edit_payload,
11 git, make_repo, metrics_of, run_hook, stop_payload, ups_payload,
12)
13
14import gitutil
15import reporesolve as rr
16import security_reminder_hook as hook
17
18
19
20class TestDirsFromCommand:
21 def test_cd_and_and(self, workspace):
22 ws, repo = workspace
23 assert rr.dirs_from_command("cd sub && git commit -m x", str(ws)) == [str(repo)]
24
25 def test_cd_semicolon_subshell(self, workspace):
26 ws, repo = workspace
27 assert rr.dirs_from_command("(cd sub; git commit -m x)", str(ws)) == [str(repo)]
28
29 def test_git_dash_C(self, workspace):
30 ws, repo = workspace
31 assert rr.dirs_from_command("git -C sub commit -q -m x", str(ws)) == [str(repo)]
32
33 def test_git_dash_C_absolute_and_quoted(self, tmp_path):
34 d = tmp_path / "my repo"
35 d.mkdir()
36 assert rr.dirs_from_command(f'git -C "{d}" commit -m "a b"', "/nonexistent") == [str(d)]
37
38 def test_env_prefix_and_config_opts(self, workspace):
39 ws, repo = workspace
40 cmd = "FOO=1 git -c user.name=x -C sub commit -m x"
41 assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
42
43 def test_git_dir_work_tree(self, workspace):
44 ws, repo = workspace
45 cmd = "git --git-dir=sub/.git --work-tree=sub commit -m x"
46 assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
47 cmd = "git --git-dir sub/.git commit -m x"
48 assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
49
50 def test_subcommand_filter(self, workspace):
51 ws, repo = workspace
52 cmd = "git -C other status && git -C sub commit -m x && git -C third push"
53 assert rr.dirs_from_command(cmd, str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
54 assert rr.dirs_from_command(cmd, str(ws), rr.PUSH_SUBCOMMANDS) == [str(ws / "third")]
55
56 def test_gt(self, workspace):
57 ws, repo = workspace
58 assert rr.dirs_from_command("cd sub && gt create -m x", str(ws), rr.COMMIT_SUBCOMMANDS) == [str(repo)]
59
60 def test_pushd_and_relative_chain(self, workspace):
61 ws, repo = workspace
62 cmd = "pushd sub && cd .. && cd ./sub && git commit -m x"
63 assert rr.dirs_from_command(cmd, str(ws)) == [str(repo)]
64
65 @pytest.mark.parametrize("cmd", [
66 'git -C "unterminated commit -m x',
67 "git -C=sub commit -m x",
68 "cd && git commit",
69 "cd - && git commit -m x",
70 "git",
71 "git -C",
72 "cd /definitely/not/here && git commit -m x",
73 "echo 'git commit' | cat",
74 "git commit -m \"$(cat <<'EOF'\nmsg\nEOF\n)\"",
75 "",
76 None,
77 ])
78 def test_odd_inputs_do_not_raise(self, workspace, cmd):
79 ws, _ = workspace
80 out = rr.dirs_from_command(cmd, str(ws))
81 assert isinstance(out, list)
82 assert rr.toplevel_from_command(cmd, str(ws)) in (None, str(ws / "sub"))
83
84 def test_toplevel_from_command_nonexistent_dir(self, workspace):
85 ws, _ = workspace
86 assert rr.toplevel_from_command("cd nope && git commit -m x", str(ws)) is None
87
88 def test_toplevel_from_subdir_of_repo(self, workspace):
89 ws, repo = workspace
90 (repo / "pkg").mkdir()
91 assert rr.toplevel_from_command("cd sub/pkg && git commit -m x", str(ws)) == str(repo)
92
93 def test_windows_backslash_paths_survive_tokenizing(self, workspace, monkeypatch):
94 ws, _ = workspace
95 monkeypatch.setattr(rr.os, "sep", "\\")
96 toks = rr._tokenize(r'git -C C:\Users\me\repo commit -m x && git -C "D:\a b\r" push')
97 assert r"C:\Users\me\repo" in toks and r"D:\a b\r" in toks
98 toks = rr._tokenize(r"git -C \\srv\share\repo commit -m x")
99 assert r"\\srv\share\repo" in toks
100
101
102class TestShaScan:
103 def test_finds_repo_containing_commit(self, workspace):
104 ws, repo = workspace
105 sha, _ = commit_file(repo, "app.py", VULN_PY)
106 assert rr.repo_containing_commit(sha[:7], [str(ws)]) == str(repo)
107 assert rr.repo_containing_commit(sha, [str(ws)]) == str(repo)
108
109 def test_unknown_sha(self, workspace):
110 ws, _ = workspace
111 assert rr.repo_containing_commit("deadbeefdeadbeef", [str(ws)]) is None
112
113 def test_invalid_sha(self, workspace):
114 ws, _ = workspace
115 assert rr.repo_containing_commit("HEAD; rm -rf x", [str(ws)]) is None
116 assert rr.repo_containing_commit(None, [str(ws)]) is None
117
118 def test_skips_ignored_dirs_and_respects_depth(self, tmp_path):
119 ws = tmp_path / "ws"
120 deep = make_repo(ws / "a" / "b" / "c" / "d" / "repo")
121 nm = make_repo(ws / "node_modules" / "pkg")
122 found = list(rr.iter_git_repos([str(ws)], max_depth=3))
123 assert str(deep) not in found and str(nm) not in found
124 found = list(rr.iter_git_repos([str(ws)], max_depth=6))
125 assert str(deep) in found and str(nm) not in found
126
127 def test_max_repos_cap(self, tmp_path):
128 ws = tmp_path / "ws"
129 for i in range(4):
130 make_repo(ws / f"r{i}")
131 assert len(list(rr.iter_git_repos([str(ws)], max_repos=2))) == 2
132
133
134class TestReposFromPaths:
135 def test_groups_by_toplevel_and_orders_by_count(self, tmp_path):
136 ws = tmp_path / "ws"
137 a = make_repo(ws / "a")
138 b = make_repo(ws / "b")
139 paths = [str(b / "x.py"), str(a / "one.py"), str(a / "pkg" / "two.py"),
140 str(ws / "loose.txt"), "", None, 42]
141 assert rr.repos_from_paths(paths, str(ws)) == [str(a), str(b)]
142
143 def test_relative_paths_resolve_against_cwd(self, workspace):
144 ws, repo = workspace
145 assert rr.repos_from_paths(["sub/app.py"], str(ws)) == [str(repo)]
146
147 def test_missing_parent_dirs(self, workspace):
148 ws, repo = workspace
149 assert rr.repos_from_paths([str(repo / "new" / "deeper" / "f.py")], str(ws)) == [str(repo)]
150
151
152class TestResolveRepoRoot:
153 def test_cwd_is_repo(self, workspace):
154 ws, repo = workspace
155 assert rr.resolve_repo_root(str(repo), "cd /tmp && git commit") == (str(repo), rr.RES_CWD)
156
157 def test_cwd_is_repo_same_repo_in_command_stays_cwd(self, workspace):
158 ws, repo = workspace
159 (repo / "pkg").mkdir()
160 for cmd in ("git commit -m x", "cd pkg && git commit -m x", f"git -C {repo} commit -m x",
161 "git -C pkg commit -m x", "git -C nope commit -m x"):
162 assert rr.resolve_repo_root(str(repo), cmd, rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD), cmd
163
164 def test_explicit_other_repo_in_command_beats_cwd(self, workspace):
165 ws, repo = workspace
166 other = make_repo(ws / "other")
167 assert rr.resolve_repo_root(str(repo), f"git -C {other} commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
168 assert rr.resolve_repo_root(str(repo), "git -C ../other push", rr.PUSH_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
169 assert rr.resolve_repo_root(str(repo), "cd ../other && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(other), rr.RES_COMMAND)
170 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)
171 assert rr.resolve_repo_root(str(repo), f"git -C {other} status && git commit -m x", rr.COMMIT_SUBCOMMANDS) == (str(repo), rr.RES_CWD)
172
173 def test_order_command_then_paths_then_sha(self, workspace):
174 ws, repo = workspace
175 other = make_repo(ws / "other")
176 sha, _ = commit_file(repo, "app.py", VULN_PY)
177 assert rr.resolve_repo_root(str(ws), "git -C other commit", rr.COMMIT_SUBCOMMANDS,
178 sha=sha, touched_paths=[str(repo / "app.py")]) == (str(other), rr.RES_COMMAND)
179 assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
180 sha=sha, touched_paths=[str(repo / "app.py")]) == (str(repo), rr.RES_TOUCHED_PATHS)
181 assert rr.resolve_repo_root(str(ws), "git commit", rr.COMMIT_SUBCOMMANDS,
182 sha=sha) == (str(repo), rr.RES_SHA_SCAN)
183 assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
184
185
186class TestRegexes:
187 @pytest.mark.parametrize("cmd", [
188 "git commit -m x",
189 "git -C sub commit -m x",
190 'git -C "a b" commit -m x',
191 "git -c core.editor=true -C sub commit --amend",
192 "git --git-dir=x/.git --work-tree=x commit -m x",
193 "git --git-dir x/.git commit -m x",
194 "cd sub && git commit -m x",
195 "gt create -m x",
196 ])
197 def test_commit_re(self, cmd):
198 assert hook._GIT_COMMIT_RE.search(cmd)
199
200 @pytest.mark.parametrize("cmd", [
201 "git push", "git -C sub push origin main", 'git -C "a b" push',
202 "git --work-tree x push -u origin HEAD", "gt submit",
203 ])
204 def test_push_re(self, cmd):
205 assert hook._GIT_PUSH_RE.search(cmd)
206
207 @pytest.mark.parametrize("cmd", ["git status", "git log --oneline", "echo commit"])
208 def test_commit_re_negative(self, cmd):
209 assert not hook._GIT_COMMIT_RE.search(cmd)
210
211 @pytest.mark.parametrize("unit", ['-c "a"', "-c 'a'", "--no-a=b", "--a=b=c=d", "--git-dir x"])
212 @pytest.mark.parametrize("regex,verb", [("_GIT_COMMIT_RE", "commit"), ("_GIT_PUSH_RE", "push")])
213 def test_global_option_prefix_is_linear(self, regex, verb, unit):
214 cre = getattr(hook, regex)
215 prefix = "git " + " ".join([unit] * 40)
216 t0 = time.perf_counter()
217 assert not cre.search(prefix + " " + verb + "foo")
218 assert time.perf_counter() - t0 < 0.05
219 assert cre.search(prefix + " " + verb + " -m x")
220
221
222class TestHooksJson:
223 def test_matchers_and_events(self):
224 cfg = json.loads((HOOKS_DIR / "hooks.json").read_text())
225 # Stop already reviews what subagents changed during the turn.
226 assert "SubagentStop" not in cfg["hooks"]
227 stop = cfg["hooks"]["Stop"][0]["hooks"][0]
228 assert stop["command"].endswith('security_reminder_hook.py"')
229 assert stop["asyncRewake"] is True
230 bash = [g for g in cfg["hooks"]["PostToolUse"] if g.get("matcher") == "Bash"][0]
231 ifs = {h.get("if") for h in bash["hooks"]}
232 assert {"Bash(git commit:*)", "Bash(git push:*)", "Bash(git -C * commit *)",
233 "Bash(git -C * push*)", "Bash(gt create:*)", "Bash(gt modify:*)",
234 "Bash(gt submit:*)"} <= ifs
235
236
237
238def _read_state(env):
239 d = env["SECURITY_WARNINGS_STATE_DIR"]
240 files = [e.path for e in os.scandir(d) if e.name.endswith(".json")]
241 assert files, os.listdir(d)
242 with open(files[0]) as f:
243 return json.load(f)
244
245
246def _read_state_or_empty(env):
247 d = env["SECURITY_WARNINGS_STATE_DIR"]
248 if not any(e.name.endswith(".json") for e in os.scandir(d)):
249 return {}
250 return _read_state(env)
251
252
253class TestCommitReview:
254 def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
255 ws, repo = workspace
256 sha, out = commit_file(repo, "app.py", VULN_PY)
257 rc, so, se = run_hook(bash_payload(repo, "git commit -m change", out), hook_env)
258 m = metrics_of(so)
259 assert m["commit_review"] is True
260 assert "cwd_is_repo" not in m and "repo_resolution" not in m
261 assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
262 assert stub_api.calls
263
264 def test_cwd_is_repo_cd_subdir_unchanged(self, workspace, hook_env, stub_api):
265 ws, repo = workspace
266 (repo / "pkg").mkdir()
267 sha, out = commit_file(repo, "pkg/app.py", VULN_PY)
268 rc, so, se = run_hook(bash_payload(repo, "cd pkg && git commit -m change", out), hook_env)
269 m = metrics_of(so)
270 assert "cwd_is_repo" not in m and "repo_resolution" not in m
271 assert m.get("files_reviewed") == 1 and m.get("skip_reason") is None
272
273 def test_cwd_is_repo_dash_C_other_repo_reviews_other_repo(self, workspace, hook_env, stub_api):
274 ws, repo = workspace
275 other = make_repo(ws / "other")
276 sha, out = commit_file(other, "srv.py", VULN_PY)
277 rc, so, se = run_hook(bash_payload(repo, f"git -C {other} commit -m change", out), hook_env)
278 m = metrics_of(so)
279 assert m.get("skip_reason") is None, m
280 assert "cwd_is_repo" not in m and m["repo_resolution"] == rr.RES_COMMAND
281 assert m["files_reviewed"] == 1
282 assert stub_api.calls
283 assert (other / ".git" / "sg-reviewed-shas").exists()
284 assert not (repo / ".git" / "sg-reviewed-shas").exists()
285
286 def test_workspace_cwd_cd_sub(self, workspace, hook_env, stub_api):
287 ws, repo = workspace
288 sha, out = commit_file(repo, "app.py", VULN_PY)
289 rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
290 m = metrics_of(so)
291 assert m.get("skip_reason") is None, m
292 assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
293 assert m["files_reviewed"] == 1
294 assert stub_api.calls
295
296 def test_workspace_cwd_git_dash_C_quiet_uses_reflog(self, workspace, hook_env, stub_api):
297 ws, repo = workspace
298 sha, _ = commit_file(repo, "app.py", VULN_PY)
299 rc, so, se = run_hook(bash_payload(ws, "git -C sub commit -q -m change", ""), hook_env)
300 m = metrics_of(so)
301 assert m.get("skip_reason") is None, m
302 assert m["repo_resolution"] == rr.RES_COMMAND and m["sha_via_reflog"] is True
303 assert m["files_reviewed"] == 1
304
305 def test_workspace_cwd_sha_scan(self, workspace, hook_env, stub_api):
306 ws, repo = workspace
307 sha, out = commit_file(repo, "app.py", VULN_PY)
308 rc, so, se = run_hook(bash_payload(ws, "git commit -m change", out), hook_env)
309 m = metrics_of(so)
310 assert m.get("skip_reason") is None, m
311 assert m["repo_resolution"] == rr.RES_SHA_SCAN and m["files_reviewed"] == 1
312 assert "repo_root_hint" not in _read_state_or_empty(hook_env)
313
314 def test_workspace_cwd_sha_scan_via_project_dir(self, workspace, hook_env, tmp_path):
315 ws, repo = workspace
316 sha, out = commit_file(repo, "app.py", VULN_PY)
317 elsewhere = tmp_path / "elsewhere"
318 elsewhere.mkdir()
319 env = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
320 rc, so, se = run_hook(bash_payload(elsewhere, "git commit -m change", out), env)
321 m = metrics_of(so)
322 assert m.get("skip_reason") is None, m
323 assert m["repo_resolution"] == rr.RES_SHA_SCAN
324
325 def test_hint_saved_and_used(self, workspace, hook_env):
326 ws, repo = workspace
327 sha, out = commit_file(repo, "app.py", VULN_PY)
328 run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
329 state = _read_state(hook_env)
330 assert state.get("repo_root_hint") == str(repo)
331 assert rr.resolve_repo_root(str(ws), "git commit") == (None, rr.RES_NONE)
332 os.environ["SECURITY_WARNINGS_STATE_DIR"] = hook_env["SECURITY_WARNINGS_STATE_DIR"]
333 try:
334 assert rr.load_repo_hint("s1") == str(repo)
335 finally:
336 os.environ.pop("SECURITY_WARNINGS_STATE_DIR", None)
337
338 def test_hint_used_for_quiet_commit_without_dir(self, workspace, hook_env):
339 ws, repo = workspace
340 sha, out = commit_file(repo, "app.py", VULN_PY)
341 run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
342 commit_file(repo, "app.py", VULN_PY + "# 2\n")
343 rc, so, se = run_hook(bash_payload(ws, "git commit -q -m change", ""), hook_env)
344 m = metrics_of(so)
345 assert m.get("skip_reason") is None, m
346 assert m["repo_resolution"] == rr.RES_HINT and m["sha_via_reflog"] is True
347
348 def test_unresolvable_still_skips_26(self, workspace, hook_env, tmp_path):
349 ws, repo = workspace
350 sha, out = commit_file(repo, "app.py", VULN_PY)
351 empty = tmp_path / "empty"
352 empty.mkdir()
353 rc, so, se = run_hook(bash_payload(empty, "git commit -m change", out), hook_env)
354 m = metrics_of(so)
355 assert m["skip_reason"] == 26 and m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_NONE
356
357 def test_no_credentials_gate_precedes_repo_resolution(self, workspace, hook_env):
358 ws, repo = workspace
359 sha, out = commit_file(repo, "app.py", VULN_PY)
360 env = {k: v for k, v in hook_env.items() if k != "ANTHROPIC_API_KEY"}
361 rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), env)
362 m = metrics_of(so)
363 assert m["skip_reason"] == 22 and m["repo_resolution"] == rr.RES_COMMAND
364
365 def test_dedup_sentinel_uses_resolved_repo(self, workspace, hook_env):
366 ws, repo = workspace
367 sha, out = commit_file(repo, "app.py", VULN_PY)
368 p = bash_payload(ws, "git -C sub commit -m change && git -C sub push", out, tool_use_id="toolu_1")
369 rc1, so1, _ = run_hook(p, hook_env)
370 rc2, so2, _ = run_hook(p, hook_env)
371 assert metrics_of(so1).get("commit_review") is True
372 assert metrics_of(so2) == {"bash_hook_dedup": True}
373
374
375class TestPushSweep:
376 def test_workspace_cwd_git_dash_C_push(self, workspace, hook_env, tmp_path):
377 ws, repo = workspace
378 remote = tmp_path / "remote.git"
379 git(ws, "init", "-q", "--bare", str(remote))
380 git(repo, "remote", "add", "origin", str(remote))
381 git(repo, "push", "-q", "-u", "origin", "main")
382 base = git(repo, "rev-parse", "HEAD").strip()
383 sha, _ = commit_file(repo, "app.py", VULN_PY)
384 out = git(repo, "push", "--porcelain", "origin", "main")
385 push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
386 rc, so, se = run_hook(bash_payload(ws, "git -C sub push origin main", push_stdout), hook_env)
387 m = metrics_of(so)
388 assert m["push_sweep"] is True
389 assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_COMMAND
390 assert m.get("skip_reason") != 26
391 assert m.get("pushed") == 1
392
393 def test_workspace_cwd_sha_scan_push_leaves_no_hint(self, workspace, hook_env, tmp_path):
394 ws, repo = workspace
395 remote = tmp_path / "remote.git"
396 git(ws, "init", "-q", "--bare", str(remote))
397 git(repo, "remote", "add", "origin", str(remote))
398 git(repo, "push", "-q", "-u", "origin", "main")
399 base = git(repo, "rev-parse", "HEAD").strip()
400 sha, _ = commit_file(repo, "app.py", VULN_PY)
401 git(repo, "push", "-q", "origin", "main")
402 push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
403 rc, so, se = run_hook(bash_payload(ws, "git push origin main", push_stdout), hook_env)
404 m = metrics_of(so)
405 assert m["push_sweep"] is True and m["repo_resolution"] == rr.RES_SHA_SCAN, m
406 assert m.get("skip_reason") != 26
407 assert "repo_root_hint" not in _read_state_or_empty(hook_env)
408
409 def test_cwd_is_repo_dash_C_other_repo(self, workspace, hook_env, tmp_path):
410 ws, repo = workspace
411 other = make_repo(ws / "other")
412 remote = tmp_path / "remote.git"
413 git(ws, "init", "-q", "--bare", str(remote))
414 git(other, "remote", "add", "origin", str(remote))
415 git(other, "push", "-q", "-u", "origin", "main")
416 base = git(other, "rev-parse", "HEAD").strip()
417 sha, _ = commit_file(other, "srv.py", VULN_PY)
418 git(other, "push", "-q", "origin", "main")
419 push_stdout = f"To {remote}\n {base[:7]}..{sha[:7]} main -> main\n"
420 rc, so, se = run_hook(bash_payload(repo, f"git -C {other} push origin main", push_stdout), hook_env)
421 m = metrics_of(so)
422 assert m["push_sweep"] is True and "cwd_is_repo" not in m
423 assert m["repo_resolution"] == rr.RES_COMMAND and m.get("pushed") == 1
424
425
426class TestStop:
427 def _touch(self, ws, repo, hook_env, session_id="s1"):
428 (repo / "app.py").write_text(VULN_PY)
429 run_hook(edit_payload(ws, repo / "app.py", VULN_PY, session_id=session_id), hook_env)
430
431 def test_cwd_is_repo_unchanged(self, workspace, hook_env, stub_api):
432 ws, repo = workspace
433 run_hook(ups_payload(repo), hook_env)
434 self._touch(repo, repo, hook_env)
435 rc, so, se = run_hook(stop_payload(repo), hook_env)
436 m = metrics_of(so)
437 assert m.get("skip_reason") is None, m
438 assert "repo_resolution" not in m and m["files_reviewed"] == 1
439 assert stub_api.calls
440
441 def test_workspace_cwd_resolves_from_touched_paths(self, workspace, hook_env, stub_api):
442 ws, repo = workspace
443 run_hook(ups_payload(ws), hook_env)
444 self._touch(ws, repo, hook_env)
445 rc, so, se = run_hook(stop_payload(ws), hook_env)
446 m = metrics_of(so)
447 assert m.get("skip_reason") is None, m
448 assert m["cwd_is_repo"] is False and m["repo_resolution"] == rr.RES_TOUCHED_PATHS
449 assert m["files_reviewed"] == 1 and m["review_set_count"] == 1
450 assert stub_api.calls
451
452 def test_subagent_stop_reviews_without_consuming_session_state(self, workspace, hook_env, stub_api):
453 ws, repo = workspace
454 run_hook(ups_payload(ws), hook_env)
455 self._touch(ws, repo, hook_env)
456 before = _read_state(hook_env)
457 rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), hook_env)
458 m = metrics_of(so)
459 assert m.get("skip_reason") is None, m
460 assert m["repo_resolution"] == rr.RES_TOUCHED_PATHS and m["files_reviewed"] == 1
461 after = _read_state(hook_env)
462 assert after["touched_paths"] == before["touched_paths"] != []
463 assert after.get("baseline_sha") == before.get("baseline_sha")
464 assert after.get("reviewed_diff_hash")
465 assert "repo_root_hint" not in after
466 n_calls = len(stub_api.calls)
467 rc, so, se = run_hook(stop_payload(ws), hook_env)
468 m2 = metrics_of(so)
469 assert m2["skip_reason"] == 12, m2
470 assert m2["repo_resolution"] == rr.RES_TOUCHED_PATHS
471 assert len(stub_api.calls) == n_calls
472 final = _read_state(hook_env)
473 assert final["touched_paths"] == [] and "reviewed_diff_hash" not in final
474
475 def test_main_edits_during_subagent_review_are_reviewed_at_stop(self, workspace, hook_env, stub_api):
476 ws, repo = workspace
477 run_hook(ups_payload(repo), hook_env)
478 self._touch(repo, repo, hook_env)
479 stub_api.delay = 3
480 res = {}
481 t = threading.Thread(target=lambda: res.update(
482 sub=run_hook(stop_payload(repo, event="SubagentStop"), hook_env)))
483 t.start()
484 time.sleep(1.5)
485 (repo / "b.py").write_text("import os\nos.system(input())\n")
486 run_hook(edit_payload(repo, repo / "b.py", "x"), hook_env)
487 t.join()
488 stub_api.delay = 0
489 m_sub = metrics_of(res["sub"][1])
490 assert m_sub.get("skip_reason") is None and m_sub["files_reviewed"] == 1, m_sub
491 n_calls = len(stub_api.calls)
492 rc, so, se = run_hook(stop_payload(repo), hook_env)
493 m = metrics_of(so)
494 assert m.get("skip_reason") is None, m
495 assert m["files_reviewed"] == 2 and m["touched_paths_count"] == 2
496 assert len(stub_api.calls) > n_calls
497
498 def test_subagent_stop_findings_do_not_advance_baseline_or_fire_count(self, workspace, hook_env, stub_api):
499 ws, repo = workspace
500 run_hook(ups_payload(repo), hook_env)
501 self._touch(repo, repo, hook_env)
502 before = _read_state(hook_env)
503 stub_api.vulns = [STUB_VULN]
504 rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), hook_env)
505 m = metrics_of(so)
506 assert rc == 2 and m["vulns_found"] == 1, (rc, m)
507 assert "repo_resolution" not in m and "cwd_is_repo" not in m
508 after = _read_state(hook_env)
509 assert after.get("baseline_sha") == before.get("baseline_sha")
510 assert not after.get("stop_hook_fire_count")
511 assert after["touched_paths"] == before["touched_paths"]
512 assert len(after.get("previous_findings", [])) == 1 and after.get("reviewed_diff_hash")
513 rc, so, se = run_hook(stop_payload(repo), hook_env)
514 assert metrics_of(so)["skip_reason"] == 12
515 (repo / "app.py").write_text(VULN_PY + "x = 1\n")
516 run_hook(edit_payload(repo, repo / "app.py", "x"), hook_env)
517 rc, so, se = run_hook(stop_payload(repo), hook_env)
518 m3 = metrics_of(so)
519 assert rc == 2 and m3.get("skip_reason") is None and m3["files_reviewed"] == 1, m3
520 assert _read_state(hook_env)["stop_hook_fire_count"] == 1
521
522 def test_subagent_stop_in_other_worktree_is_skipped(self, workspace, hook_env, stub_api, tmp_path):
523 ws, repo = workspace
524 wt = tmp_path / "wt"
525 git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
526 run_hook(ups_payload(repo), hook_env)
527 self._touch(repo, repo, hook_env)
528 before = _read_state(hook_env)
529 (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
530 run_hook(edit_payload(wt, wt / "new.py", "x"), hook_env)
531 env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
532 rc, so, se = run_hook(stop_payload(wt, event="SubagentStop"), env)
533 m = metrics_of(so)
534 assert m["skip_reason"] == 11, m
535 assert not stub_api.calls
536 after = _read_state(hook_env)
537 assert after.get("baseline_sha") == before.get("baseline_sha")
538 assert after.get("head_at_capture") == before.get("head_at_capture")
539 rc, so, se = run_hook(stop_payload(repo), env)
540 m2 = metrics_of(so)
541 assert m2.get("skip_reason") is None and m2["files_reviewed"] == 1, m2
542
543 def test_subagent_stop_in_other_worktree_from_workspace_cwd(self, workspace, hook_env, stub_api, tmp_path):
544 ws, repo = workspace
545 wt = tmp_path / "wt"
546 git(repo, "worktree", "add", "-q", "-b", "agent", str(wt))
547 run_hook(ups_payload(ws), hook_env)
548 (wt / "new.py").write_text("import pickle\npickle.loads(b)\n")
549 run_hook(edit_payload(ws, wt / "new.py", "x"), hook_env)
550 env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
551 rc, so, se = run_hook(stop_payload(ws, event="SubagentStop"), env)
552 m = metrics_of(so)
553 assert m["skip_reason"] == 11 and m["repo_resolution"] == rr.RES_TOUCHED_PATHS, m
554 assert not stub_api.calls
555 assert "repo_root_hint" not in _read_state(hook_env)
556
557 def test_repository_config_cannot_run_programs(self, workspace, hook_env, stub_api, tmp_path):
558 ws, repo = workspace
559 marker = tmp_path / "marker"
560 mon = tmp_path / "mon.sh"
561 mon.write_text(f"#!/bin/sh\necho ran >> '{marker}'\n")
562 mon.chmod(0o755)
563 git(repo, "config", "core.fsmonitor", str(mon))
564 clean = {k: v for k, v in os.environ.items() if not k.startswith("GIT_CONFIG_")}
565 subprocess.run(["git", "status"], cwd=repo, env={**clean, **GIT_ENV}, capture_output=True)
566 assert marker.exists()
567 marker.unlink()
568 run_hook(ups_payload(ws), hook_env)
569 self._touch(ws, repo, hook_env)
570 rc, so, se = run_hook(stop_payload(ws), hook_env)
571 m = metrics_of(so)
572 assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
573 assert not marker.exists()
574 sha, out = commit_file(repo, "app.py", VULN_PY + "z = 3\n")
575 marker.unlink(missing_ok=True)
576 rc, so, se = run_hook(bash_payload(ws, "cd sub && git commit -m change", out), hook_env)
577 assert metrics_of(so).get("files_reviewed") == 1
578 assert not marker.exists()
579
580 def test_safe_git_env_extends_existing_config_count(self):
581 base = {"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "a.b", "GIT_CONFIG_VALUE_0": "1",
582 "GIT_CONFIG_KEY_1": "c.d", "GIT_CONFIG_VALUE_1": "2"}
583 env = gitutil.git_config_env(gitutil.SAFE_GIT_CONFIG, base=base)
584 assert env["GIT_CONFIG_COUNT"] == str(2 + len(gitutil.SAFE_GIT_CONFIG))
585 assert "GIT_CONFIG_KEY_0" not in env and "GIT_CONFIG_KEY_1" not in env
586 got = {env[f"GIT_CONFIG_KEY_{i}"]: env[f"GIT_CONFIG_VALUE_{i}"]
587 for i in range(2, int(env["GIT_CONFIG_COUNT"]))}
588 assert got == dict(gitutil.SAFE_GIT_CONFIG)
589 assert gitutil.git_config_env((("x.y", "z"),), base={"GIT_CONFIG_COUNT": "junk"})["GIT_CONFIG_COUNT"] == "1"
590
591 def test_subagent_stop_same_repo_with_project_dir_reviews(self, workspace, hook_env, stub_api):
592 ws, repo = workspace
593 (repo / "pkg").mkdir()
594 run_hook(ups_payload(repo), hook_env)
595 self._touch(repo, repo, hook_env)
596 env = {**hook_env, "CLAUDE_PROJECT_DIR": str(repo)}
597 rc, so, se = run_hook(stop_payload(repo / "pkg", event="SubagentStop"), env)
598 m = metrics_of(so)
599 assert m.get("skip_reason") is None and m["files_reviewed"] == 1, m
600 env_ws = {**hook_env, "CLAUDE_PROJECT_DIR": str(ws)}
601 (repo / "app.py").write_text(VULN_PY + "y = 2\n")
602 rc, so, se = run_hook(stop_payload(repo, event="SubagentStop"), env_ws)
603 assert metrics_of(so).get("skip_reason") is None
604
605 def test_ups_uses_hint_for_baseline(self, workspace, hook_env):
606 ws, repo = workspace
607 run_hook(ups_payload(ws), hook_env)
608 self._touch(ws, repo, hook_env)
609 run_hook(stop_payload(ws), hook_env)
610 (repo / "app.py").write_text(VULN_PY + "\n# more\n")
611 run_hook(ups_payload(ws, session_id="s1"), hook_env)
612 state = _read_state(hook_env)
613 assert state.get("baseline_sha") and state.get("repo_root_hint") == str(repo)
614
615 def test_workspace_cwd_nothing_touched_skips(self, workspace, hook_env, stub_api):
616 ws, repo = workspace
617 run_hook(ups_payload(ws), hook_env)
618 rc, so, se = run_hook(stop_payload(ws), hook_env)
619 m = metrics_of(so)
620 assert m["skip_reason"] == 9 and m["repo_resolution"] == rr.RES_NONE
621 assert not stub_api.calls