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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
# =============================================
|
||||
Reference in New Issue
Block a user