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:
co-authored by
Claude Opus 4.8
parent
97a3276b81
commit
f8f102e16f
+6
-5
@@ -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"] == ""
|
||||
|
||||
Reference in New Issue
Block a user