diff --git a/CHANGELOG.md b/CHANGELOG.md index bea529e7..cae7216c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,16 @@ PyPI version — not the changelog header. ### Fixed +- **SubagentStop gate no longer runs its ~600ms seedgo check on every internal + turn (issue #606).** Claude Code creates an internal agent per response turn + with an empty `agent_type`, so the `subagent_gate` handler was firing its full + `drone @git status` + seedgo modified-files check on every turn, not just when a + real Agent-tool sub-agent completed. `handle()` now early-returns `_ALLOW` when + `agent_type` is empty; the full check runs only for a real sub-agent + (non-empty `agent_type`). Piper speech is a separate notification hook and is + unaffected — the trust layer stays visible. 3 new tests (empty skip, missing-key + skip, real-agent full check), 17/17 green. + - **Watchdog stall detector no longer false-fires on a long single tool call, and a real stall now reaches devpulse live (issue #634).** Liveness was inferred purely from JSONL file-size growth, so an agent doing one genuinely long diff --git a/src/aipass/hooks/apps/handlers/security/subagent_gate.py b/src/aipass/hooks/apps/handlers/security/subagent_gate.py index c4d99a6f..e4db4530 100644 --- a/src/aipass/hooks/apps/handlers/security/subagent_gate.py +++ b/src/aipass/hooks/apps/handlers/security/subagent_gate.py @@ -152,6 +152,10 @@ def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None: def handle(hook_data: dict) -> dict: """Check modified files against seedgo standards on subagent stop.""" try: + agent_type = hook_data.get("agent_type", "") + if not agent_type: + return _ALLOW + cwd = hook_data.get("cwd", "") or os.getcwd() repo_root = _find_repo_root(cwd) if repo_root is None: diff --git a/src/aipass/hooks/tests/test_subagent_gate.py b/src/aipass/hooks/tests/test_subagent_gate.py index 1f4e098d..f7a84b99 100644 --- a/src/aipass/hooks/tests/test_subagent_gate.py +++ b/src/aipass/hooks/tests/test_subagent_gate.py @@ -18,14 +18,14 @@ from aipass.hooks.apps.handlers.security.subagent_gate import handle class TestSubagentGateHandler: def test_no_repo_root_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - result = handle({"cwd": "/tmp/nowhere"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/nowhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result def test_no_modified_files_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - result = handle({"cwd": "/tmp/somewhere"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/somewhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result @@ -39,7 +39,7 @@ class TestSubagentGateHandler: mock_root.return_value = Path("/fake/repo") mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/test.py"] - result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result @@ -54,7 +54,7 @@ class TestSubagentGateHandler: mock_root.return_value = Path("/fake/repo") mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/bad.py"] mock_seedgo.return_value = ["Missing docstring", "No tests"] - result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 2 parsed = json.loads(result["stdout"]) assert parsed["decision"] == "block" @@ -99,7 +99,7 @@ class TestSubagentGateHandler: "Hook files were modified but .claude/hooks/README.md was not updated. " "Consider updating the README to reflect your changes." ) - result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + result = handle({"agent_type": "Explore", "cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 0 parsed = json.loads(result["stdout"]) assert parsed["decision"] == "allow" @@ -107,12 +107,42 @@ class TestSubagentGateHandler: assert "sound" not in result def test_empty_hook_data_allows(self): - with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - result = handle({}) + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result + def test_empty_agent_type_skips_heavy_work(self): + """Internal CC turns (empty agent_type) skip drone @git status + seedgo.""" + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") as mock_root: + result = handle({"agent_type": "", "cwd": "/fake/repo"}) + assert result["exit_code"] == 0 + assert result["stdout"] == "" + mock_root.assert_not_called() + + def test_missing_agent_type_skips_heavy_work(self): + """Missing agent_type key treated same as empty — skip.""" + with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") as mock_root: + result = handle({"cwd": "/fake/repo"}) + assert result["exit_code"] == 0 + mock_root.assert_not_called() + + @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) + @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") + @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") + def test_real_agent_type_runs_full_check(self, mock_root, mock_modified, mock_seedgo, mock_readme): + """Real sub-agents (non-empty agent_type) get the full seedgo check.""" + from pathlib import Path + + mock_root.return_value = Path("/fake/repo") + mock_modified.return_value = ["/fake/repo/src/aipass/hooks/apps/bad.py"] + mock_seedgo.return_value = ["Missing docstring"] + result = handle({"agent_type": "general-purpose", "cwd": "/fake/repo/src/aipass/hooks"}) + assert result["exit_code"] == 2 + mock_root.assert_called_once() + mock_seedgo.assert_called_once() + @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") def test_exception_in_get_modified_allows(self, mock_root, mock_modified): @@ -120,7 +150,7 @@ class TestSubagentGateHandler: mock_root.return_value = Path("/fake/repo") mock_modified.side_effect = RuntimeError("subprocess died") - result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result @@ -152,9 +182,9 @@ class TestSubagentGateExternalProject: def test_get_package_from_cwd_external(self): from aipass.hooks.apps.handlers.security.subagent_gate import _get_package_from_cwd - assert _get_package_from_cwd("/home/user/Projects/vera/src/vera_studio/quality") == "vera_studio" - assert _get_package_from_cwd("/home/user/Projects/AIPass/src/aipass/hooks") == "aipass" - assert _get_package_from_cwd("/tmp/no-src-here") == "" + assert _get_package_from_cwd("/fake/projects/vera/src/vera_studio/quality") == "vera_studio" + assert _get_package_from_cwd("/fake/projects/AIPass/src/aipass/hooks") == "aipass" + assert _get_package_from_cwd("/fake/no-src-here") == "" @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") @@ -166,7 +196,7 @@ class TestSubagentGateExternalProject: mock_root.return_value = Path("/fake/vera") mock_modified.return_value = ["/fake/vera/src/vera_studio/quality/apps/bad.py"] mock_seedgo.return_value = ["Missing docstring"] - result = handle({"cwd": "/fake/vera/src/vera_studio/quality"}) + result = handle({"agent_type": "general-purpose", "cwd": "/fake/vera/src/vera_studio/quality"}) assert result["exit_code"] == 2 parsed = json.loads(result["stdout"]) assert parsed["decision"] == "block" @@ -182,7 +212,7 @@ class TestSubagentGateExternalProject: mock_root.return_value = Path("/fake/vera") mock_modified.return_value = ["/fake/vera/src/vera_studio/quality/apps/clean.py"] - result = handle({"cwd": "/fake/vera/src/vera_studio/quality"}) + result = handle({"agent_type": "Explore", "cwd": "/fake/vera/src/vera_studio/quality"}) assert result["exit_code"] == 0 assert result["stdout"] == "" assert "sound" not in result