diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py index b4491faa..c5acea28 100755 --- a/.claude/hooks/git_gate.py +++ b/.claude/hooks/git_gate.py @@ -22,8 +22,8 @@ from pathlib import Path BLOCKED_GIT_VERBS = ( "commit", "push", "pull", "merge", "rebase", "reset", - "checkout", "switch", "branch", "cherry-pick", "revert", - "rm", "mv", "restore", "clean", "config", "tag", + "checkout", "switch", "cherry-pick", "revert", + "rm", "mv", "restore", "clean", "config", ) BLOCKED_GIT_RE = re.compile( @@ -35,6 +35,18 @@ BLOCKED_GIT_STASH_RE = re.compile( r"(? str: return "" +def _is_project_owner(cwd: str) -> bool: + """Check if the current branch's passport has citizenship.owner: true.""" + p = Path(cwd) + for d in [p] + list(p.parents): + passport = d / ".trinity" / "passport.json" + if passport.is_file(): + try: + data = json.loads(passport.read_text(encoding="utf-8")) + return bool(data.get("citizenship", {}).get("owner")) + except Exception: + return False + if (d / ".git").exists(): + break + return False + + def main(): try: data = json.load(sys.stdin) @@ -108,10 +136,13 @@ def main(): # (PR descriptions, commit messages, examples in docs), not code to enforce. scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd) scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan) - if BLOCKED_GIT_STASH_RE.search(scan) or BLOCKED_GIT_RE.search(scan): + if (BLOCKED_GIT_RE.search(scan) or BLOCKED_GIT_STASH_RE.search(scan) + or BLOCKED_GIT_BRANCH_RE.search(scan) or BLOCKED_GIT_TAG_RE.search(scan) + or BLOCKED_GIT_REMOTE_RE.search(scan)): _block(GIT_REDIRECT) if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan): - _block(GH_REDIRECT) + if not (_cwd_branch(cwd) in TRUSTED_HOOK_EDITORS or _is_project_owner(cwd)): + _block(GH_REDIRECT) return if tool_name in EDIT_TOOLS: diff --git a/.gitignore b/.gitignore index cc35ba23..c864df81 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ claude_4_7_transition_notes.md *.bak src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/auth.cpython-312.pyc src/aipass/drone/apps/plugins/devpulse_ops/__pycache__/__init__.cpython-312.pyc +.backup_system \ No newline at end of file diff --git a/src/aipass/cli/apps/handlers/init/scaffold_content.py b/src/aipass/cli/apps/handlers/init/scaffold_content.py index c4582a18..d1ce34a1 100644 --- a/src/aipass/cli/apps/handlers/init/scaffold_content.py +++ b/src/aipass/cli/apps/handlers/init/scaffold_content.py @@ -312,6 +312,16 @@ def global_prompt_md(name: str) -> str: "Projects use the registry.\n" "- **Memory** — update `.trinity/local.json` at session end. " "Memory is presence.\n" + "\n" + "## Maintenance\n" + "\n" + "- **Upgrade scaffold**: `drone @cli aipass init update` refreshes " + "managed project files (hooks, prompts, settings) to latest templates.\n" + "- **Entry point**: each agent's `apps/{name}.py` auto-configures " + "`sys.path` and `AIPASS_BRANCH_NAME` env var. If prax logs to " + "`unknown_branch/`, check that these are set.\n" + "- **Standalone projects** use `src/{name}/` layout (not `src/aipass/{name}/`). " + "Module discovery adapts automatically.\n" ) diff --git a/src/aipass/devpulse/tests/test_git_gate.py b/src/aipass/devpulse/tests/test_git_gate.py index 537679fc..9db0ae00 100644 --- a/src/aipass/devpulse/tests/test_git_gate.py +++ b/src/aipass/devpulse/tests/test_git_gate.py @@ -27,6 +27,9 @@ _spec.loader.exec_module(_mod) BLOCKED_GIT_RE = _mod.BLOCKED_GIT_RE BLOCKED_GIT_STASH_RE = _mod.BLOCKED_GIT_STASH_RE +BLOCKED_GIT_BRANCH_RE = _mod.BLOCKED_GIT_BRANCH_RE +BLOCKED_GIT_TAG_RE = _mod.BLOCKED_GIT_TAG_RE +BLOCKED_GIT_REMOTE_RE = _mod.BLOCKED_GIT_REMOTE_RE BLOCKED_GH_RE = _mod.BLOCKED_GH_RE BLOCKED_GH_API_RE = _mod.BLOCKED_GH_API_RE BLOCKED_EDIT_PATTERNS = _mod.BLOCKED_EDIT_PATTERNS @@ -44,7 +47,6 @@ class TestGitWriteBlocking: "git reset HEAD~1", "git checkout -b new-branch", "git switch -c new-branch", - "git branch -D old", "git cherry-pick abc123", "git revert HEAD", "git rm file.txt", @@ -52,7 +54,6 @@ class TestGitWriteBlocking: "git restore --staged file.py", "git clean -fd", "git config user.name 'x'", - "git tag v1.0", ]) def test_blocks_write_verbs(self, cmd): """Each blocked git verb triggers the regex.""" @@ -68,6 +69,44 @@ class TestGitWriteBlocking: """Destructive stash subcommands are blocked.""" assert BLOCKED_GIT_STASH_RE.search(cmd), f"Should block: {cmd}" + @pytest.mark.parametrize("cmd", [ + "git branch -D old", + "git branch -d old", + "git branch -m old new", + "git branch -M old new", + "git branch -c old new", + "git branch --delete old", + "git branch --move old new", + "git branch --copy old new", + "git branch --force old", + "git branch --set-upstream-to=origin/main", + "git branch --unset-upstream", + ]) + def test_blocks_branch_destructive(self, cmd): + """Destructive branch subcommands are blocked.""" + assert BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should block: {cmd}" + + @pytest.mark.parametrize("cmd", [ + "git tag -d v1.0", + "git tag --delete v1.0", + "git tag -f v1.0", + "git tag --force v1.0", + ]) + def test_blocks_tag_destructive(self, cmd): + """Destructive tag operations are blocked.""" + assert BLOCKED_GIT_TAG_RE.search(cmd), f"Should block: {cmd}" + + @pytest.mark.parametrize("cmd", [ + "git remote add origin url", + "git remote remove origin", + "git remote rename old new", + "git remote set-url origin url", + "git remote prune origin", + ]) + def test_blocks_remote_destructive(self, cmd): + """Destructive remote operations are blocked.""" + assert BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should block: {cmd}" + class TestLongFormFlagBypass: """DPLAN-0163 Finding 1: long-form flags must not bypass detection.""" @@ -135,11 +174,26 @@ class TestReadOnlyAllowed: "git remote -v", "git blame file.py", "git shortlog -sn", + "git branch", + "git branch -r", + "git branch -a", + "git branch --list", + "git branch -v", + "git branch --show-current", + "git branch --contains abc123", + "git tag", + "git tag -l", + "git tag --list", + "git remote", + "git remote show origin", ]) def test_allows_read_only(self, cmd): """Read-only git subcommands must pass through.""" assert not BLOCKED_GIT_RE.search(cmd), f"Should allow: {cmd}" assert not BLOCKED_GIT_STASH_RE.search(cmd), f"Should allow: {cmd}" + assert not BLOCKED_GIT_BRANCH_RE.search(cmd), f"Should allow: {cmd}" + assert not BLOCKED_GIT_TAG_RE.search(cmd), f"Should allow: {cmd}" + assert not BLOCKED_GIT_REMOTE_RE.search(cmd), f"Should allow: {cmd}" class TestDroneNotBlocked: diff --git a/src/aipass/prax/.backupignore b/src/aipass/prax/.backupignore new file mode 100644 index 00000000..aef1a733 --- /dev/null +++ b/src/aipass/prax/.backupignore @@ -0,0 +1,39 @@ +# Backup System ignore patterns (gitignore-style) +# Lines starting with # are comments. Blank lines are ignored. + +# Backup system's own directory +.backup_system/ + +# Version control +.git/ +.svn/ +.hg/ + +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.venv/ +venv/ +.tox/ + +# Node +node_modules/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +build/ +dist/ + +# Logs +*.log diff --git a/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py b/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py index d139020f..6bc61c60 100644 --- a/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py +++ b/src/aipass/spawn/templates/builder/apps/{{BRANCH}}.py @@ -7,12 +7,19 @@ Auto-discovery architecture: - No manual imports or routing needed """ -import sys import importlib +import os +import sys from pathlib import Path from typing import List, Any -from aipass.prax import logger +PROJECT_ROOT = str(Path(__file__).resolve().parent.parent) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + +os.environ.setdefault("AIPASS_BRANCH_NAME", "{{BRANCH}}") + +from aipass.prax import logger # noqa: E402 # ============================================================================= # MODULE DISCOVERY @@ -21,6 +28,15 @@ from aipass.prax import logger MODULES_DIR = Path(__file__).parent / "modules" +def _module_import_path(stem: str) -> str: + """Return the correct import path for a module, handling both layouts.""" + try: + importlib.import_module(f"aipass.{{BRANCH}}.apps.modules.{stem}") + return f"aipass.{{BRANCH}}.apps.modules.{stem}" + except ImportError: + return f"apps.modules.{stem}" + + def discover_modules() -> List[Any]: """Auto-discover modules in modules/ directory.""" modules = [] @@ -32,7 +48,7 @@ def discover_modules() -> List[Any]: if file_path.name.startswith("_"): continue - module_name = f"aipass.{{BRANCH}}.apps.modules.{file_path.stem}" + module_name = _module_import_path(file_path.stem) try: module = importlib.import_module(module_name)