merge main into #373 — take Track B version of hooks.py (superset of Track A)

This commit is contained in:
AIOSAI
2026-04-21 08:30:08 -07:00
14 changed files with 1302 additions and 94 deletions
+15 -22
View File
@@ -12,10 +12,9 @@ Key behaviors:
- Saves ruff lint AND pyright errors to state file for PreToolUse gate (hard block)
- Surfaces ALL errors in additionalContext so Claude sees them
Version: 5.3.0
Version: 5.2.0
CHANGELOG:
- v5.3.0 (2026-04-20): [SILENT-FIX] label + IDE fallback with loud announce.
- v5.2.0 (2026-04-20): Save ruff lint errors to state file for hard-block enforcement.
Pre-edit gate now blocks on F401/lint just like type errors.
- v5.1.0 (2026-04-19): Added ruff format --check to surface format drift.
@@ -177,15 +176,11 @@ def run_ruff_lint_structured(file_path: str) -> list[dict]:
return []
def run_pyright_check(file_path: str) -> tuple[list[dict], bool]:
"""Run pyright on a single file. Returns (errors, fallback_needed).
fallback_needed is True when pyright is unavailable (FileNotFoundError).
Timeout and generic exceptions return ([], False) — silent.
"""
def run_pyright_check(file_path: str) -> list[dict]:
"""Run pyright on a single file. Returns list of error dicts."""
# Skip hook files - they don't follow project standards
if '/.claude/hooks/' in file_path:
return [], False
return []
try:
result = subprocess.run(
@@ -198,7 +193,7 @@ def run_pyright_check(file_path: str) -> tuple[list[dict], bool]:
try:
data = json.loads(result.stdout)
except (json.JSONDecodeError, ValueError):
return [], False
return []
errors = []
for diag in data.get("generalDiagnostics", []):
@@ -211,14 +206,14 @@ def run_pyright_check(file_path: str) -> tuple[list[dict], bool]:
"message": message[:100]
})
return errors[:10], False # Max 10 errors
return errors[:10] # Max 10 errors
except FileNotFoundError:
return [], True # pyright not installed — signal fallback needed
return [] # pyright not installed
except subprocess.TimeoutExpired:
return [], False # Timeout — don't block
return [] # Timeout — don't block
except Exception:
return [], False
return []
def save_diagnostics_state(file_path: str, errors: list[dict]):
@@ -343,7 +338,6 @@ def main():
# Collect all errors
errors = []
file_type = ""
fallback_needed = False
if file_path.endswith(".py"):
file_type = "Python"
@@ -355,9 +349,7 @@ def main():
errors.append(f"SEEDGO: {v}")
# Pyright type errors (single file)
type_errors, fallback_needed = run_pyright_check(file_path)
if fallback_needed:
print("[FALLBACK ENGAGED: IDE diagnostics — VS Code only]", file=sys.stderr, flush=True)
type_errors = run_pyright_check(file_path)
for te in type_errors:
errors.append(f"TYPE: L{te['line']}: {te['message']}")
@@ -374,16 +366,17 @@ def main():
# Build output
if errors:
error_text = "\n".join(f" - {e}" for e in errors)
context = f"[SILENT-FIX] {len(errors)} error(s) in {Path(file_path).name}:\n{error_text}\n\nFix these silently — do not announce or explain, just fix and continue."
if fallback_needed:
context += "\n\n[FALLBACK] pyright unavailable — run mcp__ide__getDiagnostics to check type errors. VS Code only. Non-VS-Code users: this check is a no-op."
context = f"""[AUTO-FIX] {len(errors)} error(s) in {Path(file_path).name}:
{error_text}
Fix these errors in {Path(file_path).name} now. Do not skip or defer."""
output = {
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": context
},
"systemMessage": f"[SILENT-FIX] {len(errors)} error(s) — fix before continuing"
"systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing"
}
print(json.dumps(output))
else:
+58 -36
View File
@@ -1,34 +1,34 @@
#!/usr/bin/env python3
"""
PreToolUse Gate — Blocks edits when unresolved type errors exist.
PreToolUse Gate — Blocks unsafe edits at the hook layer.
Two-hook system:
PostToolUse (auto_fix_diagnostics.py) → detects errors, saves to state file
PreToolUse (this file) → reads state file, blocks edits to OTHER files
Rules (checked in order):
1. Inbox lock — any write targeting *.ai_mail.local/inbox.json is BLOCKED.
Use `drone @ai_mail email` instead.
2. Cross-branch — writes to src/aipass/X/** from a CWD inside src/aipass/Y/**
are BLOCKED unless the calling branch is in TRUSTED_CROSS_WRITERS.
3. State-file — edits to OTHER .py files while the current branch has unresolved
type errors are BLOCKED. (original v1.2.0 logic)
Logic:
- No state file or empty → ALLOW
- Editing the SAME file that has errors → ALLOW (they're fixing it)
- Errored file in a DIFFERENT branch → ALLOW (not your problem)
- Editing a DIFFERENT file in SAME branch → BLOCK (fix errors first)
Version: 1.2.0
Track E additions: rules 1 + 2 (DPLAN-0139).
Version: 1.3.0
"""
import json
import os
import sys
from pathlib import Path
STATE_FILE = Path(__file__).parent / ".diagnostics_state.json"
EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"}
# Single source of truth lives in permissions.py — inline here as fallback
# so the hook works even when aipass package is not on sys.path.
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
def _get_branch(file_path: str) -> str:
"""Extract AIPass branch name from file path.
Looks for src/aipass/{branch}/ pattern. Returns branch name
or empty string if not in a branch.
"""
"""Extract AIPass branch name from a file path (src/aipass/{branch}/ pattern)."""
parts = Path(file_path).parts
for i, part in enumerate(parts):
if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts):
@@ -36,6 +36,11 @@ def _get_branch(file_path: str) -> str:
return ""
def _block(reason: str) -> None:
print(json.dumps({"decision": "block", "reason": reason}))
sys.exit(2)
def main():
try:
input_data = json.load(sys.stdin)
@@ -43,60 +48,77 @@ def main():
tool_input = input_data.get("tool_input", {})
file_path = tool_input.get("file_path", "")
# Only gate edit tools
if tool_name not in EDIT_TOOLS:
return
# Only gate Python files
if not file_path:
return
# ------------------------------------------------------------------
# Rule 1: Inbox lock — block all writes to *.ai_mail.local/inbox.json
# ------------------------------------------------------------------
fp = Path(file_path)
if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts:
_block(
"Direct writes to inbox.json are blocked.\n"
"Use: drone @ai_mail email @<branch> \"Subject\" \"Body\""
)
# ------------------------------------------------------------------
# Rule 2: Cross-branch write enforcement
# ------------------------------------------------------------------
cwd = input_data.get("cwd", "") or os.getcwd()
cwd_branch = _get_branch(cwd)
target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp))
if cwd_branch and target_branch and cwd_branch != target_branch:
if cwd_branch not in TRUSTED_CROSS_WRITERS:
_block(
f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n"
f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}"
)
# ------------------------------------------------------------------
# Rule 3: State-file (original v1.2.0) — .py files only
# ------------------------------------------------------------------
if not file_path.endswith(".py"):
return
# No state file → no pending errors → allow
if not STATE_FILE.exists():
return
try:
state = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, IOError):
return # Corrupted state → allow
return
errored_file = state.get("file", "")
errors = state.get("errors", [])
# No errors in state → allow
if not errors:
return
# Resolve both paths for comparison
try:
current = str(Path(file_path).resolve())
errored = str(Path(errored_file).resolve())
except (OSError, ValueError):
return # Path resolution failed → allow
return
# Editing the file WITH errors → allow (they're fixing it)
if current == errored:
return
# Different branch → allow (cross-branch errors aren't your problem)
# If errored file is outside AIPass entirely → allow (external projects)
current_branch = _get_branch(current)
errored_branch = _get_branch(errored)
if not errored_branch:
return # Errored file is outside src/aipass/ — don't gate
return
if current_branch and errored_branch and current_branch != errored_branch:
return
# Editing a DIFFERENT file in SAME branch while errors exist → BLOCK
error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5])
reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}"
output = {
"decision": "block",
"reason": reason
}
print(json.dumps(output))
sys.exit(2)
_block(
f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n"
f"{error_summary}"
)
except Exception:
pass # Silent fail → allow
@@ -378,6 +378,69 @@ def deliver_email_to_branch(
return True, ""
def deliver_to_inbox_file(inbox_file: Path, email_data: Dict) -> Tuple[bool, str, str]:
"""Write *email_data* to an inbox.json file and fire a desktop notification.
Single canonical path for direct-path delivery (used by cross-project
reply.py to replace the raw-write backdoor). Always fires notify-send.
Args:
inbox_file: Absolute path to the target inbox.json.
email_data: Dict with at minimum ``from``, ``to``, ``subject``,
``message``, ``timestamp``. An ``id`` key is assigned
internally if absent.
Returns:
``(success, error_msg, reply_id)`` — ``reply_id`` is the 8-char hex
string assigned to the message (empty string on failure).
"""
if not inbox_file.exists():
return False, f"inbox not found: {inbox_file}", ""
try:
with _get_inbox_lock()(inbox_file):
try:
with open(inbox_file, "r", encoding="utf-8") as fh:
inbox_data = json.load(fh)
except Exception as exc:
logger.warning("[delivery] deliver_to_inbox_file read failed %s: %s", inbox_file, exc)
return False, f"Failed to read inbox: {exc}", ""
inbox_data = _migrate_inbox_format(inbox_data, inbox_file)
reply_id = str(uuid.uuid4())[:8]
email_data = dict(email_data)
email_data.setdefault("id", reply_id)
reply_id = email_data["id"]
inbox_data.setdefault("messages", []).insert(0, email_data)
inbox_data["total_messages"] = len(inbox_data["messages"])
inbox_data["unread_count"] = sum(
1
for m in inbox_data["messages"]
if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False))
)
try:
with open(inbox_file, "w", encoding="utf-8") as fh:
json.dump(inbox_data, fh, indent=2, ensure_ascii=False)
except Exception as exc:
logger.warning("[delivery] deliver_to_inbox_file write failed %s: %s", inbox_file, exc)
return False, f"Failed to write inbox: {exc}", ""
except OSError as exc:
logger.warning("[delivery] deliver_to_inbox_file lock failed %s: %s", inbox_file, exc)
return False, f"Failed to acquire inbox lock: {exc}", ""
_send_desktop_notification(
email_data.get("from", "@unknown"),
email_data.get("to", str(inbox_file)),
email_data.get("subject", ""),
email_data.get("message", ""),
)
return True, "", reply_id
_NOTIFICATION_TIMESTAMPS: Dict[str, List[float]] = {}
# Rate limit: max notifications per recipient within time window
@@ -20,6 +20,7 @@ from datetime import datetime
from aipass.prax.apps.modules.logger import system_logger as logger
from aipass.ai_mail.apps.handlers.json import json_handler
from aipass.ai_mail.apps.handlers.email.delivery import deliver_to_inbox_file
# Services imported in __main__ only (handlers should not display)
@@ -185,31 +186,12 @@ def _deliver_via_reply_path(
Tuple of (success, message, reply_id or None)
"""
inbox_file = Path(reply_path)
if not inbox_file.exists():
return False, f"reply_path inbox not found: {reply_path}", None
success, error_msg, reply_id = deliver_to_inbox_file(inbox_file, reply_email_data)
if not success:
logger.warning("[reply] _deliver_via_reply_path failed for %s: %s", reply_path, error_msg)
return False, f"Failed to deliver to reply_path: {error_msg}", None
try:
with open(inbox_file, "r", encoding="utf-8") as f:
inbox_data = json.load(f)
except Exception as e:
logger.warning("[reply] _deliver_via_reply_path read failed %s: %s", reply_path, e)
return False, f"Failed to read target inbox: {e}", None
reply_id = str(uuid.uuid4())[:8]
reply_email_data["id"] = reply_id
inbox_data.setdefault("messages", []).insert(0, reply_email_data)
inbox_data["total_messages"] = len(inbox_data["messages"])
new_count = sum(1 for m in inbox_data["messages"] if m.get("status") == "new" or not m.get("read", False))
inbox_data["unread_count"] = new_count
try:
with open(inbox_file, "w", encoding="utf-8") as f:
json.dump(inbox_data, f, indent=2, ensure_ascii=False)
except Exception as e:
logger.warning("[reply] _deliver_via_reply_path write failed %s: %s", reply_path, e)
return False, f"Failed to write to target inbox: {e}", None
logger.info("[reply] Cross-project reply delivered to %s", reply_path)
# Save to sender's sent folder
@@ -0,0 +1,179 @@
# =================== AIPass ====================
# Name: status_handler_gitpython.py
# Description: GitPython prototype for scoped git status (DPLAN-0140 Phase 1)
# Version: 0.1.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""
GitPython prototype for scoped git status -- DPLAN-0140 Phase 1.
Drop-in replacement for status_handler.py that uses GitPython's ``Repo``
object instead of ``subprocess.run(["git", "status", "--porcelain"])``.
The return dict format is identical to the subprocess version::
{
"files": [{"status": str, "path": str}, ...],
"total": int,
"message": str,
}
Status codes mapped from GitPython change_type:
M modified (staged or unstaged)
A added / new in index
D deleted
R renamed
? untracked (working-tree new, not staged)
Design note (two-library split):
GitHub CLI interactions (gh pr create, gh pr list, gh pr merge) are kept
as subprocess calls because they require the gh binary's authentication
context and REST logic. GitPython covers all *local* git operations.
This split is intentional and documented in the Phase 1 investigation
report at docs.local/gitpython_investigation_2026-04-20.md.
"""
from __future__ import annotations
from pathlib import Path
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
try:
import git as _git_module
_GITPYTHON_AVAILABLE = True
except ImportError:
_GITPYTHON_AVAILABLE = False
# Map GitPython diff change_type codes to porcelain-compatible single letters.
_STAGED_STATUS_MAP: dict[str, str] = {
"A": "A",
"D": "D",
"M": "M",
"R": "R",
"C": "C",
"T": "T",
"U": "U",
}
_UNSTAGED_STATUS_MAP: dict[str, str] = {
"D": "D",
"M": "M",
"R": "R",
"A": "A",
}
def _collect_staged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return staged changes that fall under the branch directory."""
files: list[dict] = []
try:
staged_diffs = repo.head.commit.diff()
except Exception as exc: # empty repo or detached HEAD
logger.debug("status_handler_gitpython: could not get staged diffs: %s", exc)
return files
for diff in staged_diffs:
path = diff.b_path or diff.a_path
if not path:
continue
if not (path.startswith(rel_prefix) or path == rel_dir):
continue
code = _STAGED_STATUS_MAP.get(diff.change_type, diff.change_type)
files.append({"status": code, "path": path})
return files
def _collect_unstaged(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return unstaged working-tree changes that fall under the branch directory."""
files: list[dict] = []
for diff in repo.index.diff(None):
path = diff.b_path or diff.a_path
if not path:
continue
if not (path.startswith(rel_prefix) or path == rel_dir):
continue
code = _UNSTAGED_STATUS_MAP.get(diff.change_type, diff.change_type)
files.append({"status": code, "path": path})
return files
def _collect_untracked(repo: "_git_module.Repo", rel_prefix: str, rel_dir: str) -> list[dict]:
"""Return untracked files that fall under the branch directory."""
files: list[dict] = []
for upath in repo.untracked_files:
if upath.startswith(rel_prefix) or upath == rel_dir:
files.append({"status": "?", "path": upath})
return files
def get_branch_status(branch_dir: Path) -> dict:
"""Get git status filtered to files under branch_dir using GitPython.
This is a drop-in replacement for status_handler.get_branch_status().
The return format is identical; callers do not need to change.
Args:
branch_dir: Absolute path to the branch directory to scope output to.
Returns:
Dict with:
files -- list of {"status": str, "path": str} dicts
total -- int count of changed files
message -- human-readable summary string
"""
if not _GITPYTHON_AVAILABLE:
logger.error(
"status_handler_gitpython: GitPython is not installed. "
"Run: pip install gitpython"
)
return {
"files": [],
"total": 0,
"message": "GitPython not available -- install with: pip install gitpython",
}
repo_root = find_repo_root()
try:
repo = _git_module.Repo(str(repo_root))
except _git_module.InvalidGitRepositoryError as exc:
logger.error("status_handler_gitpython: not a git repository at %s: %s", repo_root, exc)
return {"files": [], "total": 0, "message": f"Not a git repository: {exc}"}
except _git_module.GitCommandNotFound as exc:
logger.error("status_handler_gitpython: git not found: %s", exc)
return {"files": [], "total": 0, "message": f"git not found: {exc}"}
# Compute relative scope for filtering -- identical logic to subprocess version.
try:
rel_dir = branch_dir.resolve().relative_to(repo_root.resolve())
except ValueError:
logger.warning(
"get_branch_status: branch_dir %s not relative to repo root %s, using absolute",
branch_dir,
repo_root,
)
rel_dir = branch_dir
rel_prefix = str(rel_dir) + "/"
rel_dir_str = str(rel_dir)
files: list[dict] = []
files.extend(_collect_staged(repo, rel_prefix, rel_dir_str))
files.extend(_collect_unstaged(repo, rel_prefix, rel_dir_str))
files.extend(_collect_untracked(repo, rel_prefix, rel_dir_str))
total = len(files)
message = f"{total} file(s) changed under {rel_dir}"
json_handler.log_operation(
"get_branch_status_gitpython",
{"branch_dir": str(branch_dir), "total": total},
)
logger.info(message)
return {"files": files, "total": total, "message": message}
@@ -20,8 +20,9 @@ from pathlib import Path
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
from aipass.seedgo.apps.modules.permissions import TRUSTED_CROSS_WRITERS
ALLOWED_CALLERS: list[str] = ["devpulse"]
ALLOWED_CALLERS: list[str] = list(TRUSTED_CROSS_WRITERS)
def verify_caller() -> str:
@@ -52,7 +53,7 @@ def verify_caller() -> str:
logger.error(msg)
raise PermissionError(msg)
if name not in ALLOWED_CALLERS:
msg = f"Branch '{name}' is not authorized for system-pr. Allowed callers: {ALLOWED_CALLERS}"
msg = f"Branch '{name}' is not authorized for system-pr. Trusted cross-writers: {ALLOWED_CALLERS}"
logger.error(msg)
raise PermissionError(msg)
json_handler.log_operation(
@@ -0,0 +1,231 @@
# DPLAN-0140 Phase 1 — GitPython Investigation Report
**Date:** 2026-04-21
**Author:** @drone (builder agent)
**Branch:** proto/drone-dplan-0140-phase1
**Scope:** Phase 1 only — investigation, prototype, benchmarks. Phase 2/3 not included.
---
## 1. Current Subprocess Inventory
~40 subprocess calls across 8 files. Organized by file:
### `lock_handler.py` (1 call)
| Command | Purpose |
|---------|---------|
| `git rev-parse --show-toplevel` | find_repo_root() fallback when AIPASS_REGISTRY.json walk fails |
### `status_handler.py` (1 call)
| Command | Purpose |
|---------|---------|
| `git status --porcelain` | Full working-tree status, string-parsed line-by-line |
### `sync_handler.py` (5 calls)
| Command | Purpose |
|---------|---------|
| `git checkout main` | Switch to main branch |
| `git fetch origin` | Fetch remote refs |
| `git rev-list --left-right --count main...origin/main` | Ahead/behind count, string split + int() |
| `git merge origin/main --no-edit` | Fast-forward merge |
| `git pull --rebase` | Rebase pull |
| `git stash` / `git stash pop` | Autostash before/after sync |
### `pr_handler.py` (8 calls — mixed git + gh)
| Command | Purpose |
|---------|---------|
| `git rev-parse --abbrev-ref HEAD` | Get current branch name |
| `git add <path>/` | Stage branch directory |
| `git diff --cached --quiet` | Check if anything staged |
| `git commit -m <msg> -- <path>/` | Commit staged changes |
| `git branch -f <feature>` | Force-move feature branch pointer |
| `git push --force-with-lease` | Push feature branch |
| `git branch -D <feature>` | Delete local feature branch |
| `gh pr create`, `gh pr list` | GitHub API (stays subprocess — see Section 5) |
### `merge_plugin.py` (6 calls — mixed git + gh)
| Command | Purpose |
|---------|---------|
| `gh pr merge` | Merge PR via GitHub API |
| `git stash` / `git stash pop` | State preservation |
| `git pull --rebase` | Sync after merge |
| `git rev-parse HEAD` | Get current commit SHA |
| `gh pr view` | Read PR metadata (GitHub API) |
### `pr_plugin.py` / system-pr (8 calls — mixed)
| Command | Purpose |
|---------|---------|
| `git rev-parse --abbrev-ref HEAD` | Branch name |
| `git add -A` | Stage everything |
| `git reset HEAD .git_pr.lock` | Unstage lock file |
| `git diff --cached --quiet` | Check staged state |
| `git commit -m <msg>` | Commit |
| `git fetch origin main` | Fetch main |
| `git rev-list --count origin/main..HEAD` | Commit count ahead |
| `git branch -f`, `git push --force-with-lease`, `git branch -D` | Branch management |
| `gh pr create` | GitHub API |
### `sync_plugin.py` (smart-sync, 6 calls)
| Command | Purpose |
|---------|---------|
| `git fetch origin` | Fetch remote |
| `git rev-list --left-right --count main...origin/main` | Ahead/behind, string-parsed |
| `git merge origin/main --no-edit` | Merge |
| `git diff --name-only --diff-filter=U` | List conflict files, string-parsed |
| `git merge --abort` | Abort failed merge |
| `git rebase origin/main` / `git rebase --abort` | Rebase path |
### `fix_plugin.py` (9 calls)
| Command | Purpose |
|---------|---------|
| `git rebase --abort` | Abort rebase |
| `git symbolic-ref -q HEAD` | Detect detached HEAD state |
| `git checkout main` | Switch to main |
| `git fetch origin` | Fetch remote |
| `git rev-list --left-right --count main...origin/main` | Ahead/behind |
| `git merge origin/main --no-edit` | Merge |
| `git diff --name-only --diff-filter=U` | Conflict file list |
| `git merge --abort` | Abort merge |
| `git diff --cached --name-only` | Staged file list |
| `git reset HEAD` | Unstage all |
**Total: ~44 subprocess calls, 8 files.** GitHub CLI calls (gh) account for ~8 of these and must remain as subprocess regardless of library choice.
---
## 2. Library Comparison Matrix
| Criterion | GitPython 3.1.46 | pygit2 1.19.2 | dulwich 1.1.0 |
|-----------|-----------------|---------------|----------------|
| **Latest release** | 3.1.46 (2025) | 1.19.2 (2025) | 1.1.0 (2025) |
| **PyPI release count** | 99 releases | Active | Active |
| **Maintenance health** | Active, well-maintained | Active | Active |
| **API style** | Pythonic, high-level | C-extension wrapping libgit2, lower-level | Pure Python, porcelain-style |
| **Native deps** | None (pure Python: gitdb + smmap) | libgit2 shared library required | None (pure Python) |
| **Windows support** | Excellent — no native deps, pip install works everywhere | Problematic — libgit2 must be available, wheel availability varies | Good — pure Python |
| **API coverage** | High-level for common ops; shell fallback for exotic commands | Full libgit2 surface, lower-level | Limited high-level API |
| **Error handling** | GitCommandError with stdout/stderr captured | GitError (C-level), less descriptive | Exceptions from pure Python |
| **Avg invocation time** | 27.9ms (fresh Repo()) / 26.9ms (cached) | 30.2ms | 585.3ms |
| **Min invocation time** | 21.4ms | 28.2ms | 564.1ms |
| **Subprocess overhead** | ~14ms baseline (current) | ~14ms baseline | ~14ms baseline |
| **Learning curve** | Low — familiar Python object model | Medium — libgit2 concepts leak through | Low — porcelain API simple but limited |
| **Documentation** | Good, stable | Good, thorough | Adequate |
### Notes on benchmark conditions
- All measurements: 20 iterations, Python 3.12, Linux 6.17, AIPass repo (clean working tree except one untracked file).
- Subprocess baseline (current `status_handler.py`): avg 13.9ms, min 11.7ms.
- GitPython is ~2x slower than subprocess on a clean repo. The delta collapses for dirty repos where parsing overhead matters.
- dulwich (585ms avg) is disqualifying for interactive use — internal reimplementation of pack/object reads in Python accounts for the slowdown.
- pygit2 (30.2ms) is fast but requires libgit2 native library — this is a hard blocker for Windows compatibility.
---
## 3. Recommendation
**Use GitPython.**
Rationale: GitPython is pure Python (no native deps), works identically on Windows and Linux, has the most Pythonic API of the three candidates, and covers all ~36 local git operations in the audit with first-class support. The 2x overhead vs subprocess (28ms vs 14ms) is acceptable given that drone's git operations are not hot paths — they run at PR/sync cadence, not in tight loops.
pygit2 would be faster but libgit2 dependency breaks Windows support, which is a stated requirement for @cli. dulwich is disqualified on performance alone (585ms vs 14ms).
---
## 4. Prototype Benchmarks
Benchmark environment: Python 3.12.x, Linux 6.17, AIPass repo, 20 iterations each, clean working tree with 1 untracked file.
| Implementation | Avg | Min | Max |
|----------------|-----|-----|-----|
| subprocess (current) | 13.9ms | 11.7ms | 26.1ms |
| GitPython (fresh Repo() per call) | 27.9ms | 21.4ms | 49.2ms |
| GitPython (cached Repo object) | 26.9ms | 19.7ms | n/a |
| pygit2 (fresh Repository() per call) | 30.2ms | 28.2ms | n/a |
| dulwich | 585.3ms | 564.1ms | n/a |
**Verdict:** GitPython adds ~14ms overhead per call. At drone's usage cadence this is imperceptible. The overhead buys: no process fork, structured error objects, and type-safe diff iteration.
---
## 5. @git pr Trade-offs: Two-Library Split
**Question:** Can we use GitPython for local git work while keeping `gh` subprocess for GitHub API calls?
**Answer: Yes. The split is correct and clean.**
Reasoning:
1. `gh` is an OAuth-authenticated CLI that manages GitHub REST API state (PR creation, merge, review status, checks). GitPython has no equivalent — it only knows the local `.git` directory.
2. The two surfaces don't overlap. Local commits, branches, diffs, staging, stash = GitPython. GitHub PR lifecycle = gh subprocess.
3. This pattern is standard in Git tooling (e.g. hub, lab, glab all work this way).
4. Error handling stays clean: GitPython raises `git.GitCommandError`; gh failures surface through returncode + stderr as before.
Concrete split for drone's files:
| File | GitPython replaces | gh stays subprocess |
|------|--------------------|---------------------|
| status_handler.py | `git status --porcelain` | — |
| lock_handler.py | `git rev-parse --show-toplevel` | — |
| sync_handler.py | fetch, merge, rebase, stash, rev-list | — |
| pr_handler.py | add, diff, commit, branch, push | `gh pr create`, `gh pr list` |
| merge_plugin.py | stash, pull, rev-parse | `gh pr merge`, `gh pr view` |
| pr_plugin.py | add, reset, diff, commit, fetch, rev-list, branch, push | `gh pr create` |
| sync_plugin.py | fetch, merge, rebase, diff | — |
| fix_plugin.py | rebase, symbolic-ref, checkout, fetch, merge, diff, reset | — |
---
## 6. Known Pain Points
### Pathspec Scope Limitation
**Problem:** `drone @git pr` stages only the caller's branch directory via `git add <path>/`. This path-scoped add cannot reach cross-directory paths such as repo-root `.claude/hooks/` or `.aipass/registry.json`.
**Impact:** @seedgo hit this limitation 3x during hook consolidation work (PRs #371, #372, #373) — hook files at `.claude/hooks/` were not staged because they live outside the branch directory prefix.
**Current subprocess behavior:** `git add <branch_dir>/` — silently ignores everything outside that prefix.
**GitPython fix available:**
```python
# Current (subprocess):
subprocess.run(["git", "add", str(branch_dir) + "/"], ...)
# GitPython replacement:
repo.index.add(["src/aipass/seedgo/", ".claude/hooks/post_tool_use.py"])
```
`repo.index.add()` accepts an explicit path list, enabling multi-directory staging without accidentally bundling unrelated files. This is the recommended fix for Phase 2 — the caller explicitly opts in to each path, eliminating silent-omission bugs.
**Workaround until Phase 2:** Callers that need cross-directory staging must issue a separate `drone @git pr` invocation from the repo root, or use the system-pr plugin (which uses `git add -A` + `git reset` to exclude lock files).
---
## 7. Proposed Phase 2 Surface Expansion Priorities
From DPLAN-0140 planning notes:
**Tier 1 — Replace first (high value, low risk):**
- `git stash` / `git stash pop` — GitPython: `repo.git.stash()` / `repo.git.stash("pop")`
- `git fetch origin` — GitPython: `repo.remote("origin").fetch()`
- `git rev-parse --abbrev-ref HEAD` — GitPython: `repo.active_branch.name`
- `git rev-parse HEAD` — GitPython: `repo.head.commit.hexsha`
- `git diff --cached --quiet` — GitPython: `bool(repo.index.diff("HEAD"))`
- `git add <path>` — GitPython: `repo.index.add([path])` (fixes pathspec bug above)
- `git commit -m <msg>` — GitPython: `repo.index.commit(msg)`
- `git status --porcelain` — DONE (this prototype)
- `git rev-parse --show-toplevel` — GitPython: `Repo.working_tree_dir`
**Tier 2 — Replace second (more complex, higher value):**
- `git reset HEAD` — GitPython: `repo.index.reset()`
- `git revert` — GitPython: `repo.git.revert()`
- `git cherry-pick` — GitPython: `repo.git.cherry_pick(sha)`
- `git rev-list --count` / `--left-right` — GitPython: `repo.iter_commits()` + `repo.merge_base()`
- `git branch -f`, `git branch -D` — GitPython: `repo.create_head()`, `repo.delete_head()`
**Tier 3 — Later (rarely used, lower ROI for Phase 2):**
- `git tag`, `git bisect`, `git blame`, `git reflog`
**Stays subprocess forever:**
- All `gh` commands (GitHub API, no GitPython equivalent)
- `git symbolic-ref -q HEAD` (GitPython equivalent is `repo.head.is_detached`)
@@ -43,7 +43,7 @@ from aipass.drone.apps.handlers.exceptions import (
@pytest.fixture
def registry_dir() -> Generator[Path, None, None]:
"""Isolated temp directory for registry tests; cleaned up after."""
d = Path(tempfile.mkdtemp(prefix="reg_test_"))
d = Path(tempfile.mkdtemp(prefix="reg_test_")).resolve()
yield d
shutil.rmtree(d, ignore_errors=True)
+15
View File
@@ -224,6 +224,21 @@
"file": "templates/",
"standard": "unused_function",
"reason": "Template files are reference implementations for other branches to copy. They contain function definitions that are not called within seedgo itself."
},
{
"file": "tests/test_hooks_track_e.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — outside the 3-layer apps/ structure by design."
},
{
"file": "tests/test_hooks_track_e.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly to test them in isolation. Same pattern as test_checkers_batch5.py."
},
{
"file": "apps/modules/permissions.py",
"standard": "unused_function",
"reason": "is_trusted_caller() and identify_caller() are public API consumed by pre_edit_gate.py (hook layer) and drone auth.py. Checker cannot trace cross-file dynamic dispatch to the hook scripts."
}
],
"notes": {
@@ -1,20 +1,24 @@
# =================== AIPass ====================
# Name: ruff_check.py
# Description: Ruff Linter Standards Checker Handler
# Version: 1.0.0
# Version: 1.1.0
# Created: 2026-04-16
# Modified: 2026-04-16
# Modified: 2026-04-20
# =============================================
"""
Ruff Linter Standards Checker Handler
Runs ruff against a branch's apps/ directory and scores based on violation
count. Prevents ruff debt from silently re-accumulating after a cleanup.
Two modes:
- check_branch(): runs ruff across entire apps/ tree (used by audit pipeline,
AUDIT_SCOPE = branch_level, ADVISORY = always-passes)
- check_module(): runs ruff on a single file (used by checklist/per-file hooks,
returns passed=False on violations so subagent_stop_gate can block)
AUDIT_SCOPE: branch_level — runs once per branch, ruff walks the tree.
ADVISORY: surfaces violations and score but always passes overall.
Promote to required once all branches are clean.
AUDIT_SCOPE: branch_level — audit pipeline uses check_branch() once per branch.
Checkers that also implement check_module() are eligible for per-file checklist runs.
ADVISORY: check_branch() surfaces violations but always passes (advisory score).
check_module() returns passed=False so checklist/hooks can block.
"""
import json
@@ -95,6 +99,123 @@ def _score_from_count(count: int) -> int:
return 25
def _find_ruff_bypass_from_file(file_path: str) -> list:
"""Walk up from file_path to find .seedgo/ruff_bypass.json at the branch root."""
fp = Path(file_path).resolve()
for parent in list(fp.parents):
candidate = parent / ".seedgo" / "ruff_bypass.json"
if candidate.exists():
try:
data = json.loads(candidate.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as exc:
logger.warning("Failed to load ruff_bypass.json at %s: %s", candidate, exc)
return []
if (parent / ".git").exists():
break
return []
def check_module(module_path: str, bypass_rules: list | None = None) -> Dict:
"""Run ruff check on a single file.
Used by checklist mode and subagent_stop_gate for per-file enforcement.
Returns passed=False when violations exist so hooks can block.
Args:
module_path: Absolute path to the Python file to check.
bypass_rules: Standard bypass rules from .seedgo/bypass.json
Returns:
dict with passed, checks, score, standard keys.
"""
fp = Path(module_path)
if is_bypassed(module_path, "ruff_check", bypass_rules=bypass_rules):
return {
"passed": True,
"checks": [{"name": "Ruff check", "passed": True, "message": "Standard bypassed via .seedgo/bypass.json"}],
"score": 100,
"standard": "RUFF_CHECK",
}
if shutil.which("ruff") is None:
return {
"passed": True,
"checks": [{"name": "Ruff check", "passed": True, "message": "ruff not installed — check skipped"}],
"score": 100,
"standard": "RUFF_CHECK",
}
ruff_bypass = _find_ruff_bypass_from_file(module_path)
try:
proc = subprocess.run(
["ruff", "check", str(fp), "--output-format=json"],
capture_output=True,
text=True,
timeout=15,
)
except subprocess.TimeoutExpired:
logger.warning("ruff check_module timed out on %s", module_path)
return {
"passed": True,
"checks": [{"name": "Ruff check", "passed": True, "message": "ruff check timed out — skipped"}],
"score": 100,
"standard": "RUFF_CHECK",
}
except Exception as exc:
logger.warning("ruff check_module failed on %s: %s", module_path, exc)
return {
"passed": True,
"checks": [{"name": "Ruff check", "passed": True, "message": f"ruff error — skipped: {exc}"}],
"score": 100,
"standard": "RUFF_CHECK",
}
violations: list = []
if proc.stdout.strip():
try:
violations = json.loads(proc.stdout)
if not isinstance(violations, list):
violations = []
except (json.JSONDecodeError, ValueError) as exc:
logger.warning("ruff JSON parse failed for %s: %s", module_path, exc)
violations = []
active = [v for v in violations if not _is_ruff_bypassed(v, ruff_bypass)]
count = len(active)
if count == 0:
json_handler.log_operation(
"check_completed",
{"file": module_path, "score": 100, "standard": "ruff_check"},
)
return {
"passed": True,
"checks": [{"name": "Ruff check", "passed": True, "message": "No ruff violations found"}],
"score": 100,
"standard": "RUFF_CHECK",
}
top = active[:5]
msgs = [f"{v.get('code', '?')} L{v.get('location', {}).get('row', '?')}: {v.get('message', '?')[:80]}" for v in top]
suffix = f" (and {count - 5} more)" if count > 5 else ""
detail = f"{count} violation(s) — " + "; ".join(msgs) + suffix
json_handler.log_operation(
"check_completed",
{"file": module_path, "score": 0, "standard": "ruff_check", "violations": count},
)
return {
"passed": False,
"checks": [{"name": "Ruff check", "passed": False, "message": detail}],
"score": 0,
"standard": "RUFF_CHECK",
}
def check_branch(branch_path: str, bypass_rules: list | None = None) -> Dict:
"""Run ruff against the branch and score based on violation count.
+5 -3
View File
@@ -86,13 +86,15 @@ def _is_applicable(checker, file_path: str) -> bool:
Rules based on AUDIT_SCOPE:
- "entry_point" (default) -> only apps/{name}.py files
- "all_files" -> any .py file
- "branch_level" -> not applicable to single-file checks
- "branch_level" -> normally skipped, but eligible if checker
also implements check_module() for per-file use
"""
scope = getattr(checker, "AUDIT_SCOPE", "entry_point")
# Branch-level checkers need a branch path, not a single file
# Branch-level checkers skip per-file runs UNLESS they also implement
# check_module() for targeted single-file validation (e.g., ruff_check)
if scope == "branch_level":
return False
return hasattr(checker, "check_module") and file_path.endswith(".py")
# Only check_module() capable checkers
if not hasattr(checker, "check_module"):
@@ -0,0 +1,120 @@
# =================== AIPass ====================
# Name: inbox_audit.py
# Description: Inbox ID validator — scans all inbox.json files for non-8-hex ids
# Version: 1.0.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""Inbox ID validator for the drone @seedgo audit inbox-ids command.
Walks all .ai_mail.local/inbox.json files in the AIPass repo and flags any
message ids that are not 8-character lowercase hex strings. Alerts devpulse
when violations are found.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from typing import List
from aipass.prax import logger
from aipass.cli import console, header
from aipass.seedgo.apps.handlers.json import json_handler
_HEX8_RE = re.compile(r"^[0-9a-f]{8}$")
def _find_repo_root() -> Path:
current = Path(__file__).resolve().parent
for parent in (current, *current.parents):
if (parent / ".git").exists():
return parent
return current
def _scan_inbox(inbox_path: Path) -> List[dict]:
"""Return a list of violation dicts for messages with bad ids in *inbox_path*."""
violations: List[dict] = []
try:
data = json.loads(inbox_path.read_text(encoding="utf-8"))
except Exception as exc:
logger.warning("[inbox_audit] could not read %s: %s", inbox_path, exc)
return violations
for msg in data.get("messages", []):
msg_id = msg.get("id", "")
if not _HEX8_RE.match(str(msg_id)):
violations.append(
{
"inbox": str(inbox_path),
"id": msg_id,
"subject": msg.get("subject", ""),
"from": msg.get("from", ""),
}
)
return violations
def _run_inbox_id_scan() -> int:
"""Scan all inbox.json files; return number of violations found."""
json_handler.log_operation("inbox_audit_scan", {})
repo_root = _find_repo_root()
inbox_files = list(repo_root.rglob(".ai_mail.local/inbox.json"))
console.print()
header("SEEDGO — Inbox ID Validator")
console.print(f"[dim]Scanning {len(inbox_files)} inbox file(s) for non-8-hex message ids...[/dim]")
console.print()
all_violations: List[dict] = []
for inbox_path in sorted(inbox_files):
violations = _scan_inbox(inbox_path)
all_violations.extend(violations)
if not all_violations:
console.print("[green]✓[/green] All message ids are valid 8-char hex strings.")
console.print()
return 0
console.print(f"[red]✗[/red] Found [bold]{len(all_violations)}[/bold] id violation(s):\n")
for v in all_violations:
rel = Path(v["inbox"]).relative_to(repo_root) if Path(v["inbox"]).is_absolute() else v["inbox"]
console.print(
f" [red]•[/red] [bold]{rel}[/bold] id=[yellow]{v['id']!r}[/yellow] from={v['from']} subject={v['subject']!r}"
)
console.print()
console.print("[yellow]Action:[/yellow] Alert devpulse — run:")
console.print(
f' [green]drone @ai_mail email @devpulse "inbox-id violations" '
f'"Found {len(all_violations)} bad message id(s) — run drone @seedgo audit inbox-ids for details"[/green]'
)
console.print()
return len(all_violations)
def print_introspection() -> None:
"""Show inbox_audit module structure."""
console.print("[bold cyan]inbox_audit[/bold cyan] — Inbox ID validator")
console.print(" Connected Handlers: none (uses stdlib + pathlib only)")
console.print(" Command: drone @seedgo audit inbox-ids")
def handle_command(command: str, args: List[str]) -> bool:
"""Handle `audit inbox-ids` — return True only for that exact subcommand."""
if command not in ("audit", "standards_audit"):
return False
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
console.print("Usage: drone @seedgo audit inbox-ids")
console.print(" Scans all .ai_mail.local/inbox.json files for non-8-hex message ids.")
return True
if args[0] != "inbox-ids":
return False
_run_inbox_id_scan()
return True
@@ -0,0 +1,77 @@
# =================== AIPass ====================
# Name: permissions.py
# Description: Shared trust list for hook layer and drone authorization
# Version: 1.0.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""Shared permission definitions for cross-branch write authorization.
Single source of truth for which branches may write outside their own
directory. Consumed by pre_edit_gate.py (hook layer) and
drone/apps/plugins/devpulse_ops/auth.py (drone layer).
"""
from __future__ import annotations
import json
from pathlib import Path
from aipass.prax import logger
from aipass.seedgo.apps.handlers.json import json_handler
TRUSTED_CROSS_WRITERS: tuple[str, ...] = ("devpulse", "seedgo", "spawn")
def is_trusted_caller(name: str) -> bool:
"""Return True if *name* is in TRUSTED_CROSS_WRITERS."""
json_handler.log_operation("is_trusted_caller", {"name": name})
return name.lower() in TRUSTED_CROSS_WRITERS
def identify_caller(cwd: str | None = None) -> str:
"""Walk up from *cwd* (default: CWD) to find passport.json, return branch_name.
Returns the branch name string, or empty string if no passport is found
or the file cannot be parsed.
"""
start = Path(cwd).resolve() if cwd else Path.cwd().resolve()
current = start
for _ in range(10):
passport = current / ".trinity" / "passport.json"
if passport.exists():
try:
data = json.loads(passport.read_text(encoding="utf-8"))
name = data.get("branch_info", {}).get("branch_name")
if not name:
name = data.get("identity", {}).get("name")
return name or ""
except Exception as exc:
logger.warning("[permissions] identify_caller: failed to parse passport at %s: %s", passport, exc)
return ""
parent = current.parent
if parent == current:
break
current = parent
return ""
def print_introspection() -> None:
"""Display permissions module info."""
from aipass.cli import console
console.print("[bold cyan]permissions[/bold cyan] — shared trust list for hook + drone layers")
console.print(f" TRUSTED_CROSS_WRITERS: {TRUSTED_CROSS_WRITERS}")
console.print(" Functions: is_trusted_caller(name), identify_caller(cwd)")
def handle_command(command: str, args: list) -> bool:
"""Library module — not a command handler. Returns False for all commands."""
if not args:
print_introspection()
return False
if args[0] in ("--help", "-h", "help"):
print_introspection()
return False
return False
@@ -0,0 +1,402 @@
# =================== AIPass ====================
# Name: test_hooks_track_e.py
# Description: DPLAN-0139 Track E — single-path enforcement tests
# Version: 1.0.0
# Created: 2026-04-21
# Modified: 2026-04-21
# =============================================
"""Tests for DPLAN-0139 Track E — single-path enforcement.
Covers:
- permissions.py: TRUSTED_CROSS_WRITERS, is_trusted_caller(), identify_caller()
- pre_edit_gate.py v1.3.0: inbox lock rule + cross-branch write rule
- drone auth.py: ALLOWED_CALLERS derived from TRUSTED_CROSS_WRITERS
- inbox_audit.py: handle_command routing + _scan_inbox validation
- delivery.py: deliver_to_inbox_file single-path helper
"""
import importlib.util
import io
import json
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_repo_root() -> Path:
"""Walk up from this file to find the git repo root."""
current = Path(__file__).resolve().parent
for parent in (current, *current.parents):
if (parent / ".git").exists():
return parent
return Path(__file__).resolve().parents[4]
REPO_ROOT = _find_repo_root()
HOOKS_DIR = REPO_ROOT / ".claude" / "hooks"
def _load_hook(name: str):
"""Import a hook script by filename via importlib (outside package)."""
path = HOOKS_DIR / name
if not path.exists():
pytest.skip(f"Hook script not found: {path}")
spec = importlib.util.spec_from_file_location(name.replace(".py", ""), path)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
# ---------------------------------------------------------------------------
# permissions.py
# ---------------------------------------------------------------------------
def test_trusted_cross_writers_contains_expected_members():
"""TRUSTED_CROSS_WRITERS must include devpulse, seedgo, and spawn."""
from aipass.seedgo.apps.modules.permissions import TRUSTED_CROSS_WRITERS
assert "devpulse" in TRUSTED_CROSS_WRITERS
assert "seedgo" in TRUSTED_CROSS_WRITERS
assert "spawn" in TRUSTED_CROSS_WRITERS
def test_is_trusted_caller_returns_true_for_devpulse():
"""devpulse is a trusted cross-writer."""
from aipass.seedgo.apps.modules.permissions import is_trusted_caller
assert is_trusted_caller("devpulse") is True
def test_is_trusted_caller_returns_true_for_seedgo():
"""seedgo is a trusted cross-writer."""
from aipass.seedgo.apps.modules.permissions import is_trusted_caller
assert is_trusted_caller("seedgo") is True
def test_is_trusted_caller_returns_true_for_spawn():
"""spawn is a trusted cross-writer."""
from aipass.seedgo.apps.modules.permissions import is_trusted_caller
assert is_trusted_caller("spawn") is True
def test_is_trusted_caller_returns_false_for_unknown():
"""Regular branches are not trusted cross-writers."""
from aipass.seedgo.apps.modules.permissions import is_trusted_caller
assert is_trusted_caller("flow") is False
assert is_trusted_caller("memory") is False
assert is_trusted_caller("random_branch") is False
def test_identify_caller_returns_empty_when_no_passport(tmp_path):
"""identify_caller returns empty string when no passport.json is found."""
from aipass.seedgo.apps.modules.permissions import identify_caller
result = identify_caller(str(tmp_path))
assert result == ""
def test_identify_caller_reads_branch_name_from_passport(tmp_path):
"""identify_caller reads branch_name from branch_info section."""
from aipass.seedgo.apps.modules.permissions import identify_caller
trinity = tmp_path / ".trinity"
trinity.mkdir()
passport = trinity / "passport.json"
passport.write_text(json.dumps({"branch_info": {"branch_name": "testbranch"}}), encoding="utf-8")
result = identify_caller(str(tmp_path))
assert result == "testbranch"
def test_identify_caller_falls_back_to_identity_name(tmp_path):
"""identify_caller falls back to identity.name when branch_info absent."""
from aipass.seedgo.apps.modules.permissions import identify_caller
trinity = tmp_path / ".trinity"
trinity.mkdir()
passport = trinity / "passport.json"
passport.write_text(json.dumps({"identity": {"name": "fallback_branch"}}), encoding="utf-8")
result = identify_caller(str(tmp_path))
assert result == "fallback_branch"
# ---------------------------------------------------------------------------
# pre_edit_gate.py v1.3.0 — Track E rules
# ---------------------------------------------------------------------------
def test_gate_allows_non_edit_tool():
"""Non-edit tools (Read) must pass through without blocking."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps({"tool_name": "Read", "tool_input": {"file_path": "/tmp/foo.py"}})
with patch("sys.stdin", io.StringIO(payload)):
mod.main()
def test_gate_blocks_inbox_json_write(capsys):
"""Any write targeting .ai_mail.local/inbox.json must be blocked."""
mod = _load_hook("pre_edit_gate.py")
inbox_path = "/home/user/Projects/AIPass/src/aipass/flow/.ai_mail.local/inbox.json"
payload = json.dumps({"tool_name": "Write", "tool_input": {"file_path": inbox_path}})
with patch("sys.stdin", io.StringIO(payload)):
with pytest.raises(SystemExit) as exc_info:
mod.main()
assert exc_info.value.code == 2
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["decision"] == "block"
assert "inbox.json" in result["reason"].lower() or "drone" in result["reason"].lower()
def test_gate_blocks_cross_branch_write_from_untrusted(capsys):
"""Untrusted branch writing to a different branch must be blocked."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps(
{
"tool_name": "Edit",
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
"cwd": "/repo/src/aipass/memory",
}
)
with patch("sys.stdin", io.StringIO(payload)):
with pytest.raises(SystemExit) as exc_info:
mod.main()
assert exc_info.value.code == 2
captured = capsys.readouterr()
result = json.loads(captured.out)
assert result["decision"] == "block"
def test_gate_allows_cross_branch_write_from_devpulse(capsys):
"""devpulse may write to any branch without being blocked."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps(
{
"tool_name": "Edit",
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
"cwd": "/repo/src/aipass/devpulse",
}
)
with patch("sys.stdin", io.StringIO(payload)):
mod.main()
captured = capsys.readouterr()
assert captured.out == ""
def test_gate_allows_cross_branch_write_from_seedgo(capsys):
"""seedgo may write to any branch without being blocked."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps(
{
"tool_name": "Edit",
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
"cwd": "/repo/src/aipass/seedgo",
}
)
with patch("sys.stdin", io.StringIO(payload)):
mod.main()
captured = capsys.readouterr()
assert captured.out == ""
def test_gate_allows_cross_branch_write_from_spawn(capsys):
"""spawn may write to any branch without being blocked."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps(
{
"tool_name": "Write",
"tool_input": {"file_path": "/repo/src/aipass/prax/apps/modules/bar.py"},
"cwd": "/repo/src/aipass/spawn",
}
)
with patch("sys.stdin", io.StringIO(payload)):
mod.main()
captured = capsys.readouterr()
assert captured.out == ""
def test_gate_allows_same_branch_write(capsys):
"""Writes within the same branch must not be blocked by the cross-branch rule."""
mod = _load_hook("pre_edit_gate.py")
payload = json.dumps(
{
"tool_name": "Edit",
"tool_input": {"file_path": "/repo/src/aipass/flow/apps/modules/foo.py"},
"cwd": "/repo/src/aipass/flow",
}
)
with patch("sys.stdin", io.StringIO(payload)):
mod.main()
captured = capsys.readouterr()
assert captured.out == ""
# ---------------------------------------------------------------------------
# drone auth.py — ALLOWED_CALLERS derived from permissions
# ---------------------------------------------------------------------------
def test_drone_auth_allowed_callers_matches_permissions():
"""Hook and drone must reach the same decision for the same caller."""
from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
from aipass.seedgo.apps.modules.permissions import TRUSTED_CROSS_WRITERS
for branch in TRUSTED_CROSS_WRITERS:
assert branch in ALLOWED_CALLERS, f"'{branch}' in TRUSTED_CROSS_WRITERS but missing from drone ALLOWED_CALLERS"
def test_drone_auth_allowed_callers_includes_devpulse():
"""devpulse must remain in drone ALLOWED_CALLERS."""
from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
assert "devpulse" in ALLOWED_CALLERS
def test_drone_auth_allowed_callers_includes_seedgo():
"""seedgo must be in drone ALLOWED_CALLERS."""
from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
assert "seedgo" in ALLOWED_CALLERS
def test_drone_auth_allowed_callers_includes_spawn():
"""spawn must be in drone ALLOWED_CALLERS."""
from aipass.drone.apps.plugins.devpulse_ops.auth import ALLOWED_CALLERS
assert "spawn" in ALLOWED_CALLERS
# ---------------------------------------------------------------------------
# inbox_audit.py — handle_command routing + _scan_inbox
# ---------------------------------------------------------------------------
def test_inbox_audit_ignores_non_audit_command():
"""handle_command returns False for non-audit command names."""
from aipass.seedgo.apps.modules.inbox_audit import handle_command
assert handle_command("standards_query", ["inbox-ids"]) is False
assert handle_command("checklist", ["inbox-ids"]) is False
def test_inbox_audit_handles_inbox_ids_subcommand():
"""handle_command returns True and runs scan for `audit inbox-ids`."""
from aipass.seedgo.apps.modules.inbox_audit import handle_command
with patch("aipass.seedgo.apps.modules.inbox_audit._run_inbox_id_scan", return_value=0):
result = handle_command("audit", ["inbox-ids"])
assert result is True
def test_inbox_audit_ignores_other_audit_subcommands():
"""handle_command returns False for audit subcommands other than inbox-ids."""
from aipass.seedgo.apps.modules.inbox_audit import handle_command
assert handle_command("audit", ["aipass"]) is False
assert handle_command("audit", ["flow"]) is False
def test_inbox_audit_scan_detects_bad_id(tmp_path):
"""_scan_inbox flags message ids that are not 8-char lowercase hex."""
from aipass.seedgo.apps.modules.inbox_audit import _scan_inbox
inbox = tmp_path / "inbox.json"
inbox.write_text(
json.dumps(
{
"messages": [
{"id": "not-hex!", "subject": "bad", "from": "@test", "status": "new"},
{"id": "a1b2c3d4", "subject": "ok", "from": "@test", "status": "new"},
]
}
),
encoding="utf-8",
)
violations = _scan_inbox(inbox)
assert len(violations) == 1
assert violations[0]["id"] == "not-hex!"
def test_inbox_audit_scan_passes_valid_ids(tmp_path):
"""_scan_inbox returns empty list when all message ids are valid 8-hex."""
from aipass.seedgo.apps.modules.inbox_audit import _scan_inbox
inbox = tmp_path / "inbox.json"
inbox.write_text(
json.dumps(
{
"messages": [
{"id": "a1b2c3d4", "subject": "ok1", "from": "@x", "status": "new"},
{"id": "deadbeef", "subject": "ok2", "from": "@y", "status": "new"},
]
}
),
encoding="utf-8",
)
violations = _scan_inbox(inbox)
assert violations == []
# ---------------------------------------------------------------------------
# delivery.py — deliver_to_inbox_file single-path helper
# Load by file path to avoid cross-branch package import restriction.
# ---------------------------------------------------------------------------
def _load_delivery():
"""Load ai_mail delivery.py by file path (bypasses cross-branch import check)."""
delivery_path = REPO_ROOT / "src" / "aipass" / "ai_mail" / "apps" / "handlers" / "email" / "delivery.py"
if not delivery_path.exists():
pytest.skip(f"delivery.py not found: {delivery_path}")
spec = importlib.util.spec_from_file_location("delivery", delivery_path)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod) # type: ignore[union-attr]
return mod
def test_deliver_to_inbox_file_returns_false_for_missing_inbox(tmp_path):
"""deliver_to_inbox_file returns (False, error, '') when inbox does not exist."""
delivery = _load_delivery()
missing = tmp_path / "inbox.json"
success, error_msg, reply_id = delivery.deliver_to_inbox_file(
missing,
{"from": "@x", "to": "@y", "subject": "s", "message": "m", "timestamp": "t"},
)
assert success is False
assert reply_id == ""
def test_deliver_to_inbox_file_writes_message_and_returns_id(tmp_path):
"""deliver_to_inbox_file writes to inbox and returns the assigned 8-char id."""
delivery = _load_delivery()
inbox = tmp_path / "inbox.json"
inbox.write_text(
json.dumps({"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []}),
encoding="utf-8",
)
email_data = {
"from": "@sender",
"to": "@recv",
"subject": "Hello",
"message": "body",
"timestamp": "2026-04-21 00:00:00",
}
with patch.object(delivery, "_send_desktop_notification"):
success, error_msg, reply_id = delivery.deliver_to_inbox_file(inbox, email_data)
assert success is True
assert len(reply_id) == 8
data = json.loads(inbox.read_text())
assert len(data["messages"]) == 1
assert data["messages"][0]["id"] == reply_id