diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index e6f21256..777ec850 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -79,10 +79,11 @@ Allowed: - `drone @git fix` — repair broken git states (devpulse only) - `git status`, `git diff`, `git log` — read-only, always fine -Forbidden (denied system-wide in `.claude/settings.json`): - - `git checkout*` — any form, including `-b`, `-`, branch names - - `git add -f*` / `--force*` - - Culturally avoid `git commit`, `git push`, `gh pr create` directly — go through drone +Mechanically blocked by the `git_gate.py` PreToolUse hook (applies to ALL sessions including dispatched agents — bypassPermissions does not skip hooks): + - All raw `git` write verbs: `commit`, `push`, `pull`, `merge`, `rebase`, `reset`, `checkout`, `switch`, `branch`, `cherry-pick`, `revert`, `rm`, `mv`, `restore`, `clean`, `config`, `tag`, `stash drop|clear|pop|apply` + - All raw `gh` write subcommands (`pr`, `issue`, `repo`, `release`, `workflow`, `run`, `cache`, `secret`, `variable`, `gist`) and any `gh api` call + - Edits to `**/.claude/settings*.json`, `**/.claude/hooks/**`, `**/.git/hooks/**` (the enforcement layer itself) + - Use `drone @git pr "msg"` instead. Drone calls git via Python subprocess so its operations don't pass through this hook. If `drone @git system-pr` fails to return HEAD to main, that's a drone bug — report it, don't work around it by staying on a branch. diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py index 2f149d85..b4491faa 100755 --- a/.claude/hooks/git_gate.py +++ b/.claude/hooks/git_gate.py @@ -27,7 +27,7 @@ BLOCKED_GIT_VERBS = ( ) BLOCKED_GIT_RE = re.compile( - r"(?/dev/null; then - echo " $LOCAL_BIN/$cmd -> $VENV_BIN/$cmd" + if sudo ln -sf "$VENV_BIN/$cmd" "/usr/local/bin/$cmd" 2>/dev/null; then + echo " /usr/local/bin/$cmd -> $VENV_BIN/$cmd" + LINUX_SYMLINK_DIR="/usr/local/bin" else - echo " WARN: Could not create symlink for $cmd (try running with sudo)" - echo " Manual fix: sudo ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd" + # Fallback: user-local bin (no sudo needed) + LOCAL_BIN="$HOME/.local/bin" + mkdir -p "$LOCAL_BIN" + if ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd"; then + echo " /usr/local/bin failed (no sudo) — using $LOCAL_BIN/$cmd instead" + LINUX_SYMLINK_DIR="$LOCAL_BIN" + # Ensure ~/.local/bin is on PATH + PROFILE="${HOME}/.bashrc" + if ! grep -q '\.local/bin' "$PROFILE" 2>/dev/null; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE" + echo " ~/.local/bin added to PATH in $PROFILE" + fi + export PATH="$HOME/.local/bin:$PATH" + else + echo " WARN: Could not create symlink for $cmd" + echo " Manual fix: ln -sf $VENV_BIN/$cmd $LOCAL_BIN/$cmd" + fi fi fi done @@ -858,8 +876,10 @@ if [ "$FAIL" -eq 0 ]; then echo "Add the appropriate directory to your PATH (see above)." elif [ "$IS_MACOS" -eq 1 ]; then echo "drone is available via ~/.local/bin symlink (on PATH)." - else + elif [ "$LINUX_SYMLINK_DIR" = "/usr/local/bin" ]; then echo "drone is available globally via /usr/local/bin symlink." + else + echo "drone is available via ~/.local/bin symlink (on PATH)." fi echo "seedgo is accessed via: drone @seedgo" echo "No venv activation needed for CLI commands." diff --git a/src/aipass/ai_mail/apps/handlers/email/purge.py b/src/aipass/ai_mail/apps/handlers/email/purge.py index f6319a4e..8b1804c8 100644 --- a/src/aipass/ai_mail/apps/handlers/email/purge.py +++ b/src/aipass/ai_mail/apps/handlers/email/purge.py @@ -20,6 +20,8 @@ v2.0.0: deleted/ now uses directory structure (like sent/). """ import json +import os +import sys import subprocess from pathlib import Path from datetime import datetime @@ -35,12 +37,24 @@ MAX_EMAILS = 10 # Memory branch paths for subprocess vectorization (optional external service) # These are resolved relative to repo root if available; vectorization is best-effort _REPO_ROOT = find_repo_root() -MEMORY_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3" +_MEMORY_VENV_PYTHON = _REPO_ROOT / "src" / "aipass" / "memory" / ".venv" / "bin" / "python3" CHROMA_SUBPROCESS_SCRIPT = ( _REPO_ROOT / "src" / "aipass" / "memory" / "apps" / "handlers" / "storage" / "chroma_subprocess.py" ) +def _get_memory_python() -> str: + env = os.environ.get("AIPASS_MEMORY_PYTHON") + if env: + return env + if _MEMORY_VENV_PYTHON.exists(): + return str(_MEMORY_VENV_PYTHON) + return sys.executable + + +MEMORY_PYTHON = _get_memory_python() + + def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]: """ Purge sent folder if count exceeds threshold. diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index e8355b41..502cffba 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -180,6 +180,11 @@ "standard": "imports", "file": "apps/handlers/feedback/compose.py", "reason": "_AIPASS_ROOT used as fallback for ai_mail inbox resolution when CWD-based lookup fails. Feedback channel only works between dev-install branches — pip users don't have cross-branch communication. Real fix is registry-based path resolution (same class as DPLAN-0149 pip install gaps)." + }, + { + "standard": "encapsulation", + "file": "tests/test_git_gate.py", + "reason": "Test imports git_gate.py from ~/.claude/hooks/ via importlib.util.spec_from_file_location. git_gate is a user-level hook (not a branch module), so no aipass package path exists. No cross-branch handler import — this is outside the branch tree entirely." } ], "notes": { diff --git a/src/aipass/devpulse/tests/test_git_gate.py b/src/aipass/devpulse/tests/test_git_gate.py new file mode 100644 index 00000000..537679fc --- /dev/null +++ b/src/aipass/devpulse/tests/test_git_gate.py @@ -0,0 +1,222 @@ +# =================== AIPass ==================== +# Name: test_git_gate.py +# Description: Regex coverage for git_gate.py hook (DPLAN-0163) +# Version: 1.0.0 +# Created: 2026-05-03 +# Modified: 2026-05-03 +# ============================================= + +"""Tests for git_gate.py — PreToolUse hook blocking raw git/gh writes. + +NOTE: This test imports git_gate.py from ~/.claude/hooks/ (outside +the branch tree). This is intentional — git_gate is a user-level +hook, not a branch module, so there is no aipass package path for it. +""" + +import importlib.util +import re +from pathlib import Path + +import pytest + +HOOK_PATH = Path.home() / ".claude" / "hooks" / "git_gate.py" + +_spec = importlib.util.spec_from_file_location("git_gate", HOOK_PATH) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +BLOCKED_GIT_RE = _mod.BLOCKED_GIT_RE +BLOCKED_GIT_STASH_RE = _mod.BLOCKED_GIT_STASH_RE +BLOCKED_GH_RE = _mod.BLOCKED_GH_RE +BLOCKED_GH_API_RE = _mod.BLOCKED_GH_API_RE +BLOCKED_EDIT_PATTERNS = _mod.BLOCKED_EDIT_PATTERNS + + +class TestGitWriteBlocking: + """Core git write verbs must be blocked.""" + + @pytest.mark.parametrize("cmd", [ + "git commit -m 'test'", + "git push origin main", + "git pull", + "git merge main", + "git rebase main", + "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", + "git mv old.py new.py", + "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.""" + assert BLOCKED_GIT_RE.search(cmd), f"Should block: {cmd}" + + @pytest.mark.parametrize("cmd", [ + "git stash drop", + "git stash clear", + "git stash pop", + "git stash apply", + ]) + def test_blocks_stash_destructive(self, cmd): + """Destructive stash subcommands are blocked.""" + assert BLOCKED_GIT_STASH_RE.search(cmd), f"Should block: {cmd}" + + +class TestLongFormFlagBypass: + """DPLAN-0163 Finding 1: long-form flags must not bypass detection.""" + + @pytest.mark.parametrize("cmd", [ + "git --config core.hooksPath=/dev/null commit", + "git --no-pager push", + "git -c x=y commit", + "git --git-dir=/x checkout", + "git --config=core.hooksPath=/dev/null commit", + "git --no-pager -c x=y push", + "git --work-tree=/tmp commit -m 'x'", + "git -C /some/path commit", + "git --bare push origin main", + ]) + def test_blocks_long_form_flag_bypass(self, cmd): + """Long-form flags before the verb must not hide the write verb.""" + assert BLOCKED_GIT_RE.search(cmd), f"Should block: {cmd}" + + +class TestEscapedQuoteBypass: + """DPLAN-0163 Finding 2: escaped quotes must not break quote-stripping. + + The gate strips quoted strings before scanning, so commit messages + containing git verbs don't trigger false positives. Escaped quotes + inside those strings must not break the stripping. + """ + + @pytest.mark.parametrize("cmd,should_block", [ + ('echo "git commit inside quotes"', False), + ("echo 'git push inside single quotes'", False), + ('echo "msg \\"escaped\\" inner"', False), + ("echo 'msg \\'escaped\\' inner'", False), + ('drone @git pr "fix: git commit msg"', False), + ('git commit -m "msg \\"escaped\\""', True), + ]) + def test_escaped_quote_stripping(self, cmd, should_block): + """Escaped quotes inside strings must not leak verb matches.""" + scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd) + scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan) + matched = bool(BLOCKED_GIT_RE.search(scan)) + assert matched == should_block, ( + f"{'Should block' if should_block else 'Should allow'}: {cmd}\n" + f" After strip: {scan}" + ) + + +class TestReadOnlyAllowed: + """Read-only git commands must not be blocked.""" + + @pytest.mark.parametrize("cmd", [ + "git status", + "git log --oneline", + "git diff", + "git diff --staged", + "git show HEAD", + "git fetch", + "git fetch origin", + "git ls-files", + "git log --graph --all", + "git stash list", + "git stash show", + "git rev-parse HEAD", + "git describe --tags", + "git remote -v", + "git blame file.py", + "git shortlog -sn", + ]) + 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}" + + +class TestDroneNotBlocked: + """Drone commands must never be blocked.""" + + @pytest.mark.parametrize("cmd", [ + 'drone @git pr "description"', + 'drone @git system-pr "fix"', + "drone @git smart-sync", + "drone @git sync", + "drone @git status", + "drone @git merge 42", + ]) + def test_allows_drone(self, cmd): + """Drone-wrapped git ops are not raw git — must pass.""" + assert not BLOCKED_GIT_RE.search(cmd), f"Should allow: {cmd}" + + +class TestGhBlocking: + """gh write subcommands blocked, read-only allowed.""" + + @pytest.mark.parametrize("cmd", [ + "gh pr create --title x", + "gh pr merge 42", + "gh pr close 42", + "gh issue create", + "gh issue close 5", + "gh release create v1", + "gh repo create x", + "gh api repos/x/pulls", + ]) + def test_blocks_gh_writes(self, cmd): + """State-changing gh subcommands are blocked.""" + blocked = BLOCKED_GH_RE.search(cmd) or BLOCKED_GH_API_RE.search(cmd) + assert blocked, f"Should block: {cmd}" + + @pytest.mark.parametrize("cmd", [ + "gh pr list", + "gh pr view 42", + "gh pr status", + "gh pr diff 42", + "gh pr checks 42", + "gh issue list", + "gh issue view 5", + "gh issue status", + ]) + def test_allows_gh_reads(self, cmd): + """Read-only gh subcommands must pass through.""" + assert not BLOCKED_GH_RE.search(cmd), f"Should allow: {cmd}" + assert not BLOCKED_GH_API_RE.search(cmd), f"Should allow: {cmd}" + + +class TestEditBlocking: + """Protected file paths must be blocked by edit patterns.""" + + @pytest.mark.parametrize("path", [ + "/home/user/.claude/settings.json", + "/home/user/.claude/settings.local.json", + "/home/user/.claude/hooks/git_gate.py", + "/home/user/.claude/hooks/pre_edit_gate.py", + "/repo/.git/hooks/pre-commit", + ]) + def test_blocks_protected_paths(self, path): + """Enforcement-layer files are protected from edits.""" + matched = any(p.search(path) for p in BLOCKED_EDIT_PATTERNS) + assert matched, f"Should block edit: {path}" + + @pytest.mark.parametrize("path", [ + "/home/user/project/src/main.py", + "/home/user/.claude/CLAUDE.md", + "/home/user/project/.git/config", + "/home/user/project/src/aipass/devpulse/apps/handler.py", + ]) + def test_allows_normal_paths(self, path): + """Normal project files are not blocked.""" + matched = any(p.search(path) for p in BLOCKED_EDIT_PATTERNS) + assert not matched, f"Should allow edit: {path}" + + +# =============================================