fix(hooks): subagent_gate package-aware — closes #605

subagent_gate.py derived package name dynamically from CWD (mirrors edit_gate),
replacing 3 hardcoded src/aipass/ paths. Branch detection + cross-branch
protection now work for external projects (src/<package>/<branch>); previously
silently no-opped. 5 new external-project tests (258 pass), seedgo 100%.

Closes #605

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-05-29 00:33:50 -07:00
co-authored by Claude Opus 4.8
parent 97a3276b81
commit f8f102e16f
3 changed files with 86 additions and 10 deletions
+6 -5
View File
@@ -30,11 +30,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Changed
- **Edit gate now project-aware** — cross-branch write protection and daemon
confinement no longer hardcode `src/aipass/`. The package name is derived
dynamically from CWD, so any `src/<package>/<branch>/` project gets the
same security. 4 new tests for external projects. Addresses
[#605](https://github.com/AIOSAI/AIPass/issues/605).
- **Security gates fully project-aware** — both the edit gate *and* the
subagent stop gate now derive the package name dynamically from CWD instead
of hardcoding `src/aipass/`. Cross-branch write protection and branch
detection work for any `src/<package>/<branch>/` project; previously the
subagent gate silently no-opped outside AIPass. 9 new external-project tests.
Closes [#605](https://github.com/AIOSAI/AIPass/issues/605).
- **Hooks branch promoted to service** — registry profile changed from
"AIPass Workshop" to "library" so it appears in `drone systems` alongside
the other 12 services.
@@ -25,6 +25,15 @@ def _block(reason: str) -> dict:
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
def _get_package_from_cwd(cwd: str) -> str:
"""Derive package name from CWD path (e.g., 'aipass' from src/aipass/hooks)."""
parts = Path(cwd).parts
for i, part in enumerate(parts):
if part == "src" and i + 2 < len(parts):
return parts[i + 1]
return ""
def _find_repo_root(cwd: str) -> Path | None:
"""Walk up from AIPASS_HOME or CWD to find the git repo root."""
for start in (os.environ.get("AIPASS_HOME", ""), cwd):
@@ -39,20 +48,25 @@ def _find_repo_root(cwd: str) -> Path | None:
def _get_cwd_branch(cwd: str, repo_root: Path) -> str | None:
"""Detect which branch directory (src/aipass/<name>) the CWD is in."""
src = repo_root / "src" / "aipass"
"""Detect which branch directory (src/<package>/<name>) the CWD is in."""
package = _get_package_from_cwd(cwd)
if not package:
logger.info("[HOOKS] subagent_gate: CWD %s not inside src/<package>", cwd)
return None
src = repo_root / "src" / package
try:
rel = Path(cwd).resolve().relative_to(src)
return rel.parts[0] if rel.parts else None
except ValueError:
logger.info("[HOOKS] subagent_gate: CWD %s not inside src/aipass", cwd)
logger.info("[HOOKS] subagent_gate: CWD %s not inside %s", cwd, src)
return None
def _get_modified_py_files(cwd: str, repo_root: Path) -> list[str]:
"""Get modified .py files scoped to the CWD branch via drone."""
cwd_branch = _get_cwd_branch(cwd, repo_root)
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
package = _get_package_from_cwd(cwd)
branch_dir = repo_root / "src" / package / cwd_branch if (cwd_branch and package) else None
if not branch_dir or not branch_dir.exists():
return []
result = subprocess.run(
@@ -105,7 +119,8 @@ def _run_seedgo_checklist(file_path: str, repo_root: Path) -> list[str]:
def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None:
"""Return advisory if hook files changed without README update."""
cwd_branch = _get_cwd_branch(cwd, repo_root)
branch_dir = repo_root / "src" / "aipass" / cwd_branch if cwd_branch else None
package = _get_package_from_cwd(cwd)
branch_dir = repo_root / "src" / package / cwd_branch if (cwd_branch and package) else None
if not branch_dir or not branch_dir.exists():
return None
result = subprocess.run(
@@ -125,3 +125,63 @@ class TestSubagentGateHandler:
result = handle({"cwd": "/fake/repo/src/aipass/hooks"})
assert result["exit_code"] == 0
assert result["stdout"] == ""
class TestSubagentGateExternalProject:
"""Verify subagent gate works for non-AIPass projects (e.g. src/vera_studio/)."""
def test_get_cwd_branch_external_package(self):
from pathlib import Path
from aipass.hooks.apps.handlers.security.subagent_gate import _get_cwd_branch
repo_root = Path("/home/user/Projects/vera")
cwd = "/home/user/Projects/vera/src/vera_studio/quality"
branch = _get_cwd_branch(cwd, repo_root)
assert branch == "quality"
def test_get_cwd_branch_aipass_still_works(self):
from pathlib import Path
from aipass.hooks.apps.handlers.security.subagent_gate import _get_cwd_branch
repo_root = Path("/home/user/Projects/AIPass")
cwd = "/home/user/Projects/AIPass/src/aipass/hooks"
branch = _get_cwd_branch(cwd, repo_root)
assert branch == "hooks"
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") == ""
@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")
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
def test_violations_block_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
from pathlib import Path
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"})
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "Missing docstring" in parsed["reason"]
@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", return_value=[])
@patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files")
@patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root")
@patch("aipass.hooks.apps.handlers.security.subagent_gate.speak")
def test_clean_files_allow_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme):
from pathlib import Path
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"})
assert result["exit_code"] == 0
assert result["stdout"] == ""