#606: SubagentStop gate skips ~600ms seedgo check on empty agent_type (internal CC turns) — early-return _ALLOW at top of handle(); real sub-agents (non-empty agent_type) still get the full modified-files check, Piper untouched (separate hook). +3 tests, 17/17 green, seedgo 31/31.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user