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