Merge pull request #510 from AIOSAI/work/system

feat(system): ai_mail vectorization: sys.executable fallback when memory venv missing + mechanical-guardrails prompt docs
This commit is contained in:
AIPass
2026-05-03 23:16:21 -07:00
committed by GitHub
6 changed files with 276 additions and 14 deletions
+5 -4
View File
@@ -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.
+3 -3
View File
@@ -27,7 +27,7 @@ BLOCKED_GIT_VERBS = (
)
BLOCKED_GIT_RE = re.compile(
r"(?<![@\w/.])git\s+(?:-[A-Za-z0-9_=]+(?:\s+[^\s]+)?\s+)*"
r"(?<![@\w/.])git\s+(?:--?[A-Za-z][A-Za-z0-9_-]*(?:[= ][^\s]+)?\s+)*"
r"(" + "|".join(BLOCKED_GIT_VERBS) + r")\b"
)
@@ -106,8 +106,8 @@ def main():
return
# Strip quoted strings before matching — text inside "..." or '...' is data
# (PR descriptions, commit messages, examples in docs), not code to enforce.
scan = re.sub(r'"[^"]*"', '""', cmd)
scan = re.sub(r"\'[^\']*\'", "\'\'", scan)
scan = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
scan = re.sub(r"'(?:[^'\\]|\\.)*'", "''", scan)
if BLOCKED_GIT_STASH_RE.search(scan) or BLOCKED_GIT_RE.search(scan):
_block(GIT_REDIRECT)
if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan):
+26 -6
View File
@@ -552,6 +552,8 @@ settings["hooks"] = {
"PostToolUse": [
{"matcher": "Edit|MultiEdit|Write|NotebookEdit",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_fix_diagnostics.py"}]},
{"matcher": "Bash",
"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/auto_watchdog.py"}]},
],
"Stop": [
{"hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/stop_sound.py"}]},
@@ -834,15 +836,31 @@ elif [ "$IS_MACOS" -eq 1 ]; then
else
echo "Creating global symlinks ..."
VENV_BIN="$SCRIPT_DIR/.venv/bin"
LOCAL_BIN="/usr/local/bin"
LINUX_SYMLINK_DIR=""
for cmd in drone; do
if [ -f "$VENV_BIN/$cmd" ]; then
if sudo ln -sf "$VENV_BIN/$cmd" "$LOCAL_BIN/$cmd" 2>/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."
@@ -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.
+5
View File
@@ -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": {
+222
View File
@@ -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}"
# =============================================