From f8f102e16f4270b1103410d531ca24ee8c09f6bb Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Fri, 29 May 2026 00:33:50 -0700 Subject: [PATCH] =?UTF-8?q?fix(hooks):=20subagent=5Fgate=20package-aware?= =?UTF-8?q?=20=E2=80=94=20closes=20#605?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//); previously silently no-opped. 5 new external-project tests (258 pass), seedgo 100%. Closes #605 Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 11 ++-- .../apps/handlers/security/subagent_gate.py | 25 ++++++-- src/aipass/hooks/tests/test_subagent_gate.py | 60 +++++++++++++++++++ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9992c6d8..30d4776a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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///` 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///` 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. diff --git a/src/aipass/hooks/apps/handlers/security/subagent_gate.py b/src/aipass/hooks/apps/handlers/security/subagent_gate.py index 06d28b8f..a6a64309 100644 --- a/src/aipass/hooks/apps/handlers/security/subagent_gate.py +++ b/src/aipass/hooks/apps/handlers/security/subagent_gate.py @@ -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/) the CWD is in.""" - src = repo_root / "src" / "aipass" + """Detect which branch directory (src//) the CWD is in.""" + package = _get_package_from_cwd(cwd) + if not package: + logger.info("[HOOKS] subagent_gate: CWD %s not inside src/", 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( diff --git a/src/aipass/hooks/tests/test_subagent_gate.py b/src/aipass/hooks/tests/test_subagent_gate.py index e5c83899..8c97358e 100644 --- a/src/aipass/hooks/tests/test_subagent_gate.py +++ b/src/aipass/hooks/tests/test_subagent_gate.py @@ -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"] == ""