Merge pull request #629 from AIOSAI/dev

Fix dashboard plan-count zeroing + aipass bare-command introspection

Two verified bug fixes:

1. fix(flow): dashboard refresh no longer zeroes non-flow branch plan counts.
   PLANS.central.json now comprehensive (all branches grouped per-branch).
   Devpulse shows its 12 open plans again. +1 regression test, 734 pass.

2. fix(aipass): bare 'aipass <command>' runs instead of showing introspection
   banner. All 7 modules fixed; 'aipass doctor' runs the health check.
   Introspection moved to --info. seedgo standard bypassed for binary-invoked
   modules. 424 tests pass.

Both verified independently: dashboard refresh writes active_plans=12 (was 0);
bare 'aipass doctor' runs the full check.
This commit is contained in:
AIPass
2026-06-04 18:01:57 -07:00
committed by GitHub
51 changed files with 3675 additions and 426 deletions
+5
View File
@@ -41,6 +41,11 @@
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
},
"rm_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
"matcher": "Bash"
},
"engine_test_sound": {
"enabled": false,
"command": "python3 $AIPASS_HOME/.claude/hooks/engine_test_sound.py",
+5
View File
@@ -40,6 +40,11 @@
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.git_gate.handle",
"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit"
},
"rm_gate": {
"enabled": true,
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
"matcher": "Bash"
}
},
+4 -2
View File
@@ -38,7 +38,9 @@ jobs:
- run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- run: coverage run -m pytest -v --tb=short --rootdir=.
# tests/e2e build a wheel + clean venv per the dedicated e2e-wheel.yml
# workflow — they are not part of the fast unit lane.
- run: coverage run -m pytest -v --tb=short --rootdir=. --ignore=tests/e2e
standards:
name: seedgo-audit
@@ -66,7 +68,7 @@ jobs:
- run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- run: coverage run -m pytest --rootdir=.
- run: coverage run -m pytest --rootdir=. --ignore=tests/e2e
- run: coverage xml
- uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
+50
View File
@@ -0,0 +1,50 @@
name: e2e-wheel
# Cross-OS end-to-end WIRING test (FPLAN-0239, P1 of DPLAN-0194).
# Builds the wheel, installs it into a clean venv (handled by the pytest
# fixtures in tests/e2e/conftest.py), and runs the 4-tier wiring ladder.
#
# RED-FIRST: Windows is EXPECTED to fail in known places (symlink init,
# bin-vs-Scripts, /tmp). Do not "fix" Windows here — the red is the deliverable.
on:
push:
branches: [main, dev]
paths:
- "tests/e2e/**"
- ".github/workflows/e2e-wheel.yml"
- "pyproject.toml"
- "src/**"
pull_request:
paths:
- "tests/e2e/**"
- ".github/workflows/e2e-wheel.yml"
- "pyproject.toml"
- "src/**"
workflow_dispatch:
jobs:
e2e-wheel:
name: e2e-wheel (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.12"]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install build tooling
run: python -m pip install --upgrade pip build pytest
- name: Run cross-OS e2e wiring harness
# conftest.py builds the wheel + clean venv internally; the outer env
# only needs build + pytest.
run: python -m pytest tests/e2e -v
+135
View File
@@ -8,6 +8,141 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
---
## [2026.W23] - 2026-06-02
### Added
- **Cross-OS end-to-end WIRING test (`tests/e2e/`, `e2e-wheel.yml`)** — the first
CI gate that proves real AIPass *wiring* (not units-with-mocks) by building the
wheel, installing it into a clean venv, and asserting a 4-tier ladder: package
install + console scripts (T0), `aipass init` scaffolding (T1), a hook actually
firing via the bridge with an observable `engine.jsonl` record (T2a), and
`drone` resolving + subprocess-executing a real branch (T3). Runs on a 3-OS
matrix (ubuntu/windows/macos, `fail-fast: false`). Ran red-first on Windows by
design and immediately earned its keep — it caught two real, *previously
uncovered* Windows wiring bugs (`aipass init` preflight + `drone` stdout
encoding, both fixed below). Notably the layers we most feared — clean-wheel
install (T0) and hook firing (T2a) — passed on Windows. (DPLAN-0194 /
FPLAN-0239)
- **`drone rm` — provider-agnostic safe delete** — a contained recursive delete
that lets agents clean up scratch dirs without tripping the `rm -rf` block.
Deletes are confined to the project root and the system temp dirs (`/tmp` and
`$TMPDIR`), refusing anything outside (home, `/etc`, `/`, etc.). Even inside
those roots it hard-refuses protected internals — `.git`, `.trinity/`,
`.aipass/`, `.codex/`, `.agents/`, and sibling-branch worktrees — mirroring the
filesystem boundary an OS-sandboxed agent (e.g. Codex) enforces, so behavior is
consistent across CLIs. Pure-Python (`shutil.rmtree`), with a red-team test
suite for containment escapes (symlinks, traversal, sibling branches). (#630)
- **`rm_gate` hook — block raw recursive `rm`, teach the safe path** — a
PreToolUse gate (mirroring `git_gate`) that blocks raw `rm -r`/`-rf`/`-fr`/
`--recursive` and redirects the agent to `drone rm`. Provider-agnostic (runs in
the hook engine, not tied to Claude Code permission rules), conservative
(unparseable targets are blocked, not allowed), and skips `drone rm` itself.
This makes the safe-delete path discoverable at the moment of friction. (#630)
- **Hook engine logs `agent_type` / `agent_id` per fire** — the engine now
records which agent triggered each hook (e.g. `agent=main` vs `agent=Explore`)
in both `engine.jsonl` and the prax monitor stream. Previously the payload
flowed into handlers but was never logged, leaving no way to tell an internal
main-turn fire from a real sub-agent fire. Pure visibility; no behavior change.
Groundwork for #606. (#606)
- **OpenSSF Best Practices passing badge** — AIPass earned the OpenSSF Best
Practices (CII) **passing** badge (100% of criteria), added to the README badge
cluster. Self-certified across all six categories — basics, change control,
reporting, quality, security, and analysis. Complements the existing OpenSSF
Scorecard, lifting the `CII-Best-Practices` check from 0. (DPLAN-0193)
### Changed
- **Retired the blanket `rm` deny from provider settings** — `setup.sh` and
`aipass init` no longer ship `Bash(rm -rf*)` / `Bash(rm -r *)` deny rules
(they were mis-filed among git rules, blocked all `/tmp` cleanup, and gave a
bare "permission denied" with no guidance). The `rm_gate` hook + `drone rm`
now own this — cross-provider, path-aware, and they teach. `aipass doctor`
detects the stale rules on existing installs and `aipass doctor --fix` removes
them (idempotent, preserves all other rules). Claude Code still natively
circuit-breaks `rm -rf /` and `rm -rf ~`. (#630)
### Fixed
- **Two latent Windows portability bugs caught by the new e2e harness** — both
were always present in the code; they only surfaced now because this is the
first CI to run `aipass init` scaffolding and real-branch `drone` routing on
Windows (the old Windows CI ran an editable install, `aipass`-less, and only
routed to in-process modules, so both paths had zero Windows coverage). Pure
portability fixes — Linux/macOS behaviour is unchanged.
- **`aipass init` crashed on Windows (surfaced as a misleading "Unknown
command: init").** Init scaffolded the project correctly, then crashed
*printing its `✓ Project initialized` banner* — Rich wrote the ✓/box glyphs
through a cp1252 stdout, raising `UnicodeEncodeError ('charmap')`; the error
handler's `✗` message hit the same wall, bubbling up to the command router
which mislabeled it. The `aipass` entry point now reconfigures stdout/stderr
to UTF-8 in place on Windows. (The init preflight ancestor-walk was also
hardened to skip un-enumerable Windows drive-root entries — defensive, not
the trigger.)
- **`drone @branch` crashed on Windows with the same `UnicodeEncodeError
('charmap')`.** `drone` resolved + subprocessed the branch correctly, then
crashed *printing* the captured output through cp1252 stdout. The existing
`PYTHONUTF8` guard only affected child interpreters, not the live process
streams — `drone`'s entry point now also `reconfigure()`s stdout/stderr to
UTF-8 in place.
- **CI unit lane no longer runs the e2e wheel tests.** `ci.yml`'s
`pytest --rootdir=.` swept in `tests/e2e/` (which build a wheel per the
dedicated `e2e-wheel.yml`), failing the unit lane; it now `--ignore`s them.
(DPLAN-0194)
- **A release merge can no longer destroy the `dev` branch** — `drone @git merge`
passed `--delete-branch` to `gh pr merge` unconditionally, so merging a
`dev`→`main` PR deleted the persistent `dev` branch on the remote and stranded
the working tree on `main` (the next commit silently landing on main). Merge now
looks up the PR's head ref and **only deletes non-protected branches** — `dev`
and `main` are never deleted, and an undeterminable head ref fails safe (no
delete). After a merge it returns the working tree to `dev` (loud warning if it
can't). `drone @git branches` now runs `fetch --prune` before listing so it
reflects the live remote instead of stale cached refs, and a new
`drone @git prune-temp` cleans up merged temp PR branches. (#625)
- **`drone @git status`/`diff` show their scope** — when scoped to a branch (no
`--all`), output now appends "(showing <branch> scope — use --all for full
repo)", so an empty scoped view is no longer mistaken for a clean repo. (#623)
- **External projects can call AIPass branches via drone** — `drone @api ...`
(and any `drone @X`) now resolves from a non-AIPass project CWD instead of
being blocked with "path escapes project root." The resolver was validating a
branch's path against the *primary* registry root even when the branch was
found via the `AIPASS_HOME` fallback, so any external project (Vera Studio,
Daemon) hit a false security block. `resolve_branch()` now validates
containment against the registry the branch was actually found in. Security is
unchanged — each branch is still contained within its own declaring registry's
root; genuine path escapes remain blocked. (#618)
- **`aipass <command>` runs instead of printing an introspection banner** —
`aipass` is a user-facing binary, so `aipass doctor` (and every other command)
must execute, not describe itself. All 7 modules (`doctor`, `doctor_fix`,
`doctor_wire`, `handoff`, `help_chat`, `init_flow`, `profile`) previously hit a
no-args→introspection gate (a standard meant for `drone @branch <module>`
discovery) and showed a banner on bare invocation. Now bare invocation runs the
command or shows usage; the introspection banner moved to `--info`. The seedgo
introspection standard is bypassed for these binary-invoked modules (documented).
- **Dashboard plan counts no longer zeroed on refresh** — a branch's
`active_plans` was reset to `0` by every `drone @prax dashboard refresh`, because
`PLANS.central.json` only held Flow's own plans (`location==FLOW_ROOT` filter).
The central file is now comprehensive: all plans grouped per-branch, so refresh
reports each branch's real count (e.g. devpulse now shows its 12 open plans
instead of 0).
### Security
- **Pinned the `requests` floor to a non-vulnerable version** — raised
`requests` to `>=2.34.2` in `pyproject.toml` and the API branch's
`requirements.project.txt` (which previously listed it unconstrained). This
clears six OSV advisories the OpenSSF Scorecard flagged against the dependency
(PYSEC-2014-13, PYSEC-2014-14, PYSEC-2018-28, GHSA-9wx4-h78v-vm56,
GHSA-9hjg-9r4m-mvj7, GHSA-gc5v-m9x4-r6x2) — the oldest surfaced only because the
dependency was declared without a version bound. No runtime change (the AIPass
venv already ran a fixed release). (DPLAN-0193)
- **Pinned the test container base image by digest** — `Dockerfile.test` now pins
`ubuntu:24.04` to its registry digest (`sha256:786a8b55…`) so the test image is
reproducible and tamper-evident, clearing the Scorecard `containerImage not
pinned by hash` finding. (DPLAN-0193)
---
## [2026.W22] - 2026-05-30
### Added
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ubuntu:24.04
FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54
ENV DEBIAN_FRONTEND=noninteractive
+2 -5
View File
@@ -2,19 +2,16 @@
[![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
[![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/aipass)](https://pypi.org/project/aipass/)
[![CLI](https://img.shields.io/badge/CLI-Claude%20Code-purple)](#cli-support)
[![Give Feedback](https://img.shields.io/badge/Give-Feedback-brightgreen)](https://github.com/AIOSAI/AIPass/issues/new?template=feedback.yml)
[![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass)
[![OSS Health](https://oss-health-monitor.vercel.app/api/badge/AIOSAI/AIPass)](https://github.com/volotat/OSS-Health-Monitor)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge)](https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass)
[![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13095/badge)](https://www.bestpractices.dev/projects/13095)
<p align="center">
<img src="assets/logo.png" alt="AIPass" width="400" />
</p>
<p align="center"><strong>Persistent Agent Workspace</strong></p>
<p align="center"><em>AI agents that remember, collaborate, and never start from zero.</em></p>
<p align="center">
<a href="https://scorecard.dev/viewer/?uri=github.com/AIOSAI/AIPass"><img src="https://api.scorecard.dev/projects/github.com/AIOSAI/AIPass/badge" alt="OpenSSF Scorecard" /></a>
</p>
![demo](assets/demo.gif)
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "aipass"
version = "2.5.0"
version = "2.5.1"
description = "A local multi-agent framework where your AI agents keep their memory, work together, and never ask you to re-explain context"
readme = "README.md"
license = "MIT"
@@ -28,7 +28,7 @@ classifiers = [
dependencies = [
"rich>=13.0",
"watchdog>=3.0",
"requests>=2.28",
"requests>=2.34.2",
"psutil>=5.9",
"questionary>=2.0",
]
-2
View File
@@ -606,7 +606,6 @@ git_deny = [
"Bash(git push -f *)",
"Bash(git rebase*)",
"Bash(git clean*)",
"Bash(rm -rf*)",
"Bash(git reset*)",
"Bash(git merge*)",
"Bash(git config*)",
@@ -617,7 +616,6 @@ git_deny = [
"Bash(git branch -D*)",
"Bash(git stash drop*)",
"Bash(git stash clear*)",
"Bash(rm -r *)",
"Bash(git checkout -b*)",
"Bash(git switch -c*)",
"Bash(git switch --create*)",
+40 -30
View File
@@ -2,8 +2,8 @@
"metadata": {
"version": "2.0.0",
"created": "2026-04-16",
"description": "Bypass config for @aipass citizen. While under construction (DPLAN-0136 Phase 0-3), module and handler files exist as documented placeholders with no implementation body. Each placeholder raises NotImplementedError and declares its phase. Bypass standards that fire on structural requirements the placeholders intentionally skip — json_handler import, print_introspection, CLI service wiring. Remove these entries in Phase N as each module gets its real body.",
"last_updated": "2026-04-16"
"description": "Bypass config for @aipass citizen. Modules operational: concierge/init/doctor/handoff/profile built and tested. Bypasses cover: binary-invocation introspection pattern (bare aipass <cmd> runs, --info for introspection), thin entry-point router (aipass.py uses bare print, no CLI imports), pure-python bootstrap (bootstrap.py/scaffold_content.py run before AIPass services exist), test-isolation patterns (architecture/encapsulation for tests/ directory), and CLI flag name references (permission_flags in test assertions and handoff platform).",
"last_updated": "2026-06-02"
},
"bypass": [
{
@@ -31,45 +31,20 @@
"standard": "cli",
"reason": "Session info must print immediately after tmux spawn — returning data to module layer would lose the timing context. User needs attach/kill instructions right when the session starts."
},
{
"file": "apps/modules/handoff.py",
"standard": "introspection",
"reason": "Phase 4 placeholder — print_introspection() added with handoff build."
},
{
"file": "apps/modules/handoff.py",
"standard": "json_structure",
"reason": "Phase 4 placeholder — json_handler import added with handoff build."
},
{
"file": "apps/modules/handoff.py",
"standard": "cli",
"reason": "Phase 4 placeholder — CLI service imports added with handoff build."
},
{
"file": "apps/aipass.py",
"standard": "architecture",
"reason": "Phase 0 entry-point stub from spawn template. Full 3-layer wiring (modules/ discovery, handlers/ imports) added when first module comes online (Phase 1)."
},
{
"file": "apps/aipass.py",
"standard": "cli",
"reason": "Phase 0 entry-point stub — CLI service imports (console, header) added when modules come online (Phase 1+)."
"reason": "Thin command router — discovers and routes to modules, which own the CLI service layer. Adding console/header imports here couples the bootstrap entry point to Rich for 4 status lines."
},
{
"file": "apps/aipass.py",
"standard": "debug_print",
"reason": "Phase 0 entry-point stub uses bare print() for scaffold visibility. Replaced with console.print() when CLI services are wired in Phase 1+."
"reason": "Thin command router uses bare print() for version output and help banner (4 calls). These run before module discovery — importing Rich console for bootstrap output adds startup overhead for minimal benefit."
},
{
"file": "apps/aipass.py",
"standard": "introspection",
"reason": "Phase 0 — spawn template does not emit print_introspection(). Added when modules come online (Phase 1+)."
},
{
"file": "apps/aipass.py",
"standard": "log_structure",
"reason": "Phase 0 — logs/ directory exists (spawn-created), but prax logger import not wired yet. Added when first module uses it."
"reason": "Thin command router, not a module — it has no domain to introspect. Modules handle their own introspection via --info. No print_introspection() needed."
},
{
"file": "apps/modules/doctor.py",
@@ -260,6 +235,41 @@
"file": "apps/handlers/init/scaffold_content.py",
"standard": "json_structure",
"reason": "scaffold_content.py is Pure Python only (no module/prax/cli imports) by design — pure string-returning template generators extracted from bootstrap.py. Same constraint as bootstrap.py."
},
{
"file": "apps/modules/doctor.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass doctor runs the command; introspection via --info"
},
{
"file": "apps/modules/doctor_fix.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
},
{
"file": "apps/modules/doctor_wire.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: bare invocation shows usage; introspection via --info"
},
{
"file": "apps/modules/handoff.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass handoff shows usage; introspection via --info"
},
{
"file": "apps/modules/help_chat.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass help shows usage; introspection via --info"
},
{
"file": "apps/modules/init_flow.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass init shows usage; introspection via --info"
},
{
"file": "apps/modules/profile.py",
"standard": "introspection",
"reason": "aipass is binary-invoked: aipass profile runs the command; introspection via --info"
}
]
}
+14
View File
@@ -15,11 +15,25 @@ Auto-discovery architecture:
- No manual imports or routing needed
"""
import os
import sys
import importlib
from pathlib import Path
from typing import List, Any
# Windows terminals/pipes default to cp1252, which can't encode the Unicode
# Rich emits (✓/✗, box-drawing, arrows). PYTHONUTF8 only affects child
# interpreters, not this process's already-open stdout/stderr — so we also
# reconfigure the live streams to UTF-8 in place (Python 3.7+). Without this,
# `aipass init` scaffolds correctly but crashes printing its success banner
# with UnicodeEncodeError ('charmap') on Windows. Mirrors drone/cli.py.
if sys.platform == "win32":
os.environ.setdefault("PYTHONUTF8", "1") # for child subprocesses
for _stream in (sys.stdout, sys.stderr):
_reconfigure = getattr(_stream, "reconfigure", None)
if _reconfigure is not None:
_reconfigure(encoding="utf-8", errors="replace")
from aipass.prax import logger
# =============================================================================
@@ -213,7 +213,6 @@ def _claude_settings(aipass_home: str | None = None) -> str:
data["permissions"] = {
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"EnterPlanMode",
+17 -19
View File
@@ -38,6 +38,7 @@ from aipass.aipass.apps.modules.doctor_fix import (
from aipass.aipass.apps.modules.doctor_wire import (
_auto_wire_provider,
prompt_auto_wire,
reconcile_stale_deny,
)
from aipass.aipass.apps.handlers.system_detect.system_detector import (
detect_cpu,
@@ -68,9 +69,6 @@ class CheckResult(NamedTuple):
remediation: str
# --- Identity helpers ---
def _find_registry() -> Path | None:
"""Walk up from CWD first (user's project), then branch root."""
cwd = Path.cwd()
@@ -87,9 +85,6 @@ def _find_registry() -> Path | None:
return None
# --- Check groups ---
def _check_system() -> List[CheckResult]:
"""Run System group checks."""
results: List[CheckResult] = []
@@ -147,11 +142,10 @@ def _check_identity() -> List[CheckResult]:
"""Run Identity group checks."""
results: List[CheckResult] = []
# Project root — derived from registry location
reg = _find_registry()
project_root = str(reg.parent) if reg else ""
if project_root:
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, project_root, ""))
# Project root + registry — single lookup
reg_path = _find_registry()
if reg_path:
results.append(CheckResult("AIPASS_HOME", GLYPH_PASS, str(reg_path.parent), ""))
else:
home = os.environ.get("AIPASS_HOME", "")
if home:
@@ -166,8 +160,6 @@ def _check_identity() -> List[CheckResult]:
)
)
# Registry present
reg_path = _find_registry()
if reg_path is None:
results.append(CheckResult("registry", GLYPH_FAIL, "not found", "Run 'aipass init' to create registry"))
return results
@@ -451,6 +443,10 @@ def _check_services(verbose: bool = False) -> List[CheckResult]:
manifest_checks = _check_provider_manifest()
results.extend(manifest_checks)
# stale rm deny rules — detect only (fix runs in run_doctor when --fix)
for tup in reconcile_stale_deny(fix=False):
results.append(CheckResult(*tup))
return results
@@ -588,6 +584,12 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
r for r in groups.get("Services", []) if r.label not in ("hooks", "env vars", "permissions")
] + manifest_results
if fix:
stale_results = [CheckResult(*tup) for tup in reconcile_stale_deny(fix=True)]
if stale_results:
services = groups.get("Services", [])
groups["Services"] = [r for r in services if r.label != "rm deny migration"] + stale_results
pass_count = 0
warn_count = 0
error_count = 0
@@ -664,15 +666,11 @@ def handle_command(command: str, args: list[str]) -> bool:
if command != "doctor":
return False
if not args:
print_introspection()
return True
if args[0] in ("--help", "-h", "help"):
if args and args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "--info":
if args and args[0] == "--info":
print_introspection()
return True
+3 -3
View File
@@ -304,12 +304,12 @@ def handle_command(command: str, args: list[str]) -> bool:
return False
if not args:
print_introspection()
json_handler.log_operation("doctor_fix_info", {"command": command})
console.print("[dim]Helper module — use: aipass doctor --fix [--json][/dim]")
json_handler.log_operation("doctor_fix_usage", {"command": command})
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
console.print("[dim]Helper module — use: aipass doctor --fix [--json][/dim]")
json_handler.log_operation("doctor_fix_help", {"command": command})
return True
+54 -4
View File
@@ -51,6 +51,56 @@ ENV_DESCRIPTIONS: Dict[str, str] = {
# =============================================================================
# STALE DENY RULE MIGRATION
# =============================================================================
_STALE_RM_DENY_RULES = frozenset({"Bash(rm -rf*)", "Bash(rm -r *)"})
def reconcile_stale_deny(fix: bool = False) -> list:
"""Detect and optionally remove stale rm deny rules from provider settings.
Returns list of (label, glyph, detail, remediation) tuples matching
doctor.CheckResult shape — imported as tuples to avoid circular import.
"""
from aipass.aipass.apps.handlers.ui.progress import GLYPH_PASS, GLYPH_WARN
results: list = []
settings_path = Path.home() / ".claude" / "settings.json"
if not settings_path.exists():
return results
data = json_handler.load_path(settings_path)
if data is None:
return results
deny = data.get("permissions", {}).get("deny", [])
stale = [r for r in deny if r in _STALE_RM_DENY_RULES]
if not stale:
results.append(("rm deny migration", GLYPH_PASS, "no stale rules", ""))
return results
if fix:
deny_cleaned = [r for r in deny if r not in _STALE_RM_DENY_RULES]
data.setdefault("permissions", {})["deny"] = deny_cleaned
json_handler.save_path(settings_path, data)
removed = ", ".join(stale)
results.append(("rm deny migration", GLYPH_PASS, f"removed: {removed}", ""))
logger.info("[doctor] removed stale deny rules: %s", stale)
else:
found = ", ".join(stale)
results.append(
(
"rm deny migration",
GLYPH_WARN,
f"stale rules: {found}",
"Run aipass doctor --fix to remove (rm_gate + drone rm replace these)",
)
)
return results
# =============================================================================
# AUTO-WIRE
@@ -283,13 +333,13 @@ def handle_command(command: str, args: list[str]) -> bool:
return False
if not args:
print_introspection()
json_handler.log_operation("doctor_wire_info", {"command": command})
console.print("[dim]Helper module — use: aipass doctor (auto-wire runs when needed)[/dim]")
json_handler.log_operation("doctor_wire_usage", {"command": command})
return True
if args[0] in ("--help", "-h", "help"):
print_introspection()
json_handler.log_operation("doctor_wire_info", {"command": command})
console.print("[dim]Helper module — use: aipass doctor (auto-wire runs when needed)[/dim]")
json_handler.log_operation("doctor_wire_help", {"command": command})
return True
if args[0] in ("--info", "info"):
+5 -1
View File
@@ -134,13 +134,17 @@ def handle_command(command: str, args: list[str]) -> bool:
return False
if not args:
print_introspection()
print_help()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "--info":
print_introspection()
return True
if args[0] == "launch":
cli, cwd, flag_variant = _parse_launch_args(args[1:])
if cli not in CLI_CHOICES:
+19 -1
View File
@@ -54,6 +54,20 @@ def print_introspection() -> None:
console.print(f"[bold cyan]Version:[/bold cyan] {_VERSION}")
def print_help() -> None:
"""Print usage help for the help command."""
console.print()
console.print("[bold cyan]aipass help[/bold cyan] — README-backed Q&A")
console.print()
console.print("[yellow]USAGE:[/yellow]")
console.print(" [green]aipass help <question>[/green] [dim]# Search branch READMEs[/dim]")
console.print()
console.print("[yellow]EXAMPLES:[/yellow]")
console.print(" [green]aipass help what does drone do[/green]")
console.print(" [green]aipass help how does ai_mail work[/green]")
console.print()
# =============================================================================
# KEYWORD EXTRACTION
# =============================================================================
@@ -219,10 +233,14 @@ def handle_command(command: str, args: list[str]) -> bool:
json_handler.ensure_module_jsons(_MODULE_NAME)
if not args:
print_introspection()
print_help()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "--info":
print_introspection()
return True
+29 -6
View File
@@ -645,13 +645,18 @@ def _write_init_report(agent_path: str, accumulated: Dict[str, Any], dry_run: bo
"cli_choice": accumulated.get("cli", "claude"),
"total_agents": 1,
"system": system_data,
"note": "You are the first agent created in this project. You are the orchestrator. After dispatching work to other agents, monitor them with: drone @devpulse watchdog agent @target",
"note": (
"You are the first agent created in this project. You are the orchestrator."
" After dispatching work to other agents, monitor them with:"
" drone @devpulse watchdog agent @target"
),
}
provider_gaps = accumulated.get("provider_gaps", {})
if provider_gaps:
report["provider_gaps"] = provider_gaps
report["provider_action"] = (
"Provider settings need configuring. Tell the user what is missing and point them to provider_manifest.json for details."
"Provider settings need configuring. Tell the user what is missing"
" and point them to provider_manifest.json for details."
)
report_path = dropbox / "init_report.json"
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
@@ -685,10 +690,24 @@ def _preflight_check() -> str | None:
"This directory is an agent branch (has .trinity/passport.json).\n"
"Agents are managed by 'drone @spawn', not 'aipass init'."
)
# Block if inside an existing AIPass project (registry above us)
# Block if inside an existing AIPass project (registry above us).
# Walking up to the filesystem root can hit ancestors that can't be
# enumerated or stat'd — e.g. locked Windows system entries at the drive
# root (pagefile.sys) raise OSError. Skip those rather than crash; on
# POSIX everything up to / is readable so behaviour is unchanged there.
for parent in [cwd] + list(cwd.parents):
for f in parent.iterdir():
if f.is_file() and f.name.endswith("_REGISTRY.json"):
try:
entries = list(parent.iterdir())
except OSError as exc:
logger.info("[init_flow] skipping unreadable ancestor %s: %s", parent, exc)
entries = []
for f in entries:
try:
is_registry = f.is_file() and f.name.endswith("_REGISTRY.json")
except OSError as exc:
logger.info("[init_flow] skipping unstattable entry %s: %s", f, exc)
continue
if is_registry:
return (
f"Already inside an AIPass project (found {f.name} at {parent}).\n"
"Use 'aipass init update' to upgrade an existing project."
@@ -917,13 +936,17 @@ def handle_command(command: str, args: list[str]) -> bool:
return False
if not args:
print_introspection()
print_help()
return True
if args[0] in ("--help", "-h", "help"):
print_help()
return True
if args[0] == "--info":
print_introspection()
return True
if args[0] == "agent":
sys.exit(_handle_init_agent(args[1:]))
return True
@@ -141,6 +141,10 @@ def handle_command(command: str, args: list[str]) -> bool:
print_help()
return True
if args[0] == "--info":
print_introspection()
return True
if args[0] == "set":
if len(args) < 3:
error("Usage: aipass profile set <field> <value>")
+130
View File
@@ -612,3 +612,133 @@ class TestHooksJsonCheck:
assert len(hooks_results) == 1
assert hooks_results[0].glyph == GLYPH_WARN
assert "init update" in hooks_results[0].remediation
# =============================================================================
# TestReconcileStaleDeny
# =============================================================================
class TestReconcileStaleDeny:
"""Tests for stale rm deny rule migration (DPLAN-0192 Phase 2)."""
def test_no_settings_file_returns_empty(self, tmp_path) -> None:
"""Missing settings.json returns no results."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert results == []
def test_no_stale_rules_returns_pass(self, tmp_path) -> None:
"""Settings with no stale rm rules returns PASS."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"permissions": {"deny": ["Bash(git push --force*)", "Bash(git reset --hard*)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
assert "no stale" in results[0][2]
def test_stale_rules_detected_without_fix(self, tmp_path) -> None:
"""Stale rm rules present returns WARN when fix=False."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_WARN
assert "rm -rf" in results[0][2]
assert "rm -r " in results[0][2]
def test_fix_removes_stale_rules(self, tmp_path) -> None:
"""fix=True removes stale rules and preserves others."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
original = {
"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git push --force*)", "Bash(rm -r *)"]},
"env": {"AIPASS_HOME": "/test"},
}
settings.write_text(json.dumps(original), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
assert "removed" in results[0][2]
updated = json.loads(settings.read_text(encoding="utf-8"))
assert "Bash(rm -rf*)" not in updated["permissions"]["deny"]
assert "Bash(rm -r *)" not in updated["permissions"]["deny"]
assert "Bash(git push --force*)" in updated["permissions"]["deny"]
assert updated["env"]["AIPASS_HOME"] == "/test"
def test_fix_single_stale_rule(self, tmp_path) -> None:
"""fix=True works when only one of two stale rules is present."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(git reset --hard*)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
updated = json.loads(settings.read_text(encoding="utf-8"))
assert updated["permissions"]["deny"] == ["Bash(git reset --hard*)"]
def test_fix_idempotent(self, tmp_path) -> None:
"""Running fix twice is safe — second run returns PASS with no stale rules."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps({"permissions": {"deny": ["Bash(rm -rf*)", "Bash(rm -r *)"]}}),
encoding="utf-8",
)
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
reconcile_stale_deny(fix=True)
results = reconcile_stale_deny(fix=True)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
assert "no stale" in results[0][2]
def test_empty_deny_list_returns_pass(self, tmp_path) -> None:
"""Empty deny list returns PASS."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"permissions": {"deny": []}}), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
def test_no_permissions_key_returns_pass(self, tmp_path) -> None:
"""Settings without permissions key returns PASS."""
from aipass.aipass.apps.modules.doctor_wire import reconcile_stale_deny
settings = tmp_path / ".claude" / "settings.json"
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"env": {"FOO": "bar"}}), encoding="utf-8")
with patch("aipass.aipass.apps.modules.doctor_wire.Path.home", return_value=tmp_path):
results = reconcile_stale_deny(fix=False)
assert len(results) == 1
assert results[0][1] == GLYPH_PASS
+5 -4
View File
@@ -364,14 +364,15 @@ class TestDoctorFixHandleCommand:
assert handle_command("doctor", []) is False
assert handle_command("help", []) is False
def test_no_args_calls_introspection(self) -> None:
"""No args triggers print_introspection."""
def test_no_args_shows_usage(self) -> None:
"""No args shows usage message (not introspection banner)."""
from aipass.aipass.apps.modules.doctor_fix import handle_command
with patch("aipass.aipass.apps.modules.doctor_fix.print_introspection") as mock:
with patch("aipass.aipass.apps.modules.doctor_fix.console") as mock_console:
result = handle_command("doctor_fix", [])
assert result is True
mock.assert_called_once()
printed = " ".join(str(c) for c in mock_console.print.call_args_list)
assert "aipass doctor --fix" in printed
def test_info_flag(self) -> None:
"""--info triggers print_introspection."""
+14 -18
View File
@@ -52,20 +52,6 @@ _ENCODING = "utf-8"
# =============================================================================
def _call_handle_command_no_args():
"""Call handle_command('help', []) with json_handler and console mocked."""
mock_console = MagicMock()
patches = [
patch("aipass.aipass.apps.modules.help_chat.json_handler"),
patch("aipass.aipass.apps.modules.help_chat.console", mock_console),
]
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
result = handle_command("help", [])
return result, mock_console
def _call_handle_command_drone_question(readme_content: str, readme_path: Path):
"""Call handle_command for 'what does drone do' with file I/O mocked."""
patches = [
@@ -367,11 +353,21 @@ class TestHandleCommand:
"""COMMAND module constant must equal the string 'help'."""
assert COMMAND == "help"
def test_no_args_returns_true_and_calls_console(self):
"""handle_command('help', []) must return True and print usage via console."""
result, mock_console = _call_handle_command_no_args()
def test_no_args_returns_true_and_shows_help(self):
"""handle_command('help', []) must return True and print usage help."""
with patch("aipass.aipass.apps.modules.help_chat.print_help") as mock_help:
with patch("aipass.aipass.apps.modules.help_chat.json_handler"):
result = handle_command("help", [])
assert result is True
mock_console.print.assert_called()
mock_help.assert_called_once()
def test_info_flag_calls_introspection(self):
"""--info flag triggers print_introspection."""
with patch("aipass.aipass.apps.modules.help_chat.print_introspection") as mock_intro:
with patch("aipass.aipass.apps.modules.help_chat.json_handler"):
result = handle_command("help", ["--info"])
assert result is True
mock_intro.assert_called_once()
def test_valid_drone_question_returns_true(self):
"""A well-formed question about drone must return True."""
+10 -3
View File
@@ -198,11 +198,18 @@ class TestHandleCommand:
assert handle_command("doctor", []) is False
assert handle_command("profile", ["set", "name", "X"]) is False
def test_no_args_shows_introspection(self, tmp_local_json) -> None:
"""'init' with no args calls print_introspection."""
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_intro:
def test_no_args_shows_help(self, tmp_local_json) -> None:
"""'init' with no args calls print_help (not introspection banner)."""
with patch("aipass.aipass.apps.modules.init_flow.print_help") as mock_help:
result = handle_command("init", [])
assert result is True
mock_help.assert_called_once()
def test_info_flag_calls_introspection(self, tmp_local_json) -> None:
"""--info flag calls print_introspection."""
with patch("aipass.aipass.apps.modules.init_flow.print_introspection") as mock_intro:
result = handle_command("init", ["--info"])
assert result is True
mock_intro.assert_called_once()
def test_help_flag(self) -> None:
+3 -1
View File
@@ -290,7 +290,9 @@ class TestReturnTypeContracts:
"""Doctor handle_command returns True for match, False otherwise."""
from aipass.aipass.apps.modules.doctor import handle_command as doctor_cmd
assert doctor_cmd("doctor", []) is True
with patch("aipass.aipass.apps.modules.doctor.run_doctor", return_value=0):
with patch("aipass.aipass.apps.modules.doctor.json_handler"):
assert doctor_cmd("doctor", []) is True
assert doctor_cmd("not_doctor", []) is False
def test_help_chat_handle_command_returns_bool(self):
+8 -1
View File
@@ -170,12 +170,19 @@ class TestHandleCommand:
assert handle_command("init", ["run"]) is False
def test_no_args_calls_introspection(self, tmp_local_json) -> None:
"""'profile' with no args calls print_introspection."""
"""'profile' with no args shows the profile (runs the command)."""
with patch("aipass.aipass.apps.modules.profile.print_introspection") as mock_pi:
result = handle_command("profile", [])
assert result is True
mock_pi.assert_called_once()
def test_info_flag_calls_introspection(self, tmp_local_json) -> None:
"""--info flag calls print_introspection."""
with patch("aipass.aipass.apps.modules.profile.print_introspection") as mock_pi:
result = handle_command("profile", ["--info"])
assert result is True
mock_pi.assert_called_once()
def test_help_flag_returns_true(self) -> None:
"""--help flag is handled."""
with patch("aipass.aipass.apps.modules.profile.print_help"):
+1 -1
View File
@@ -2,7 +2,7 @@
# These are beyond the base AIPass requirements
# Install with: pip install -r requirements.project.txt
requests
requests>=2.34.2
rich
google-auth
google-auth-oauthlib
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
#
# DPLAN-0194 — P1 cross-OS e2e wiring harness PROTOTYPE (Linux/Docker dev loop).
# Runs INSIDE a clean container with the repo bind-mounted read-only at /repo.
# Builds a wheel, installs into a CLEAN venv, then asserts the 4 tiers:
# T0 install+binaries T1 aipass init scaffold T2a synthetic hook fire T3 drone routing
# Tolerant: never exits on first failure — runs every assertion so we see the full red/green ladder.
#
set -uo pipefail
P=0; F=0
ok(){ echo " ok $1"; P=$((P+1)); }
no(){ echo " XX $1"; F=$((F+1)); }
chk(){ if eval "$2" >/dev/null 2>&1; then ok "$1"; else no "$1"; fi; }
hdr(){ echo; echo "=== $1 ==="; }
SRC=~/src
BUILDENV=/tmp/buildenv
CLEANENV=/tmp/cleanenv
DIST=/tmp/dist
PY=$CLEANENV/bin/python
AIPASS=$CLEANENV/bin/aipass
DRONE=$CLEANENV/bin/drone
hdr "SETUP — copy repo (writable), build wheel"
rm -rf "$SRC" "$DIST" "$BUILDENV" "$CLEANENV"
cp -r /repo "$SRC" 2>/dev/null || true # .trinity memory files are perm-restricted; harmless, code copies fine
cd "$SRC"
# A real fresh-clone runs setup.sh to GENERATE the registry (the host's AIPASS_REGISTRY.json
# is mode 0600 and won't copy across uids anyway). Synthesize a minimal one pointing at the
# copied branches — faithful to what setup.sh produces, lets Tier 3 prove routing plumbing.
cat > "$SRC/AIPASS_REGISTRY.json" <<JSON
{ "metadata": { "name": "AIPASS", "version": "1.0.0", "total_branches": 2 },
"branches": [
{ "name": "drone", "path": "$SRC/src/aipass/drone" },
{ "name": "seedgo", "path": "$SRC/src/aipass/seedgo" }
] }
JSON
python3 -m venv "$BUILDENV"
"$BUILDENV/bin/pip" -q install --upgrade pip build 2>&1 | tail -2
echo " building wheel..."
"$BUILDENV/bin/python" -m build --wheel --outdir "$DIST" . 2>&1 | tail -4
WHEEL=$(ls "$DIST"/*.whl 2>/dev/null | head -1)
echo " wheel: ${WHEEL:-<NONE>}"
hdr "TIER 0 — clean-venv wheel install + binaries"
python3 -m venv "$CLEANENV"
if [ -n "${WHEEL:-}" ]; then
"$CLEANENV/bin/pip" -q install "$WHEEL" 2>&1 | tail -3
fi
chk "wheel built" "[ -n '${WHEEL:-}' ]"
chk "clean venv has pip (not silent-broken venv, #495)" "[ -x '$CLEANENV/bin/pip' ]"
chk "aipass console_script installed" "[ -x '$AIPASS' ]"
chk "drone console_script installed" "[ -x '$DRONE' ]"
chk "drone --version runs" "'$DRONE' --version"
chk "aipass entrypoint imports (aipass init --help)" "'$AIPASS' init --help"
hdr "TIER 1 — aipass init scaffolds correctly"
PROJ=/tmp/proj; rm -rf "$PROJ"
# AIPASS_HOME left UNSET on purpose: tests core scaffold independent of venv/templates,
# and sidesteps the .venv symlink (the symlink bug is a Windows-only failure — N/A on Linux).
"$AIPASS" init "$PROJ" demo > /tmp/init.out 2>&1
echo " init exit=$? (see /tmp/init.out)"; tail -3 /tmp/init.out | sed 's/^/ | /'
chk "DEMO_REGISTRY.json exists" "[ -f '$PROJ/DEMO_REGISTRY.json' ]"
chk "DEMO_REGISTRY.json is valid JSON" "jq -e . '$PROJ/DEMO_REGISTRY.json'"
chk "registry metadata.name == DEMO" "[ \"\$(jq -r .metadata.name '$PROJ/DEMO_REGISTRY.json')\" = DEMO ]"
chk ".claude/settings.json exists" "[ -f '$PROJ/.claude/settings.json' ]"
chk "settings deny has EnterPlanMode" "jq -e '.permissions.deny|index(\"EnterPlanMode\")' '$PROJ/.claude/settings.json'"
chk "src/demo/__init__.py exists" "[ -f '$PROJ/src/demo/__init__.py' ]"
chk ".gitignore mentions .venv" "grep -q '.venv' '$PROJ/.gitignore'"
chk ".trinity/ NOT created (projects!=citizens)" "[ ! -e '$PROJ/.trinity' ]"
chk "no passport.json created" "[ ! -e '$PROJ/.trinity/passport.json' ]"
hdr "TIER 2a — synthetic hook fire (module form, sentinel UUID, engine.jsonl)"
HOOKP=/tmp/hookproj; rm -rf "$HOOKP"; mkdir -p "$HOOKP/.aipass"
# minimal isolated config: ONLY rm_gate enabled -> no git_gate/sound noise
cat > "$HOOKP/.aipass/hooks.json" <<'JSON'
{ "hooks_enabled": true,
"PreToolUse": {
"rm_gate": { "enabled": true, "handler": "aipass.hooks.apps.handlers.security.rm_gate.handle", "matcher": "Bash" }
} }
JSON
UUID="PROTOUUID12345"
LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
LOGDIR=$(dirname "$(find "$CLEANENV" -path '*/aipass/hooks' -type d 2>/dev/null | head -1)")
[ -n "$LOG" ] && : > "$LOG" # truncate if present
# fire: rm -rf -> expect block (exit 2)
OUT=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"rm -rf /tmp/x\"},\"agent_id\":\"$UUID\"}" \
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/tmp/hook.err)
HX=$?
# relocate LOG now if it didn't exist before
[ -z "$LOG" ] && LOG=$(find "$CLEANENV" -path '*/aipass/hooks/logs/engine.jsonl' 2>/dev/null | head -1)
printf '%s' "$OUT" > /tmp/hook.out # write to file: never eval-interpolate captured JSON
echo " hook exit=$HX stdout=${OUT:0:80}"
[ -s /tmp/hook.err ] && echo " stderr: $(head -1 /tmp/hook.err)"
# REAL contract (discovered by prototype): rm_gate blocks via {"decision":"block"} on STDOUT, exit 0 — NOT exit 2.
chk "rm_gate decision==block (stdout JSON)" "jq -e '.decision==\"block\"' /tmp/hook.out"
chk "bridge exit 0 (block via JSON not code)" "[ '$HX' = 0 ]"
chk "engine.jsonl exists" "[ -n '$LOG' ] && [ -f '$LOG' ]"
chk "engine.jsonl logged sentinel UUID" "[ -n '$LOG' ] && grep -q '$UUID' '$LOG'"
chk "logged record hook==rm_gate" "[ -n '$LOG' ] && grep '$UUID' '$LOG' | grep -q rm_gate"
# negative: harmless echo -> allow (exit 0)
OUT2=$(cd "$HOOKP" && echo "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":\"echo hi\"},\"agent_id\":\"$UUID-neg\"}" \
| AIPASS_HOME="$HOOKP" "$PY" -m aipass.hooks.apps.handlers.bridges.claude "PreToolUse:rm_gate" 2>/dev/null)
HX2=$?
chk "rm_gate allows echo (exit 0)" "[ '$HX2' = 0 ]"
hdr "TIER 3 — drone routing (against real repo registry)"
chk "drone systems runs (reads registry)" "cd '$SRC' && '$DRONE' systems"
chk "drone systems lists a known branch" "cd '$SRC' && '$DRONE' systems 2>/dev/null | grep -qi seedgo"
chk "drone @drone --help routes" "cd '$SRC' && '$DRONE' @drone --help"
hdr "RESULT"
echo " PASS=$P FAIL=$F"
[ "$F" -eq 0 ] && echo " ALL GREEN" || echo " $F red — that's the truth we wanted"
exit 0
+25
View File
@@ -158,6 +158,31 @@
"standard": "architecture",
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
},
{
"file": "tests/test_rm.py",
"standard": "architecture",
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
},
{
"file": "tests/test_rm.py",
"standard": "encapsulation",
"reason": "Test file imports rm_handler directly to test its public interface. Unit tests require direct access to implementation components."
},
{
"file": "tests/test_rm.py",
"standard": "documentation",
"reason": "Test class docstrings describe intent; per-method docstrings would be noise for assertion-named test methods."
},
{
"file": "tests/test_rm.py",
"standard": "meta",
"reason": "Test file — META blocks are for production source files, not test suites."
},
{
"file": "tests/test_rm.py",
"standard": "trigger",
"reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events."
},
{
"file": "CLAUDE.md",
"standard": "architecture",
+17
View File
@@ -85,6 +85,7 @@ def show_help() -> None:
table.add_row("activate @target", "Register all commands from a branch")
table.add_row("list", "List registered custom commands")
table.add_row("remove <name>", "Remove a custom command")
table.add_row("rm <path> [<path>...]", "Contained safe-delete (project + tmp)")
table.add_row("--help", "Show this help")
table.add_row("--version", "Show version")
@@ -310,6 +311,18 @@ def _handle_remove(name: str) -> int:
return 0 if success else 1
def _handle_rm(args: list[str]) -> int:
"""Handle ``drone rm <path> [<path>...]`` — contained safe-delete."""
from aipass.drone.apps.modules.rm import handle_command, print_help
if not args or args[0] in ("--help", "-h"):
print_help()
return 0
result = handle_command(args[0], args[1:] if len(args) > 1 else None)
return 0 if result else 1
def _handle_custom_command(args: list[str]) -> int:
"""Handle a custom command shortcut by matching and routing.
@@ -557,6 +570,10 @@ def main() -> int:
return 1
return _handle_remove(args[1])
# rm — contained safe-delete
if command == "rm":
return _handle_rm(args[1:])
# @target — route to branch or module
if command.startswith("@"):
return _handle_target(args)
@@ -25,6 +25,19 @@ def list_remote_branches() -> dict:
"""
repo_root = find_repo_root()
# Prune stale remote-tracking refs before listing
try:
prune = subprocess.run(
["git", "fetch", "--prune"],
capture_output=True,
text=True,
cwd=str(repo_root),
)
if prune.returncode != 0:
logger.warning("git fetch --prune failed (listing stale refs): %s", prune.stderr.strip())
except (OSError, subprocess.SubprocessError) as exc:
logger.warning("git fetch --prune unavailable (offline?), listing may include stale refs: %s", exc)
try:
result = subprocess.run(
["git", "branch", "-r"],
@@ -52,3 +65,46 @@ def list_remote_branches() -> dict:
logger.info("Listed %d remote branches", len(branches))
return {"branches": branches, "count": len(branches), "message": f"{len(branches)} remote branches"}
def prune_temp_branches() -> dict:
"""Delete local and remote temp PR branches (citizen/*) that are already merged.
Returns:
Dict with pruned list, count, and message.
"""
repo_root = find_repo_root()
pruned: list[str] = []
try:
merged = subprocess.run(
["git", "branch", "--merged", "main"],
capture_output=True,
text=True,
cwd=str(repo_root),
)
if merged.returncode != 0:
return {"pruned": [], "count": 0, "message": f"git branch --merged failed: {merged.stderr.strip()}"}
for line in merged.stdout.splitlines():
name = line.strip().lstrip("* ")
if name.startswith("citizen/"):
local_del = subprocess.run(
["git", "branch", "-d", name],
capture_output=True,
text=True,
cwd=str(repo_root),
)
if local_del.returncode == 0:
pruned.append(name)
logger.info("Pruned merged temp branch: %s", name)
else:
logger.warning("Failed to delete local branch %s: %s", name, local_del.stderr.strip())
except (OSError, subprocess.SubprocessError) as exc:
logger.error("prune_temp_branches failed: %s", exc)
return {"pruned": [], "count": 0, "message": f"Prune failed: {exc}"}
json_handler.log_operation("prune_temp_branches", {"count": len(pruned)})
msg = f"Pruned {len(pruned)} merged temp branch(es)" if pruned else "No merged temp branches to prune"
return {"pruned": pruned, "count": len(pruned), "message": msg}
@@ -396,3 +396,35 @@ def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]:
logger.warning("get_branch_by_name: AIPass home registry unavailable for '%s': %s", name, exc)
return None
def get_branch_with_registry(name: str) -> Optional[tuple]:
"""Get a branch and the registry path it was found in.
Same two-step lookup as get_branch_by_name (primary then AIPASS_HOME),
but returns (branch_dict, registry_path) so callers can determine
which project root the branch belongs to.
"""
lower_name = name.lower()
try:
primary_path = get_registry_path()
registry = load_registry()
branch = registry.get("branches", {}).get(lower_name)
if branch is not None:
return branch, primary_path
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_branch_with_registry: primary registry unavailable for '%s': %s", name, exc)
primary_path = None
home_path = _get_aipass_home_registry_path()
if home_path is not None and home_path != primary_path:
try:
home_data = _load_registry_data(home_path)
branch = home_data.get("branches", {}).get(lower_name)
if branch is not None:
return branch, home_path
except (RegistryNotFoundError, RegistryCorruptError, RegistryPermissionError) as exc:
logger.warning("get_branch_with_registry: AIPass home registry unavailable for '%s': %s", name, exc)
return None
@@ -0,0 +1,204 @@
# =================== AIPass ====================
# Name: rm_handler.py
# Description: Contained safe-delete handler
# Version: 1.1.0
# Created: 2026-06-02
# Modified: 2026-06-02
# =============================================
"""Contained safe-delete handler.
Deletes paths using shutil.rmtree (directories) or Path.unlink (files),
constrained to project root and system temp directories. Provider-agnostic
alternative to shell ``rm``.
Matches Codex sandbox boundaries: writable = {project, /tmp, $TMPDIR}.
Hard carve-outs protect .git, .trinity, .aipass, .codex, .agents, and
sibling branch worktrees even inside allowed roots.
"""
from __future__ import annotations
import os
import shutil
import tempfile
from pathlib import Path
from aipass.prax import logger
from aipass.drone.apps.handlers.json import json_handler
_CARVEOUT_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents"))
def _find_project_root() -> Path | None:
"""Walk up from CWD to find *_REGISTRY.json; return its parent as project root."""
cwd = Path.cwd()
for parent in [cwd, *cwd.parents]:
if list(parent.glob("*_REGISTRY.json")):
return parent.resolve()
aipass_home = os.environ.get("AIPASS_HOME")
if aipass_home:
home = Path(aipass_home)
if home.is_dir() and list(home.glob("*_REGISTRY.json")):
return home.resolve()
return None
def get_allowed_roots() -> list[Path]:
"""Return resolved roots under which deletion is permitted.
Union of: project root, ``/tmp``, and ``tempfile.gettempdir()`` (which
honors ``$TMPDIR``). Deduplicated by resolved path.
"""
seen: set[Path] = set()
roots: list[Path] = []
project_root = _find_project_root()
if project_root is not None and project_root not in seen:
seen.add(project_root)
roots.append(project_root)
for tmp_candidate in (Path("/tmp"), Path(tempfile.gettempdir())):
resolved = tmp_candidate.resolve()
if resolved not in seen:
seen.add(resolved)
roots.append(resolved)
return roots
def _detect_current_branch(project_root: Path | None) -> str | None:
"""Return the branch name the CWD lives in, or None."""
if project_root is None:
return None
cwd = Path.cwd().resolve()
aipass_src = project_root / "src" / "aipass"
if not cwd.is_relative_to(aipass_src):
return None
rel = cwd.relative_to(aipass_src)
return rel.parts[0] if rel.parts else None
def _resolve_git_dir(path: Path) -> Path | None:
"""If *path* is a ``.git`` file (worktree pointer), return the resolved gitdir."""
try:
if path.is_file():
text = path.read_text(encoding="utf-8", errors="replace").strip()
if text.startswith("gitdir:"):
return Path(text.split(":", 1)[1].strip()).resolve()
except OSError as exc:
logger.warning("Failed to read .git file at %s: %s", path, exc)
return None
def check_carveouts(resolved: Path, project_root: Path | None) -> tuple[bool, str]:
"""Refuse deletion of protected paths even inside allowed roots.
Returns ``(blocked, reason)``. ``blocked=True`` means the path must
NOT be deleted.
"""
parts = resolved.parts
for i, part in enumerate(parts):
if part in _CARVEOUT_DIRS:
return True, f"Protected directory: path is inside {part}/"
if part == ".git":
git_path = Path(*parts[: i + 1]) if i > 0 else Path(part)
real_gitdir = _resolve_git_dir(git_path)
if real_gitdir and resolved.is_relative_to(real_gitdir):
return True, "Protected: path resolves inside .git worktree gitdir"
if project_root is not None:
aipass_src = project_root / "src" / "aipass"
if resolved.is_relative_to(aipass_src):
rel = resolved.relative_to(aipass_src)
if rel.parts:
target_branch = rel.parts[0]
current_branch = _detect_current_branch(project_root)
if current_branch is None or target_branch != current_branch:
return True, (f"Protected: path is inside sibling branch src/aipass/{target_branch}/")
return False, ""
def check_containment(path: Path, roots: list[Path]) -> tuple[bool, str]:
"""Check if *path* (already resolved) is a strict child of any allowed root.
Returns ``(allowed, reason)``. Refuses the root directories themselves.
When multiple roots are nested (e.g. /tmp and /tmp/claude-1000), the path
must not equal ANY root — checked upfront before containment.
"""
root_set = frozenset(roots)
if path in root_set:
return False, f"Refusing to delete root directory itself: {path}"
for root in roots:
if path.is_relative_to(root):
return True, ""
allowed_str = ", ".join(str(r) for r in roots)
return False, (f"Path {path} is outside allowed roots.\n Allowed: {allowed_str}")
def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
"""Delete *paths* with containment checks.
Returns a list of ``(original_path, success, message)`` tuples.
Every path is checked independently; a refused path does not block others.
"""
roots = get_allowed_roots()
if not roots:
return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths]
project_root = _find_project_root()
json_handler.log_operation("rm", {"paths": paths, "roots": [str(r) for r in roots]})
results: list[tuple[str, bool, str]] = []
for path_str in paths:
original = Path(path_str)
absolute = original if original.is_absolute() else (Path.cwd() / original)
exists_on_disk = absolute.exists() or absolute.is_symlink()
if not exists_on_disk:
results.append((path_str, False, f"Path does not exist: {absolute}"))
logger.info("rm: nonexistent path %s", absolute)
continue
resolved = absolute.resolve()
allowed, reason = check_containment(resolved, roots)
if not allowed:
results.append((path_str, False, reason))
logger.warning(
"rm: containment refused %s (resolved %s): %s",
path_str,
resolved,
reason,
)
continue
blocked, carveout_reason = check_carveouts(resolved, project_root)
if blocked:
results.append((path_str, False, carveout_reason))
logger.warning(
"rm: carveout refused %s (resolved %s): %s",
path_str,
resolved,
carveout_reason,
)
continue
try:
if absolute.is_symlink():
absolute.unlink()
elif absolute.is_dir():
shutil.rmtree(absolute)
else:
absolute.unlink()
results.append((path_str, True, f"Deleted: {resolved}"))
logger.info("rm: deleted %s (resolved %s)", path_str, resolved)
except Exception as exc:
results.append((path_str, False, f"Delete failed: {exc}"))
logger.error("rm: delete failed for %s: %s", path_str, exc)
return results
+45 -47
View File
@@ -61,6 +61,7 @@ _COMMANDS = (
"smart-sync",
"fix",
"pr",
"prune-temp",
)
_GH_PASSTHROUGH_COMMANDS = ("issue", "run", "workflow")
@@ -167,6 +168,8 @@ def handle_command(command: str | None = None, args: list[str] | None = None) ->
return _handle_fix(args, caller)
if command == "pr":
return _handle_pr(args)
if command == "prune-temp":
return _handle_prune_temp()
available = ", ".join(_COMMANDS)
return {
@@ -219,6 +222,15 @@ def _handle_branches() -> dict:
return {"stdout": result["message"], "stderr": "", "exit_code": 0}
def _handle_prune_temp() -> dict:
"""Handle the prune-temp subcommand — delete merged citizen/* branches."""
result = branches_handler.prune_temp_branches()
lines = [result["message"]]
for name in result.get("pruned", []):
lines.append(f" deleted: {name}")
return {"stdout": "\n".join(lines), "stderr": "", "exit_code": 0}
def _handle_pr(args: list[str]) -> dict:
"""Handle the pr subcommand — push current branch and create PR to main."""
if not args:
@@ -356,6 +368,8 @@ def _handle_status(args: list[str] | None = None) -> dict:
_, branch_dir = detected
branch_name = detected[0]
if show_all:
repo_root = lock_handler.find_repo_root()
result = status_handler.get_branch_status(repo_root)
@@ -367,6 +381,9 @@ def _handle_status(args: list[str] | None = None) -> dict:
for f in result["files"]:
lines.append(f" {f['status']:>2} {f['path']}")
if not show_all:
lines.append(f"(showing {branch_name} scope — use --all for full repo)")
return {
"stdout": "\n".join(lines),
"stderr": "",
@@ -384,18 +401,18 @@ def _handle_diff(args: list[str]) -> dict:
"exit_code": 1,
}
_, branch_dir = detected
branch_name, branch_dir = detected
staged = "--staged" in args
show_all = "--all" in args
target_dir = lock_handler.find_repo_root() if show_all else branch_dir
result = diff_handler.get_branch_diff(target_dir, staged=staged)
return {
"stdout": result["diff"] if result["diff"] else result["message"],
"stderr": "",
"exit_code": 0,
}
output = result["diff"] if result["diff"] else result["message"]
if not show_all:
output += f"\n(showing {branch_name} scope — use --all for full repo)"
return {"stdout": output, "stderr": "", "exit_code": 0}
def _handle_log(args: list[str]) -> dict:
@@ -610,17 +627,16 @@ def get_help(command: str | None = None) -> str:
return (
"git — Tier-based git workflow (dev branch model)\n"
"\n"
"Global (all branches):\n"
" status Show git status for your branch\n"
" diff [--staged] Show git diff for your branch\n"
" log [count] Show recent git log (default: 10)\n"
" lock Check lock status\n"
" branches List remote branches\n"
" prune-temp Delete merged citizen/* temp branches\n"
" issue [args] Passthrough to gh issue\n"
" run [args] Passthrough to gh run\n"
" workflow [args] Passthrough to gh workflow\n"
"\n"
"Owner (devpulse only):\n"
" commit <msg> [--all | files] Commit changes (selective or --all)\n"
" checkout <main|dev> Switch branches\n"
@@ -642,52 +658,34 @@ def get_introspective() -> str:
"@git — Tier-based git workflow, dev branch model (v3.0.0)\n"
"Connected Handlers:\n"
" handlers/git/\n"
" - lock_handler.py (acquire_lock, release_lock, check_lock_status, force_unlock)\n"
" - status_handler.py (get_branch_status — scoped git status)\n"
" - diff_handler.py (get_branch_diff — scoped git diff)\n"
" - log_handler.py (get_git_log — recent log entries)\n"
" - commit_handler.py (commit_changes — selective files, --all, or pre-staged)\n"
" - checkout_handler.py (checkout_branch — main/dev only)\n"
" - sync_handler.py (sync_main — safe main synchronization)\n"
" - dev_pr_handler.py (create_branch_pr, create_dev_pr — PR to main)\n"
" - branches_handler.py (list_remote_branches)\n"
" - delete_branch_handler.py (delete_remote_branch — protected: main/dev)\n"
" - close_pr_handler.py (close_pr — close PR by number)\n"
"\n"
" - lock_handler.py, status_handler.py, diff_handler.py, log_handler.py\n"
" - commit_handler.py, checkout_handler.py, sync_handler.py\n"
" - dev_pr_handler.py, branches_handler.py, delete_branch_handler.py, close_pr_handler.py\n"
" plugins/devpulse_ops/\n"
" - auth.py (verify_git_access — tier-based authorization)\n"
" - merge_plugin.py (merge_pr — merge PR + sync)\n"
" - sync_plugin.py (smart_sync — fetch + rebase if behind)\n"
" - fix_plugin.py (fix_git_state — detect/fix broken states)\n"
"\n"
" gh passthrough:\n"
" - issue, run, workflow → subprocess gh <cmd> [args]\n"
"\n"
"Access Tiers: global (status, diff, log, lock, branches, issue, run, workflow) | owner (pr, commit, checkout, dev-pr, delete-branch, close-pr, sync, unlock, merge, smart-sync, fix)\n"
" - auth.py, merge_plugin.py, sync_plugin.py, fix_plugin.py\n"
" gh passthrough: issue, run, workflow\n"
"Tiers: global (status,diff,log,lock,branches,prune-temp,issue,run,workflow)"
" | owner (pr,commit,checkout,dev-pr,delete-branch,close-pr,sync,unlock,merge,smart-sync,fix)\n"
)
def _get_console():
try:
from aipass.cli.apps.modules.display import console
return console
except ImportError:
logger.warning("CLI console not available, using fallback")
from rich.console import Console
return Console()
def print_introspection() -> None:
"""Print introspection (seedgo compliance)."""
try:
from aipass.cli.apps.modules.display import console
except ImportError:
logger.warning("CLI console not available, using fallback")
from rich.console import Console
console = Console()
console.print(get_introspective())
_get_console().print(get_introspective())
def print_help() -> None:
"""Print help (seedgo compliance)."""
try:
from aipass.cli.apps.modules.display import console
except ImportError:
logger.warning("CLI console not available, using fallback")
from rich.console import Console
console = Console()
console.print(get_help())
_get_console().print(get_help())
+5 -4
View File
@@ -25,7 +25,7 @@ from aipass.drone.apps.handlers.registry_handler import (
load_registry,
get_all_branches,
get_branch_by_name,
get_registry_path,
get_branch_with_registry,
_validate_branch_path,
)
@@ -156,13 +156,14 @@ def resolve_branch(symbolic_name: str) -> str:
raise BranchNotFoundError(f"Branch name must use @ prefix: '@{symbolic_name}' (got '{symbolic_name}')")
name = normalize_branch_name(symbolic_name).lower()
branch = get_branch_by_name(name)
result = get_branch_with_registry(name)
if branch is None:
if result is None:
raise BranchNotFoundError(f"Branch '{symbolic_name}' not found in registry")
branch, source_registry = result
branch_path = Path(branch["path"])
project_root = get_registry_path().parent
project_root = source_registry.parent
if not branch_path.is_absolute():
branch_path = project_root / branch_path
if not _validate_branch_path(branch_path, project_root, name):
+101
View File
@@ -0,0 +1,101 @@
# =================== AIPass ====================
# Name: rm.py
# Description: Module orchestrator for contained safe-delete
# Version: 1.0.0
# Created: 2026-06-02
# Modified: 2026-06-02
# =============================================
"""Module orchestrator for contained safe-delete.
Thin orchestrator that delegates to rm_handler for path containment
checks and deletion. Provider-agnostic alternative to shell ``rm``.
"""
from __future__ import annotations
from aipass.prax import logger
from aipass.cli.apps.modules import console
from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.rm_handler import (
safe_delete as _safe_delete,
)
DRONE_MODULE = {
"name": "rm",
"version": "1.0.0",
"description": "Contained safe-delete (project + tmp)",
}
def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
"""Delete paths with containment checks.
Returns list of ``(original_path, success, message)`` tuples.
"""
logger.info("rm: requested deletion of %d path(s)", len(paths))
return _safe_delete(paths)
def handle_command(command: str | None = None, args: list[str] | None = None) -> bool:
"""Entry point for ``drone rm`` module routing."""
if not args:
if command is None:
print_introspection()
return True
args = []
if command in ("--help", "-h") or (args and args[0] in ("--help", "-h")):
print_help()
return True
json_handler.log_operation("rm_command", {"command": command, "args": args})
paths: list[str] = []
if command is not None:
paths.append(command)
if args:
paths.extend(args)
if not paths:
print_help()
return True
results = _safe_delete(paths)
ok = True
for _path_str, success, message in results:
if success:
console.print(f"[green]✓[/green] {message}")
else:
console.print(f"[red]✗[/red] {message}")
ok = False
return ok
def print_introspection() -> None:
"""Display module overview (no args)."""
console.print()
console.print("[bold cyan]rm — Contained Safe-Delete[/bold cyan]")
console.print()
console.print("[dim]Deletes files and directories constrained to project root and system tmp.[/dim]")
console.print()
console.print("Run [green]'drone rm --help'[/green] for usage information")
console.print()
def print_help() -> None:
"""Display help (--help flag)."""
console.print("Usage: drone rm <path> [<path>...]")
console.print()
console.print("Contained safe-delete. Removes files and directories using pure Python")
console.print("(shutil.rmtree), constrained to the project root and system temp directory.")
console.print()
console.print("[bold]Rules:[/bold]")
console.print(" • Path must resolve under the project root or system temp dir")
console.print(" • Cannot delete the project root or temp root itself")
console.print(" • Symlinks are resolved; refuses if target escapes allowed roots")
console.print(" • Nonexistent paths produce a clean error")
console.print()
console.print("[bold]Examples:[/bold]")
console.print(" [green]drone rm /tmp/scratch_dir[/green]")
console.print(" [green]drone rm build/ dist/[/green]")
console.print(" [green]drone rm /tmp/aipass_test_abc123[/green]")
@@ -22,6 +22,9 @@ from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.git.lock_handler import find_repo_root
PROTECTED_BRANCHES = ("dev", "main")
def merge_pr(pr_number: str, caller: str) -> dict:
"""Merge a PR and sync local main.
@@ -43,9 +46,30 @@ def merge_pr(pr_number: str, caller: str) -> dict:
}
try:
# Step 1: Merge the PR
# Step 0: Get PR head ref to decide delete-branch behavior
head_proc = subprocess.run(
["gh", "pr", "view", pr_number, "--json", "headRefName", "--jq", ".headRefName"],
capture_output=True,
text=True,
cwd=str(repo_root),
)
head_ref = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
# Step 1: Merge the PR — only delete the head branch when we can
# POSITIVELY confirm it is a non-protected branch. If the head ref is
# unknown (gh lookup failed → empty string), fail SAFE and never delete:
# this is the exact path that destroyed `dev` in S183.
merge_cmd = ["gh", "pr", "merge", pr_number, "--merge"]
if head_ref and head_ref not in PROTECTED_BRANCHES:
merge_cmd.append("--delete-branch")
elif not head_ref:
logger.warning(
"merge_pr: could not determine PR #%s head ref — skipping --delete-branch (fail-safe)",
pr_number,
)
merge = subprocess.run(
["gh", "pr", "merge", pr_number, "--merge", "--delete-branch"],
merge_cmd,
capture_output=True,
text=True,
cwd=str(repo_root),
@@ -126,6 +150,27 @@ def merge_pr(pr_number: str, caller: str) -> dict:
)
title = title_proc.stdout.strip() if title_proc.returncode == 0 else "unknown"
# Step 5: Return to dev branch
current_branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
cwd=str(repo_root),
)
on_branch = current_branch.stdout.strip() if current_branch.returncode == 0 else ""
if on_branch != "dev":
checkout_dev = subprocess.run(
["git", "checkout", "dev"],
capture_output=True,
text=True,
cwd=str(repo_root),
)
if checkout_dev.returncode != 0:
logger.warning(
"merge_pr: WARNING — could not return to dev (on '%s'). Next commit may land on wrong branch!",
on_branch,
)
result["success"] = True
result["title"] = title
result["merge_commit"] = merge_commit
+22 -1
View File
@@ -1,3 +1,11 @@
# =================== AIPass ====================
# Name: cli.py
# Description: Drone CLI entry point — console_scripts wrapper
# Version: 1.0.0
# Created: 2026-03-05
# Modified: 2026-06-04
# =============================================
"""
Drone CLI — command-line interface for aipass.drone.
@@ -19,8 +27,21 @@ import sys
# Windows terminals default to cp1252 which can't encode Rich's Unicode
# characters (box-drawing, em dashes, arrows). Force UTF-8 before any
# imports that trigger Rich output.
#
# PYTHONUTF8 only affects *child* interpreters launched afterward — it does
# nothing for this process's already-open stdout/stderr, which were created
# with the cp1252 codec at interpreter startup. Rich writes through those
# live streams, so we must reconfigure them in place (Python 3.7+). Without
# this, `drone @branch` crashes with UnicodeEncodeError ('charmap') when it
# prints a routed branch's captured output on Windows.
if sys.platform == "win32":
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONUTF8", "1") # for child subprocesses
for _stream in (sys.stdout, sys.stderr):
# getattr guard: streams replaced by a capture layer (e.g. pytest) or
# not backed by a TextIOWrapper simply lack reconfigure — skip them.
_reconfigure = getattr(_stream, "reconfigure", None)
if _reconfigure is not None:
_reconfigure(encoding="utf-8", errors="replace")
from aipass.drone.apps.drone import main as _drone_main
+383 -1
View File
@@ -842,9 +842,14 @@ def _run_merge_success(cmd: list[str], **kwargs: object) -> MagicMock:
r.stderr = ""
r.stdout = ""
if cmd[0] == "gh" and cmd[1] == "pr" and cmd[2] == "view":
r.stdout = "Fix the thing\n"
if "--jq" in cmd and ".headRefName" in cmd:
r.stdout = "citizen/test-branch\n"
else:
r.stdout = "Fix the thing\n"
elif cmd[1:3] == ["rev-parse", "HEAD"]:
r.stdout = "abc123def456\n"
elif cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.stdout = "dev\n"
return r
@@ -909,3 +914,380 @@ class TestTriggerFireIntegration:
assert result["success"] is True
mock_trigger.fire.assert_any_call("pr_merged", pr_number="42", title="Fix the thing")
# ===========================================================================
# Fix 1 & 2: Protected-branch merge + return-to-dev (#625)
# ===========================================================================
_MERGE_MOD = "aipass.drone.apps.plugins.devpulse_ops.merge_plugin"
def _merge_side_effect(head_ref: str, current_branch: str = "main"):
"""Build a subprocess mock for merge_pr with configurable head ref."""
def _run(cmd: list[str], **kwargs: object) -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = ""
if cmd[0] == "gh" and "view" in cmd:
if ".headRefName" in cmd:
r.stdout = f"{head_ref}\n"
else:
r.stdout = "PR Title\n"
elif cmd[1:3] == ["rev-parse", "HEAD"]:
r.stdout = "abc123\n"
elif cmd[1:3] == ["rev-parse", "--abbrev-ref"]:
r.stdout = f"{current_branch}\n"
elif cmd[1:3] == ["checkout", "dev"]:
r.returncode = 0
return r
return _run
class TestMergeProtectedBranch:
"""Fix 1: --delete-branch omitted for protected branches (dev, main)."""
def test_dev_head_no_delete_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When PR head is dev, merge command must NOT include --delete-branch."""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
return _merge_side_effect("dev", "dev")(cmd, **kw)
with patch(f"{_MERGE_MOD}.subprocess.run", side_effect=_capture):
result = merge_pr("10", "devpulse")
assert result["success"] is True
merge_calls = [c for c in calls if c[:3] == ["gh", "pr", "merge"]]
assert len(merge_calls) == 1
assert "--delete-branch" not in merge_calls[0]
def test_temp_branch_has_delete_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When PR head is a temp branch, merge command includes --delete-branch."""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
return _merge_side_effect("citizen/feature-x", "dev")(cmd, **kw)
with patch(f"{_MERGE_MOD}.subprocess.run", side_effect=_capture):
result = merge_pr("20", "devpulse")
assert result["success"] is True
merge_calls = [c for c in calls if c[:3] == ["gh", "pr", "merge"]]
assert "--delete-branch" in merge_calls[0]
def test_unknown_head_ref_fails_safe_no_delete(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When the PR head ref can't be determined (empty), fail SAFE: never delete.
Guards the exact path that destroyed `dev` in S183 — if gh can't report
the head ref we must not fall back to deleting the branch.
"""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
return _merge_side_effect("", "dev")(cmd, **kw)
with patch(f"{_MERGE_MOD}.subprocess.run", side_effect=_capture):
result = merge_pr("30", "devpulse")
assert result["success"] is True
merge_calls = [c for c in calls if c[:3] == ["gh", "pr", "merge"]]
assert len(merge_calls) == 1
assert "--delete-branch" not in merge_calls[0]
class TestMergeReturnToDev:
"""Fix 2: After merge+sync, checkout dev (or warn if can't)."""
def test_checkout_dev_after_merge(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""merge_pr issues 'git checkout dev' when not already on dev."""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
return _merge_side_effect("citizen/x", "main")(cmd, **kw)
with patch(f"{_MERGE_MOD}.subprocess.run", side_effect=_capture):
result = merge_pr("30", "devpulse")
assert result["success"] is True
checkout_calls = [c for c in calls if c[1:3] == ["checkout", "dev"]]
assert len(checkout_calls) == 1
def test_no_checkout_when_already_on_dev(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""merge_pr skips checkout dev when already on dev."""
from aipass.drone.apps.plugins.devpulse_ops.merge_plugin import merge_pr
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
return _merge_side_effect("citizen/y", "dev")(cmd, **kw)
with patch(f"{_MERGE_MOD}.subprocess.run", side_effect=_capture):
result = merge_pr("31", "devpulse")
assert result["success"] is True
checkout_calls = [c for c in calls if c[1:3] == ["checkout", "dev"]]
assert len(checkout_calls) == 0
# ===========================================================================
# Fix 3: Live-remote branches — fetch --prune before listing (#625)
# ===========================================================================
_BRANCHES_MOD = "aipass.drone.apps.handlers.git.branches_handler"
class TestBranchesFetchPrune:
"""Fix 3: list_remote_branches runs fetch --prune before git branch -r."""
def test_fetch_prune_before_list(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""fetch --prune is called before git branch -r."""
from aipass.drone.apps.handlers.git.branches_handler import list_remote_branches
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
calls: list[list[str]] = []
def _capture(cmd: list[str], **kw: object) -> MagicMock:
calls.append(list(cmd))
r = MagicMock()
r.returncode = 0
r.stderr = ""
r.stdout = " origin/main\n origin/dev\n"
return r
with patch(f"{_BRANCHES_MOD}.subprocess.run", side_effect=_capture):
result = list_remote_branches()
assert result["count"] == 2
cmd_summaries = [" ".join(c[:3]) for c in calls]
assert "git fetch --prune" in cmd_summaries
prune_idx = cmd_summaries.index("git fetch --prune")
branch_idx = cmd_summaries.index("git branch -r")
assert prune_idx < branch_idx
def test_deleted_branch_not_listed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""After prune, deleted remote branches do not appear."""
from aipass.drone.apps.handlers.git.branches_handler import list_remote_branches
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def _run(cmd: list[str], **kw: object) -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stderr = ""
if cmd[1:3] == ["branch", "-r"]:
r.stdout = " origin/main\n origin/dev\n"
else:
r.stdout = ""
return r
with patch(f"{_BRANCHES_MOD}.subprocess.run", side_effect=_run):
result = list_remote_branches()
assert "deleted-branch" not in result["branches"]
assert result["branches"] == ["main", "dev"]
def test_offline_graceful(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When fetch --prune fails (offline), listing still works with warning."""
from aipass.drone.apps.handlers.git.branches_handler import list_remote_branches
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
call_count = {"prune": 0}
def _run(cmd: list[str], **kw: object) -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stderr = ""
if cmd[1:3] == ["fetch", "--prune"]:
call_count["prune"] += 1
r.returncode = 1
r.stderr = "fatal: Could not read from remote repository."
elif cmd[1:3] == ["branch", "-r"]:
r.stdout = " origin/main\n"
else:
r.stdout = ""
return r
with patch(f"{_BRANCHES_MOD}.subprocess.run", side_effect=_run):
result = list_remote_branches()
assert call_count["prune"] == 1
assert result["count"] == 1
assert result["branches"] == ["main"]
# ===========================================================================
# Fix 4: Temp-branch hygiene — prune_temp_branches (#625)
# ===========================================================================
class TestPruneTempBranches:
"""Fix 4: prune_temp_branches deletes merged citizen/* branches."""
def test_prunes_merged_citizen_branches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Merged citizen/* branches are deleted."""
from aipass.drone.apps.handlers.git.branches_handler import prune_temp_branches
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def _run(cmd: list[str], **kw: object) -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stderr = ""
if cmd[1:3] == ["branch", "--merged"]:
r.stdout = " main\n dev\n citizen/drone-fix\n citizen/seedgo-pr\n"
elif cmd[1:3] == ["branch", "-d"]:
r.stdout = f"Deleted branch {cmd[3]}\n"
return r
with patch(f"{_BRANCHES_MOD}.subprocess.run", side_effect=_run):
result = prune_temp_branches()
assert result["count"] == 2
assert "citizen/drone-fix" in result["pruned"]
assert "citizen/seedgo-pr" in result["pruned"]
def test_skips_non_citizen_branches(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Non-citizen branches (main, dev, feature/*) are not pruned."""
from aipass.drone.apps.handlers.git.branches_handler import prune_temp_branches
registry = tmp_path / "AIPASS_REGISTRY.json"
registry.write_text("{}", encoding="utf-8")
monkeypatch.chdir(tmp_path)
def _run(cmd: list[str], **kw: object) -> MagicMock:
r = MagicMock()
r.returncode = 0
r.stderr = ""
if cmd[1:3] == ["branch", "--merged"]:
r.stdout = "* main\n dev\n feature/old\n"
return r
with patch(f"{_BRANCHES_MOD}.subprocess.run", side_effect=_run):
result = prune_temp_branches()
assert result["count"] == 0
assert result["pruned"] == []
# ===========================================================================
# Fix 5: Scope clarity footer on status/diff (#623)
# ===========================================================================
_GIT_MOD = "aipass.drone.apps.modules.git_module"
_AUTH = "aipass.drone.apps.plugins.devpulse_ops.auth.verify_git_access"
class TestScopeFooter:
"""Fix 5: Scoped status/diff shows footer, --all does not."""
@patch(_AUTH, return_value="test_branch")
def test_status_scoped_shows_footer(
self, _mock_auth: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Scoped status output includes scope footer."""
monkeypatch.chdir(tmp_path)
with patch(f"{_GIT_MOD}._detect_branch_dir", return_value=("drone", tmp_path / "src" / "drone")):
with patch(
f"{_GIT_MOD}.status_handler.get_branch_status",
return_value={"files": [], "total": 0, "message": "0 file(s) changed under src/drone"},
):
result = handle_command("status", [])
assert "(showing drone scope" in result["stdout"]
assert "--all for full repo)" in result["stdout"]
@patch(_AUTH, return_value="test_branch")
def test_status_all_no_footer(self, _mock_auth: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--all status output does NOT include scope footer."""
monkeypatch.chdir(tmp_path)
with patch(f"{_GIT_MOD}._detect_branch_dir", return_value=("drone", tmp_path / "src" / "drone")):
with patch(f"{_GIT_MOD}.lock_handler.find_repo_root", return_value=tmp_path):
with patch(
f"{_GIT_MOD}.status_handler.get_branch_status",
return_value={"files": [], "total": 0, "message": "0 file(s) changed in repo"},
):
result = handle_command("status", ["--all"])
assert "showing drone scope" not in result["stdout"]
@patch(_AUTH, return_value="test_branch")
def test_diff_scoped_shows_footer(
self, _mock_auth: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Scoped diff output includes scope footer."""
monkeypatch.chdir(tmp_path)
with patch(f"{_GIT_MOD}._detect_branch_dir", return_value=("drone", tmp_path / "src" / "drone")):
with patch(
f"{_GIT_MOD}.diff_handler.get_branch_diff",
return_value={"diff": "", "files_changed": 0, "message": "0 file(s) changed"},
):
result = handle_command("diff", [])
assert "(showing drone scope" in result["stdout"]
@patch(_AUTH, return_value="test_branch")
def test_diff_all_no_footer(self, _mock_auth: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--all diff output does NOT include scope footer."""
monkeypatch.chdir(tmp_path)
with patch(f"{_GIT_MOD}._detect_branch_dir", return_value=("drone", tmp_path / "src" / "drone")):
with patch(f"{_GIT_MOD}.lock_handler.find_repo_root", return_value=tmp_path):
with patch(
f"{_GIT_MOD}.diff_handler.get_branch_diff",
return_value={"diff": "some diff", "files_changed": 1, "message": "1 file(s)"},
):
result = handle_command("diff", ["--all"])
assert "showing drone scope" not in result["stdout"]
+92
View File
@@ -447,3 +447,95 @@ class TestResolverPathContainment:
assert "legit" in result
finally:
reset_registry_path()
# ===========================================================================
# Cross-project resolution (issue #618)
# ===========================================================================
class TestCrossProjectResolution:
"""resolve_branch() uses the source registry root for containment, not always the primary."""
def test_cross_project_resolves_via_aipass_home(self, tmp_path: Path, monkeypatch):
"""Branch found via AIPASS_HOME resolves even when primary registry is a different project."""
# External project with its own registry
ext_project = tmp_path / "external_project"
ext_project.mkdir()
ext_reg = ext_project / "EXT_REGISTRY.json"
_write_registry(ext_reg, [_make_branch("local_branch", "src/local_branch")])
# AIPass project with @target branch
aipass_root = tmp_path / "aipass"
target_dir = aipass_root / "src" / "aipass" / "target"
target_dir.mkdir(parents=True)
aipass_reg = aipass_root / "AIPASS_REGISTRY.json"
_write_registry(
aipass_reg,
[_make_branch("target", str(target_dir))],
)
# Primary registry = external project, AIPASS_HOME = aipass root
set_registry_path(ext_reg)
monkeypatch.setenv("AIPASS_HOME", str(aipass_root))
try:
result = resolve_branch("@target")
assert str(target_dir) in result
finally:
reset_registry_path()
def test_genuine_escape_still_blocked(self, tmp_path: Path, monkeypatch):
"""Branch whose path escapes its OWN declaring registry root is still blocked."""
ext_project = tmp_path / "external_project"
ext_project.mkdir()
ext_reg = ext_project / "EXT_REGISTRY.json"
_write_registry(ext_reg, [])
aipass_root = tmp_path / "aipass"
aipass_root.mkdir()
aipass_reg = aipass_root / "AIPASS_REGISTRY.json"
_write_registry(
aipass_reg,
[_make_branch("evil", "../../../tmp/escape")],
)
set_registry_path(ext_reg)
monkeypatch.setenv("AIPASS_HOME", str(aipass_root))
try:
with pytest.raises(BranchNotFoundError):
resolve_branch("@evil")
finally:
reset_registry_path()
def test_same_project_regression(self, tmp_path: Path, monkeypatch):
"""Same-project resolution still works (no regression)."""
branch_dir = tmp_path / "src" / "aipass" / "mybranch"
branch_dir.mkdir(parents=True)
reg_file = tmp_path / "AIPASS_REGISTRY.json"
_write_registry(
reg_file,
[_make_branch("mybranch", "src/aipass/mybranch")],
)
set_registry_path(reg_file)
monkeypatch.delenv("AIPASS_HOME", raising=False)
try:
result = resolve_branch("@mybranch")
assert "mybranch" in result
finally:
reset_registry_path()
def test_branch_exists_still_works(self, tmp_path: Path, monkeypatch):
"""branch_exists() is unaffected by the get_branch_with_registry change."""
branch_dir = tmp_path / "src" / "aipass" / "alpha"
branch_dir.mkdir(parents=True)
reg_file = tmp_path / "AIPASS_REGISTRY.json"
_write_registry(reg_file, [_make_branch("alpha", "src/aipass/alpha")])
set_registry_path(reg_file)
monkeypatch.delenv("AIPASS_HOME", raising=False)
try:
assert branch_exists("@alpha") is True
assert branch_exists("@nonexistent") is False
finally:
reset_registry_path()
+571
View File
@@ -0,0 +1,571 @@
"""Tests for drone rm — contained safe-delete.
Red-team containment tests verify that paths outside allowed roots
are refused, including symlink escapes and traversal attempts.
Carve-out tests verify .git, .trinity, .aipass, .codex, .agents,
and sibling branches are protected even inside allowed roots.
"""
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
from aipass.drone.apps.handlers.rm_handler import (
check_carveouts,
check_containment,
get_allowed_roots,
safe_delete,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def project_dir(tmp_path):
"""Fake project root with a registry file and src/aipass layout."""
(tmp_path / "AIPASS_REGISTRY.json").write_text("{}")
return tmp_path
@pytest.fixture()
def project_with_branches(project_dir):
"""Project root with src/aipass/<branch> layout for sibling tests."""
for branch in ("drone", "api", "flow"):
d = project_dir / "src" / "aipass" / branch
d.mkdir(parents=True)
(d / "README.md").write_text(f"# {branch}")
return project_dir
@pytest.fixture()
def _patch_roots(project_dir):
"""Patch get_allowed_roots to use deterministic test roots."""
tmpdir = Path(tempfile.gettempdir()).resolve()
slash_tmp = Path("/tmp").resolve()
roots = [project_dir.resolve()]
seen = set(roots)
for r in (slash_tmp, tmpdir):
if r not in seen:
seen.add(r)
roots.append(r)
with patch(
"aipass.drone.apps.handlers.rm_handler.get_allowed_roots",
return_value=roots,
):
yield
# ---------------------------------------------------------------------------
# get_allowed_roots
# ---------------------------------------------------------------------------
class TestGetAllowedRoots:
def test_includes_temp_dir(self):
roots = get_allowed_roots()
tmpdir = Path(tempfile.gettempdir()).resolve()
assert tmpdir in roots
def test_includes_slash_tmp(self):
roots = get_allowed_roots()
assert Path("/tmp").resolve() in roots
def test_includes_project_root_when_in_project(self, project_dir, monkeypatch):
monkeypatch.chdir(project_dir)
roots = get_allowed_roots()
assert project_dir.resolve() in roots
def test_temp_dir_always_present_even_without_project(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
roots = get_allowed_roots()
tmpdir = Path(tempfile.gettempdir()).resolve()
assert tmpdir in roots
def test_tmpdir_and_slash_tmp_both_present_when_different(self, monkeypatch):
"""When $TMPDIR != /tmp, both must appear in roots."""
fake_tmpdir = "/tmp/claude-9999"
os.makedirs(fake_tmpdir, exist_ok=True)
try:
monkeypatch.setenv("TMPDIR", fake_tmpdir)
tempfile.tempdir = None
roots = get_allowed_roots()
resolved_roots = {r for r in roots}
assert Path("/tmp").resolve() in resolved_roots
assert Path(fake_tmpdir).resolve() in resolved_roots
finally:
tempfile.tempdir = None
def test_roots_are_deduplicated(self):
roots = get_allowed_roots()
assert len(roots) == len(set(roots))
# ---------------------------------------------------------------------------
# check_containment
# ---------------------------------------------------------------------------
class TestCheckContainment:
def test_allows_child_of_root(self, tmp_path):
root = tmp_path.resolve()
child = (tmp_path / "sub" / "file.txt").resolve()
allowed, reason = check_containment(child, [root])
assert allowed is True
assert reason == ""
def test_refuses_root_itself(self, tmp_path):
root = tmp_path.resolve()
allowed, reason = check_containment(root, [root])
assert allowed is False
assert "root directory itself" in reason
def test_refuses_outside_path(self, tmp_path):
root = tmp_path.resolve()
outside = Path("/etc/passwd").resolve()
allowed, reason = check_containment(outside, [root])
assert allowed is False
assert "outside allowed roots" in reason
def test_allows_second_root(self, tmp_path):
root1 = (tmp_path / "a").resolve()
root2 = (tmp_path / "b").resolve()
child = (tmp_path / "b" / "file.txt").resolve()
allowed, _ = check_containment(child, [root1, root2])
assert allowed is True
def test_refuses_empty_roots(self, tmp_path):
child = (tmp_path / "file.txt").resolve()
allowed, _reason = check_containment(child, [])
assert allowed is False
# ---------------------------------------------------------------------------
# ALLOW: valid deletions
# ---------------------------------------------------------------------------
class TestAllowDeletion:
@pytest.mark.usefixtures("_patch_roots")
def test_delete_dir_in_tmp(self):
target = Path(tempfile.mkdtemp())
try:
(target / "file.txt").write_text("data")
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
finally:
if target.exists():
shutil.rmtree(target)
@pytest.mark.usefixtures("_patch_roots")
def test_delete_nested_tmp_dir(self):
"""e.g. /tmp/claude-1000/<x>."""
parent = Path(tempfile.mkdtemp())
target = parent / "nested"
target.mkdir()
(target / "data.txt").write_text("hello")
try:
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
finally:
if parent.exists():
shutil.rmtree(parent)
@pytest.mark.usefixtures("_patch_roots")
def test_delete_file_in_project(self, project_dir):
target = project_dir / "build" / "output.o"
target.parent.mkdir(parents=True)
target.write_text("binary")
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_delete_subdir_in_project(self, project_dir):
target = project_dir / "sub" / "scratch"
target.mkdir(parents=True)
(target / "temp.txt").write_text("scratch")
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_delete_multiple_paths(self):
t1 = Path(tempfile.mkdtemp())
t2 = Path(tempfile.mkdtemp())
try:
results = safe_delete([str(t1), str(t2)])
assert all(r[1] for r in results)
assert not t1.exists()
assert not t2.exists()
finally:
for t in [t1, t2]:
if t.exists():
shutil.rmtree(t)
@pytest.mark.usefixtures("_patch_roots")
def test_pure_python_no_subprocess(self):
"""Verify shutil.rmtree is used, not subprocess rm."""
target = Path(tempfile.mkdtemp())
(target / "f.txt").write_text("x")
with patch("subprocess.run") as mock_run, patch("subprocess.Popen") as mock_popen:
results = safe_delete([str(target)])
assert results[0][1] is True
mock_run.assert_not_called()
mock_popen.assert_not_called()
@pytest.mark.usefixtures("_patch_roots")
def test_project_build_dir_allowed(self, project_dir):
"""Regression guard: ordinary project dirs are still deletable."""
target = project_dir / "build"
target.mkdir()
(target / "out.js").write_text("x")
results = safe_delete([str(target)])
assert results[0][1] is True
@pytest.mark.usefixtures("_patch_roots")
def test_project_dist_dir_allowed(self, project_dir):
"""Regression guard: dist/ is not a carve-out."""
target = project_dir / "dist"
target.mkdir()
(target / "bundle.js").write_text("x")
results = safe_delete([str(target)])
assert results[0][1] is True
@pytest.mark.usefixtures("_patch_roots")
def test_slash_tmp_literal_allowed(self):
"""/tmp/<x> must succeed even if $TMPDIR differs."""
target = Path("/tmp") / f"rm_test_{os.getpid()}"
target.mkdir(exist_ok=True)
try:
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
finally:
if target.exists():
shutil.rmtree(target)
@pytest.mark.usefixtures("_patch_roots")
def test_tmpdir_env_allowed(self):
"""$TMPDIR/<x> must succeed."""
target = Path(tempfile.mkdtemp())
try:
results = safe_delete([str(target)])
assert results[0][1] is True
assert not target.exists()
finally:
if target.exists():
shutil.rmtree(target)
# ---------------------------------------------------------------------------
# REFUSE: red-team containment
# ---------------------------------------------------------------------------
class TestRefuseDeletion:
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_home_dir(self):
results = safe_delete([str(Path.home())])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_etc(self):
results = safe_delete(["/etc"])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_root_filesystem(self):
results = safe_delete(["/"])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_project_root_itself(self, project_dir):
results = safe_delete([str(project_dir)])
assert results[0][1] is False
assert "root directory itself" in results[0][2]
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_tmp_root_itself(self):
tmpdir = tempfile.gettempdir()
results = safe_delete([tmpdir])
assert results[0][1] is False
assert "root directory itself" in results[0][2]
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_traversal_escape(self, project_dir, monkeypatch):
"""../../etc from inside project should resolve outside and be refused."""
subdir = project_dir / "deep" / "nested"
subdir.mkdir(parents=True)
monkeypatch.chdir(subdir)
results = safe_delete(["../../../../../../etc"])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_absolute_outside_roots(self):
results = safe_delete(["/usr/local/bin"])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_symlink_escape_from_tmp(self):
"""Symlink under /tmp pointing to /home/user should be refused."""
target_outside = Path.home()
link_dir = Path(tempfile.mkdtemp())
link = link_dir / "escape_link"
try:
link.symlink_to(target_outside)
results = safe_delete([str(link)])
assert results[0][1] is False
finally:
if link.exists() or link.is_symlink():
link.unlink()
if link_dir.exists():
shutil.rmtree(link_dir)
@pytest.mark.usefixtures("_patch_roots")
def test_nonexistent_path_clean_error(self):
results = safe_delete(["/tmp/this_path_does_not_exist_abc123xyz"])
assert results[0][1] is False
assert "does not exist" in results[0][2]
@pytest.mark.usefixtures("_patch_roots")
def test_mixed_valid_and_invalid(self, project_dir):
"""Valid paths succeed; invalid paths fail independently."""
valid = project_dir / "ok_to_delete"
valid.mkdir()
results = safe_delete([str(valid), "/etc/shadow"])
assert results[0][1] is True
assert results[1][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_home_patrick(self):
results = safe_delete(["/home/patrick"])
assert results[0][1] is False
@pytest.mark.usefixtures("_patch_roots")
def test_refuse_var_tmp(self):
"""/var/tmp is NOT in the default allowed set (Codex excludes it)."""
results = safe_delete(["/var/tmp"])
assert results[0][1] is False
# ---------------------------------------------------------------------------
# Carve-outs: .git, .trinity, .aipass, .codex, .agents, siblings
# ---------------------------------------------------------------------------
class TestCarveouts:
def test_refuse_dot_git_dir(self, project_dir):
"""<repo>/.git directory must be refused."""
git_dir = project_dir / ".git"
git_dir.mkdir()
resolved = git_dir.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".git" in reason
def test_refuse_inside_dot_git(self, project_dir):
"""Files inside .git/ must be refused."""
git_dir = project_dir / ".git" / "objects"
git_dir.mkdir(parents=True)
resolved = git_dir.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".git" in reason
def test_refuse_dot_trinity(self, project_dir):
trinity = project_dir / ".trinity"
trinity.mkdir()
resolved = trinity.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".trinity" in reason
def test_refuse_inside_dot_trinity(self, project_dir):
passport = project_dir / ".trinity" / "passport.json"
passport.parent.mkdir(parents=True)
passport.write_text("{}")
resolved = passport.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".trinity" in reason
def test_refuse_dot_aipass(self, project_dir):
aipass_dir = project_dir / ".aipass"
aipass_dir.mkdir()
resolved = aipass_dir.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".aipass" in reason
def test_refuse_dot_codex(self, project_dir):
codex = project_dir / ".codex"
codex.mkdir()
resolved = codex.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".codex" in reason
def test_refuse_dot_agents(self, project_dir):
agents = project_dir / ".agents"
agents.mkdir()
resolved = agents.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".agents" in reason
def test_allow_normal_project_dir(self, project_dir):
"""build/ is not a carve-out."""
build = project_dir / "build"
build.mkdir()
resolved = build.resolve()
blocked, _reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is False
def test_refuse_sibling_branch(self, project_with_branches, monkeypatch):
"""From drone CWD, deleting src/aipass/api must be refused."""
drone_dir = project_with_branches / "src" / "aipass" / "drone"
monkeypatch.chdir(drone_dir)
target = project_with_branches / "src" / "aipass" / "api"
resolved = target.resolve()
blocked, reason = check_carveouts(resolved, project_with_branches.resolve())
assert blocked is True
assert "sibling branch" in reason
assert "api" in reason
def test_allow_own_branch(self, project_with_branches, monkeypatch):
"""Deleting a subdir inside own branch must be allowed."""
drone_dir = project_with_branches / "src" / "aipass" / "drone"
monkeypatch.chdir(drone_dir)
target = drone_dir / "build"
target.mkdir()
resolved = target.resolve()
blocked, _reason = check_carveouts(resolved, project_with_branches.resolve())
assert blocked is False
def test_refuse_all_branches_when_outside(self, project_with_branches, monkeypatch):
"""When CWD isn't inside any branch, ALL src/aipass/<branch> are refused."""
monkeypatch.chdir(project_with_branches)
for branch in ("drone", "api", "flow"):
target = project_with_branches / "src" / "aipass" / branch
resolved = target.resolve()
blocked, _reason = check_carveouts(resolved, project_with_branches.resolve())
assert blocked is True, f"Expected {branch} to be blocked"
def test_git_file_worktree_pointer(self, project_dir):
"""A .git FILE (worktree pointer) should protect the resolved gitdir."""
real_git = project_dir / "real_git_dir"
real_git.mkdir()
git_file = project_dir / ".git"
git_file.write_text(f"gitdir: {real_git}")
resolved = git_file.resolve()
blocked, reason = check_carveouts(resolved, project_dir.resolve())
assert blocked is True
assert ".git" in reason
# ---------------------------------------------------------------------------
# Carve-outs via safe_delete (integration)
# ---------------------------------------------------------------------------
class TestCarveoutIntegration:
@pytest.mark.usefixtures("_patch_roots")
def test_safe_delete_refuses_dot_git(self, project_dir):
git_dir = project_dir / ".git"
git_dir.mkdir()
results = safe_delete([str(git_dir)])
assert results[0][1] is False
assert ".git" in results[0][2]
assert git_dir.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_safe_delete_refuses_trinity(self, project_dir):
trinity = project_dir / ".trinity"
trinity.mkdir()
results = safe_delete([str(trinity)])
assert results[0][1] is False
assert trinity.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_safe_delete_refuses_dot_aipass(self, project_dir):
aipass_dir = project_dir / ".aipass"
aipass_dir.mkdir()
results = safe_delete([str(aipass_dir)])
assert results[0][1] is False
assert aipass_dir.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_safe_delete_allows_build_dir(self, project_dir):
"""Regression guard: build/ and dist/ still allowed."""
build = project_dir / "build"
build.mkdir()
results = safe_delete([str(build)])
assert results[0][1] is True
assert not build.exists()
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
@pytest.mark.usefixtures("_patch_roots")
def test_relative_path_resolved_from_cwd(self, project_dir, monkeypatch):
monkeypatch.chdir(project_dir)
target = project_dir / "relative_target"
target.mkdir()
results = safe_delete(["relative_target"])
assert results[0][1] is True
assert not target.exists()
@pytest.mark.usefixtures("_patch_roots")
def test_single_file_deletion(self):
fd, path = tempfile.mkstemp()
os.close(fd)
results = safe_delete([path])
assert results[0][1] is True
assert not Path(path).exists()
@pytest.mark.usefixtures("_patch_roots")
def test_empty_paths_list(self):
results = safe_delete([])
assert results == []
# ---------------------------------------------------------------------------
# Module orchestrator (rm.py)
# ---------------------------------------------------------------------------
class TestRmModule:
def test_handle_command_help(self):
from aipass.drone.apps.modules.rm import handle_command
result = handle_command("--help")
assert result is True
def test_handle_command_introspection(self):
from aipass.drone.apps.modules.rm import handle_command
result = handle_command(None, None)
assert result is True
def test_print_introspection(self):
from aipass.drone.apps.modules.rm import print_introspection
print_introspection()
def test_print_help(self):
from aipass.drone.apps.modules.rm import print_help
print_help()
@@ -9,14 +9,13 @@
"""
Push to Plans Central Handler
Pushes Flow's plan data to the central PLANS.central.json file at .ai_central.
Pushes plan data for ALL branches to the central PLANS.central.json file at .ai_central.
This handler follows the 3-tier logging standard (no Prax imports, no logging).
Features:
- Reads fplan_registry.json to get Flow's plans
- Extracts only plans where location='flow' (Flow's own plans)
- Updates branches.flow section in PLANS.central.json
- Preserves all other branch sections
- Reads all per-type plan registries
- Groups plans by branch (derived from location path)
- Updates per-branch sections in PLANS.central.json
- Calls aggregate_central_impl to rebuild top-level active_plans
- Calculates global statistics across all branches
- Pure handler - returns boolean for success/failure
@@ -124,26 +123,29 @@ def _load_registry() -> Dict[str, Any]:
return merged
def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict]]:
"""Extract Flow's own plans from registry
def _extract_plans_by_branch(registry: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""Extract plans from registry grouped by branch.
Args:
registry: The fplan_registry.json data
registry: Merged registry data (all plan types)
Returns:
Tuple of (active_plans, recently_closed_plans)
Dict mapping branch_name -> branch section with active_plans,
recently_closed, and statistics.
"""
plans = registry.get("plans", {})
active = []
closed = []
branch_buckets: Dict[str, Dict[str, List]] = {}
for plan_key, plan_data in plans.items():
# Only include plans where location is 'flow' (Flow's own plans)
location = plan_data.get("location", "")
if location != str(FLOW_ROOT):
if not location:
continue
# plan_key is composite PREFIX-NNNN from merged registry
branch_name = Path(location).name
if branch_name not in branch_buckets:
branch_buckets[branch_name] = {"active": [], "closed": [], "location": location}
plan_entry = {
"plan_id": plan_key,
"subject": plan_data.get("subject", ""),
@@ -151,24 +153,35 @@ def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict
"created": plan_data.get("created", ""),
"file_path": plan_data.get("file_path", ""),
"relative_path": plan_data.get("relative_path", ""),
"branch": branch_name,
}
if plan_data.get("status") == "open":
active.append(plan_entry)
branch_buckets[branch_name]["active"].append(plan_entry)
else:
# Add closed metadata
plan_entry["closed"] = plan_data.get("closed", "")
plan_entry["closed_reason"] = plan_data.get("closed_reason", "")
closed.append(plan_entry)
branch_buckets[branch_name]["closed"].append(plan_entry)
# Sort active by created date (newest first)
active.sort(key=lambda x: x.get("created", ""), reverse=True)
result: Dict[str, Dict[str, Any]] = {}
for branch_name, bucket in branch_buckets.items():
active = sorted(bucket["active"], key=lambda x: x.get("created", ""), reverse=True)
closed = sorted(bucket["closed"], key=lambda x: x.get("closed", ""), reverse=True)
recently_closed = closed[:5]
# Sort closed by closed date (newest first) and limit to last 5
closed.sort(key=lambda x: x.get("closed", ""), reverse=True)
recently_closed = closed[:5]
result[branch_name] = {
"branch_name": branch_name.upper(),
"branch_path": bucket["location"],
"active_plans": active,
"recently_closed": recently_closed,
"statistics": {
"active_count": len(active),
"total_closed": len(closed),
"recently_closed_included": len(recently_closed),
},
}
return active, recently_closed
return result
def _load_central() -> Dict[str, Any]:
@@ -223,80 +236,45 @@ def _calculate_global_statistics(central_data: Dict[str, Any]) -> Dict[str, int]
def push_to_plans_central() -> bool:
"""Push Flow's plan data to .ai_central/PLANS.central.json
"""Push plan data for ALL branches to .ai_central/PLANS.central.json
Algorithm:
1. Read fplan_registry.json
2. Extract only plans where location='flow' (Flow's own plans)
3. Format for central structure with branch metadata
4. Read existing PLANS.central.json if exists
5. Update ONLY branches.flow section
6. Update global_statistics (total counts across all branches)
7. Preserve ALL other branch sections
8. Write back to PLANS.central.json
9. Call aggregate_central_impl to rebuild top-level active_plans with validation
1. Read all per-type plan registries
2. Group plans by branch (derived from location path)
3. Build per-branch sections with active/closed/stats
4. Write all branch sections to PLANS.central.json
5. Update global_statistics (total counts across all branches)
6. Call aggregate_central_impl to rebuild top-level active_plans with validation
Returns:
True on success, False on failure
"""
try:
# Ensure .ai_central directory exists
AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True)
# Load registry
registry = _load_registry()
branch_sections = _extract_plans_by_branch(registry)
# Extract Flow's plans
active_plans, recently_closed = _extract_flow_plans(registry)
# Build Flow's branch section
now = datetime.now(timezone.utc).isoformat()
flow_section = {
"branch_name": "FLOW",
"branch_path": str(FLOW_ROOT),
"last_updated": now,
"active_plans": active_plans,
"recently_closed": recently_closed,
"statistics": {
"active_count": len(active_plans),
"total_closed": len(
[
p
for p in registry.get("plans", {}).values()
if p.get("location") == str(FLOW_ROOT) and p.get("status") == "closed"
]
),
},
}
for section in branch_sections.values():
section["last_updated"] = now
# Load existing central file
central_data = _load_central()
# Update Flow's section
if "branches" not in central_data:
central_data["branches"] = {}
central_data["branches"]["flow"] = flow_section
# Update global statistics
central_data["branches"] = branch_sections
central_data["global_statistics"] = _calculate_global_statistics(central_data)
# Update generated_at timestamp
central_data["generated_at"] = now
# Write back to central file
with open(CENTRAL_FILE, "w", encoding="utf-8") as f:
json.dump(central_data, f, indent=2, ensure_ascii=False)
# Call aggregate_central_impl to rebuild top-level arrays with validation
# This ensures active_plans is built from all branches and validates files exist
aggregate_central_impl(heal=True, central_file=CENTRAL_FILE, central_dir=AI_CENTRAL_DIR)
total_active = sum(len(s.get("active_plans", [])) for s in branch_sections.values())
json_handler.log_operation(
"plans_central_pushed",
{
"active_plans": len(active_plans),
"recently_closed": len(recently_closed),
"branches_reporting": central_data["global_statistics"].get("branches_reporting", 0),
"active_plans": total_active,
"branches_reporting": len(branch_sections),
"success": True,
},
)
+259 -181
View File
@@ -312,81 +312,74 @@ class TestLoadRegistry:
# =============================================
# 4. _extract_flow_plans
# 4. _extract_plans_by_branch
# =============================================
class TestExtractFlowPlans:
"""Tests for _extract_flow_plans."""
class TestExtractPlansByBranch:
"""Tests for _extract_plans_by_branch."""
def test_extracts_active_plans_matching_flow_root(self):
"""Returns active plans where location matches FLOW_ROOT."""
def test_groups_plans_by_branch(self):
"""Groups plans into per-branch sections by location path name."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
registry = {
"plans": {
"FPLAN-0001": {
"subject": "Plan A",
"subject": "Flow plan",
"status": "open",
"created": "2026-04-20",
"file_path": "/p/FPLAN-0001.md",
"location": flow_root,
"relative_path": "FPLAN-0001.md",
"location": "/repo/src/aipass/flow",
},
"DPLAN-0001": {
"subject": "Devpulse plan",
"status": "open",
"created": "2026-04-21",
"file_path": "/p/DPLAN-0001.md",
"location": "/repo/src/aipass/devpulse",
},
}
}
active, _ = mod._extract_flow_plans(registry)
assert len(active) == 1
assert active[0]["plan_id"] == "FPLAN-0001"
assert active[0]["subject"] == "Plan A"
assert active[0]["status"] == "open"
result = mod._extract_plans_by_branch(registry)
assert "flow" in result
assert "devpulse" in result
assert result["flow"]["statistics"]["active_count"] == 1
assert result["devpulse"]["statistics"]["active_count"] == 1
def test_extracts_closed_plans(self):
"""Returns closed plans with closed metadata."""
def test_extracts_active_and_closed(self):
"""Separates active and closed plans within a branch."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
registry = {
"plans": {
"FPLAN-0001": {
"subject": "Done plan",
"subject": "Open",
"status": "open",
"created": "2026-04-20",
"file_path": "/p/FPLAN-0001.md",
"location": "/repo/src/aipass/flow",
},
"FPLAN-0002": {
"subject": "Closed",
"status": "closed",
"created": "2026-04-15",
"closed": "2026-04-20",
"closed_reason": "completed",
"file_path": "/p/FPLAN-0001.md",
"location": flow_root,
"relative_path": "FPLAN-0001.md",
"closed": "2026-04-18",
"closed_reason": "done",
"file_path": "/p/FPLAN-0002.md",
"location": "/repo/src/aipass/flow",
},
}
}
active, closed = mod._extract_flow_plans(registry)
assert len(active) == 0
assert len(closed) == 1
assert closed[0]["closed"] == "2026-04-20"
assert closed[0]["closed_reason"] == "completed"
def test_excludes_plans_from_other_locations(self):
"""Excludes plans where location does not match FLOW_ROOT."""
mod = _import_mod()
registry = {
"plans": {
"FPLAN-0001": {
"subject": "Other branch plan",
"status": "open",
"created": "2026-04-20",
"file_path": "/other/FPLAN-0001.md",
"location": "/some/other/path",
},
}
}
active, closed = mod._extract_flow_plans(registry)
assert len(active) == 0
assert len(closed) == 0
result = mod._extract_plans_by_branch(registry)
flow = result["flow"]
assert flow["statistics"]["active_count"] == 1
assert flow["statistics"]["total_closed"] == 1
assert len(flow["active_plans"]) == 1
assert len(flow["recently_closed"]) == 1
assert flow["recently_closed"][0]["closed"] == "2026-04-18"
def test_sorts_active_newest_first(self):
"""Active plans are sorted by created date, newest first."""
"""Active plans sorted by created date, newest first."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
registry = {
"plans": {
"FPLAN-0001": {
@@ -394,114 +387,110 @@ class TestExtractFlowPlans:
"status": "open",
"created": "2026-04-01",
"file_path": "/p/FPLAN-0001.md",
"location": flow_root,
"location": "/repo/src/aipass/flow",
},
"FPLAN-0002": {
"subject": "Newest",
"status": "open",
"created": "2026-04-25",
"file_path": "/p/FPLAN-0002.md",
"location": flow_root,
},
"FPLAN-0003": {
"subject": "Middle",
"status": "open",
"created": "2026-04-15",
"file_path": "/p/FPLAN-0003.md",
"location": flow_root,
"location": "/repo/src/aipass/flow",
},
}
}
active, _ = mod._extract_flow_plans(registry)
result = mod._extract_plans_by_branch(registry)
active = result["flow"]["active_plans"]
assert active[0]["subject"] == "Newest"
assert active[1]["subject"] == "Middle"
assert active[2]["subject"] == "Oldest"
assert active[1]["subject"] == "Oldest"
def test_closed_plans_limited_to_5(self):
"""Recently closed plans are limited to 5."""
def test_closed_limited_to_5(self):
"""Recently closed plans limited to 5 per branch."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
plans = {}
for i in range(1, 9):
plans[f"FPLAN-{str(i).zfill(4)}"] = {
"subject": f"Closed plan {i}",
"subject": f"Closed {i}",
"status": "closed",
"created": "2026-04-01",
"closed": f"2026-04-{str(i + 10).zfill(2)}",
"closed_reason": "done",
"file_path": f"/p/FPLAN-{str(i).zfill(4)}.md",
"location": flow_root,
"location": "/repo/src/aipass/flow",
}
registry = {"plans": plans}
_, closed = mod._extract_flow_plans(registry)
assert len(closed) == 5
result = mod._extract_plans_by_branch({"plans": plans})
assert len(result["flow"]["recently_closed"]) == 5
def test_closed_sorted_newest_first(self):
"""Closed plans are sorted by closed date, newest first."""
def test_empty_registry(self):
"""Returns empty dict for empty registry."""
mod = _import_mod()
result = mod._extract_plans_by_branch({"plans": {}})
assert result == {}
def test_missing_plans_key(self):
"""Returns empty dict when registry has no plans key."""
mod = _import_mod()
result = mod._extract_plans_by_branch({})
assert result == {}
def test_skips_plans_without_location(self):
"""Plans with empty location are skipped."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
registry = {
"plans": {
"FPLAN-0001": {
"subject": "Old close",
"status": "closed",
"created": "2026-03-01",
"closed": "2026-03-10",
"subject": "No location",
"status": "open",
"created": "2026-04-20",
"file_path": "/p/FPLAN-0001.md",
"location": flow_root,
},
"FPLAN-0002": {
"subject": "New close",
"status": "closed",
"created": "2026-04-01",
"closed": "2026-04-20",
"file_path": "/p/FPLAN-0002.md",
"location": flow_root,
"location": "",
},
}
}
_, closed = mod._extract_flow_plans(registry)
assert closed[0]["subject"] == "New close"
assert closed[1]["subject"] == "Old close"
result = mod._extract_plans_by_branch(registry)
assert result == {}
def test_empty_registry(self):
"""Returns empty lists for empty registry."""
def test_branch_section_structure(self):
"""Each branch section has required keys."""
mod = _import_mod()
active, closed = mod._extract_flow_plans({"plans": {}})
assert active == []
assert closed == []
def test_missing_plans_key(self):
"""Returns empty lists when registry has no plans key."""
mod = _import_mod()
active, closed = mod._extract_flow_plans({})
assert active == []
assert closed == []
def test_plan_entry_structure(self):
"""Plan entries have all required keys."""
mod = _import_mod()
flow_root = str(mod.FLOW_ROOT)
registry = {
"plans": {
"FPLAN-0001": {
"subject": "Structured plan",
"subject": "Structured",
"status": "open",
"created": "2026-04-20",
"file_path": "/p/FPLAN-0001.md",
"relative_path": "FPLAN-0001.md",
"location": flow_root,
"location": "/repo/src/aipass/flow",
},
}
}
active, _ = mod._extract_flow_plans(registry)
entry = active[0]
result = mod._extract_plans_by_branch(registry)
section = result["flow"]
assert section["branch_name"] == "FLOW"
assert section["branch_path"] == "/repo/src/aipass/flow"
assert "active_plans" in section
assert "recently_closed" in section
assert "statistics" in section
entry = section["active_plans"][0]
assert "plan_id" in entry
assert "subject" in entry
assert "status" in entry
assert "created" in entry
assert "file_path" in entry
assert "relative_path" in entry
assert "branch" in entry
def test_plan_entries_have_branch_field(self):
"""Each plan entry includes the branch field."""
mod = _import_mod()
registry = {
"plans": {
"DPLAN-0001": {
"subject": "Test",
"status": "open",
"created": "2026-04-20",
"file_path": "/p/DPLAN-0001.md",
"location": "/repo/src/aipass/devpulse",
},
}
}
result = mod._extract_plans_by_branch(registry)
assert result["devpulse"]["active_plans"][0]["branch"] == "devpulse"
# =============================================
@@ -651,8 +640,15 @@ class TestPushToPlansCentral:
ai_central = tmp_path / ".ai_central"
mock_registry = {"plans": {}, "next_number": 1}
mock_active = [{"plan_id": "FPLAN-0001", "subject": "Test", "status": "open"}]
mock_closed: list = []
mock_branches = {
"flow": {
"branch_name": "FLOW",
"branch_path": str(mod.FLOW_ROOT),
"active_plans": [{"plan_id": "FPLAN-0001", "subject": "Test", "status": "open"}],
"recently_closed": [],
"statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0},
}
}
mock_central = {
"generated_at": "",
"branches": {},
@@ -663,7 +659,7 @@ class TestPushToPlansCentral:
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)),
patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl") as mock_agg,
):
@@ -703,30 +699,44 @@ class TestPushToPlansCentral:
assert central_file.exists()
written = json.loads(central_file.read_text(encoding="utf-8"))
assert "branches" in written
assert "flow" in written["branches"]
assert "global_statistics" in written
assert "generated_at" in written
assert written["generated_at"] != ""
def test_preserves_other_branches(self, tmp_path):
"""Preserves other branch sections when updating flow."""
def test_writes_multiple_branches(self, tmp_path):
"""Writes per-branch sections from registry data."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
mock_registry = {"plans": {}, "next_number": 1}
mock_central = {
"generated_at": "2026-04-01T00:00:00Z",
"branches": {
"drone": {"branch_name": "DRONE", "statistics": {"active_count": 3, "total_closed": 1}},
mock_branches = {
"flow": {
"branch_name": "FLOW",
"branch_path": "/repo/src/aipass/flow",
"active_plans": [],
"recently_closed": [],
"statistics": {"active_count": 0, "total_closed": 0, "recently_closed_included": 0},
},
"global_statistics": {"total_active": 3, "total_closed": 1, "branches_reporting": 1},
"devpulse": {
"branch_name": "DEVPULSE",
"branch_path": "/repo/src/aipass/devpulse",
"active_plans": [{"plan_id": "DPLAN-0001"}],
"recently_closed": [],
"statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0},
},
}
mock_central = {
"generated_at": "",
"branches": {},
"global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0},
}
with (
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl"),
):
@@ -734,8 +744,8 @@ class TestPushToPlansCentral:
assert result is True
written = json.loads(central_file.read_text(encoding="utf-8"))
assert "drone" in written["branches"]
assert "flow" in written["branches"]
assert "devpulse" in written["branches"]
def test_creates_ai_central_dir(self, tmp_path):
"""Creates .ai_central directory if it does not exist."""
@@ -773,26 +783,25 @@ class TestPushToPlansCentral:
assert result is False
def test_flow_section_has_correct_statistics(self, tmp_path):
"""Flow section statistics reflect actual plan counts."""
def test_branch_section_has_correct_statistics(self, tmp_path):
"""Branch section statistics reflect actual plan counts."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
flow_root_str = str(mod.FLOW_ROOT)
mock_registry = {
"plans": {
"FPLAN-0001": {"status": "open", "location": flow_root_str},
"FPLAN-0002": {"status": "open", "location": flow_root_str},
"FPLAN-0003": {"status": "closed", "location": flow_root_str},
},
"next_number": 4,
mock_registry = {"plans": {}, "next_number": 4}
mock_branches = {
"flow": {
"branch_name": "FLOW",
"branch_path": str(mod.FLOW_ROOT),
"active_plans": [
{"plan_id": "FPLAN-0001", "status": "open"},
{"plan_id": "FPLAN-0002", "status": "open"},
],
"recently_closed": [{"plan_id": "FPLAN-0003", "status": "closed"}],
"statistics": {"active_count": 2, "total_closed": 1, "recently_closed_included": 1},
}
}
mock_active = [
{"plan_id": "FPLAN-0001", "status": "open"},
{"plan_id": "FPLAN-0002", "status": "open"},
]
mock_closed = [{"plan_id": "FPLAN-0003", "status": "closed"}]
mock_central = {
"generated_at": "",
"branches": {},
@@ -803,7 +812,7 @@ class TestPushToPlansCentral:
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)),
patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl"),
):
@@ -817,18 +826,117 @@ class TestPushToPlansCentral:
assert flow["branch_name"] == "FLOW"
def test_global_statistics_updated(self, tmp_path):
"""Global statistics are recalculated after flow section update."""
"""Global statistics are recalculated from all branch sections."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
mock_registry = {"plans": {}, "next_number": 1}
mock_branches = {
"flow": {
"branch_name": "FLOW",
"branch_path": str(mod.FLOW_ROOT),
"active_plans": [],
"recently_closed": [],
"statistics": {"active_count": 0, "total_closed": 0, "recently_closed_included": 0},
},
"devpulse": {
"branch_name": "DEVPULSE",
"branch_path": "/repo/src/aipass/devpulse",
"active_plans": [{"plan_id": "DPLAN-0001"}],
"recently_closed": [],
"statistics": {"active_count": 5, "total_closed": 3, "recently_closed_included": 0},
},
}
mock_central = {
"generated_at": "",
"branches": {
"drone": {"statistics": {"active_count": 5, "total_closed": 3}},
"branches": {},
"global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0},
}
with (
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl"),
):
result = mod.push_to_plans_central()
assert result is True
written = json.loads(central_file.read_text(encoding="utf-8"))
stats = written["global_statistics"]
assert stats["total_active"] == 5
assert stats["total_closed"] == 3
assert stats["branches_reporting"] == 2
def test_log_operation_contains_expected_fields(self, tmp_path, mock_json_handler):
"""Log operation includes active_plans and branches_reporting."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
mock_registry = {"plans": {}, "next_number": 1}
mock_branches = {
"flow": {
"branch_name": "FLOW",
"branch_path": str(mod.FLOW_ROOT),
"active_plans": [{"plan_id": "FPLAN-0001"}],
"recently_closed": [],
"statistics": {"active_count": 1, "total_closed": 0, "recently_closed_included": 0},
},
"global_statistics": {"total_active": 5, "total_closed": 3, "branches_reporting": 1},
}
mock_central = {
"generated_at": "",
"branches": {},
"global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0},
}
with (
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_plans_by_branch", return_value=mock_branches),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl"),
):
mod.push_to_plans_central()
call_args = mock_json_handler.call_args
log_data = call_args[0][1]
assert log_data["active_plans"] == 1
assert "branches_reporting" in log_data
def test_non_flow_branch_plans_in_central(self, tmp_path):
"""Regression: non-flow branch plans must appear in PLANS.central.json."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
mock_registry = {
"plans": {
"DPLAN-0181": {
"subject": "Devpulse plan",
"status": "open",
"created": "2026-05-01",
"file_path": "/repo/src/aipass/devpulse/DPLAN-0181.md",
"location": "/repo/src/aipass/devpulse",
},
"FPLAN-0001": {
"subject": "Flow plan",
"status": "open",
"created": "2026-05-02",
"file_path": "/repo/src/aipass/flow/FPLAN-0001.md",
"location": str(mod.FLOW_ROOT),
},
},
"next_number": 1,
}
mock_central = {
"generated_at": "",
"branches": {},
"global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0},
}
with (
@@ -842,39 +950,9 @@ class TestPushToPlansCentral:
assert result is True
written = json.loads(central_file.read_text(encoding="utf-8"))
stats = written["global_statistics"]
# drone(5 active, 3 closed) + flow(0 active, 0 closed)
assert stats["total_active"] == 5
assert stats["total_closed"] == 3
assert stats["branches_reporting"] == 2
def test_log_operation_contains_expected_fields(self, tmp_path, mock_json_handler):
"""Log operation includes active_plans, recently_closed, branches_reporting."""
mod = _import_mod()
central_file = tmp_path / "PLANS.central.json"
ai_central = tmp_path / ".ai_central"
mock_registry = {"plans": {}, "next_number": 1}
mock_active = [{"plan_id": "FPLAN-0001"}]
mock_closed = [{"plan_id": "FPLAN-0002"}, {"plan_id": "FPLAN-0003"}]
mock_central = {
"generated_at": "",
"branches": {},
"global_statistics": {"total_active": 0, "total_closed": 0, "branches_reporting": 0},
}
with (
patch.object(mod, "AI_CENTRAL_DIR", ai_central),
patch.object(mod, "CENTRAL_FILE", central_file),
patch.object(mod, "_load_registry", return_value=mock_registry),
patch.object(mod, "_extract_flow_plans", return_value=(mock_active, mock_closed)),
patch.object(mod, "_load_central", return_value=mock_central),
patch.object(mod, "aggregate_central_impl"),
):
mod.push_to_plans_central()
call_args = mock_json_handler.call_args
log_data = call_args[0][1]
assert log_data["active_plans"] == 1
assert log_data["recently_closed"] == 2
assert "branches_reporting" in log_data
assert "devpulse" in written["branches"]
devpulse = written["branches"]["devpulse"]
assert devpulse["statistics"]["active_count"] == 1
assert len(devpulse["active_plans"]) == 1
assert devpulse["active_plans"][0]["plan_id"] == "DPLAN-0181"
assert devpulse["active_plans"][0]["branch"] == "devpulse"
+9
View File
@@ -35,6 +35,10 @@
{"file": "apps/handlers/security/git_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (PreToolUse.git_gate)."},
{"file": "apps/handlers/security/git_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
{"file": "apps/handlers/security/rm_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.rm_gate.handle' — not statically imported by design. Wired in PreToolUse.rm_gate."},
{"file": "apps/handlers/security/rm_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreToolUse.rm_gate."},
{"file": "apps/handlers/security/rm_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.subagent_gate.handle' — not statically imported by design. Verified wired in SubagentStop.subagent_stop_gate + fires in engine.jsonl."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Verified fires in engine.jsonl (SubagentStop.subagent_stop_gate)."},
{"file": "apps/handlers/security/subagent_gate.py", "standard": "json_structure", "reason": "Security gate uses stdlib json.dumps for hook protocol block responses — no JSON file ops needing json_handler."},
@@ -182,6 +186,11 @@
{"file": "tests/test_git_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_git_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_rm_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_rm_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_rm_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."},
{"file": "tests/test_rm_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."},
{"file": "tests/test_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."},
{"file": "tests/test_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."},
{"file": "tests/test_sound.py", "standard": "encapsulation", "reason": "Tests import sound module directly to test implementation details."},
+4 -3
View File
@@ -61,6 +61,7 @@ src/aipass/hooks/
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
│ │ │ ├── git_gate.py # Enforces git access tiers
│ │ │ ├── rm_gate.py # Blocks raw recursive rm, teaches drone rm
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
│ │ ├── lifecycle/ # Session management hooks
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
@@ -77,7 +78,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
├── tests/ # 253 tests across 19 test files
├── tests/ # 314 tests across 20 test files
└── STATUS.local.md
```
@@ -100,7 +101,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | identity, email, branch_loader, global_loader | Prompt injection + inbox check |
| PreToolUse | tool_sound, edit_gate, git_gate | Security gates + sound |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound | Achievement bell |
@@ -119,7 +120,7 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
All branches via hook dispatch. Every Claude Code session routes through the engine.
*Last Updated: 2026-05-28*
*Last Updated: 2026-06-02*
---
@@ -0,0 +1,108 @@
# =================== AIPass ====================
# Name: rm_gate.py
# Version: 1.0.0
# Description: Blocks raw recursive rm commands (PreToolUse)
# Branch: hooks
# Layer: apps/handlers/security
# Created: 2026-06-02
# Modified: 2026-06-02
# =============================================
"""Blocks raw recursive rm and teaches drone rm."""
import json
import re
from aipass.hooks.apps.sound import speak
from aipass.prax.apps.modules.logger import system_logger as logger
RM_REDIRECT = (
"Raw recursive rm is blocked. Use the safe contained delete instead:\n"
" drone rm <path> # safe delete (allows project + /tmp, refuses outside)\n"
"\n"
"This applies to all recursive rm variants (rm -rf, rm -r, rm -R, rm --recursive)."
)
_BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
def _block(reason: str) -> dict:
return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2}
def _strip_quotes(cmd: str) -> str:
"""Remove quoted strings so their contents aren't scanned."""
cmd = re.sub(r'"(?:[^"\\]|\\.)*"', '""', cmd)
cmd = re.sub(r"'(?:[^'\\]|\\.)*'", "''", cmd)
return cmd
def _split_clauses(cmd: str) -> list[str]:
"""Split on compound operators and subshell boundaries."""
parts = re.split(r"&&|\|\||[;|]", cmd)
clauses: list[str] = []
for part in parts:
clauses.extend(re.split(r"[$()`]", part))
return clauses
def _has_recursive_flag(tokens: list[str]) -> bool:
"""Return True if any token before '--' contains a recursive flag."""
for token in tokens:
if token == "--":
break
if token.startswith("-") and not token.startswith("--"):
if "r" in token[1:] or "R" in token[1:]:
return True
elif token == "--recursive":
return True
return False
def _clause_has_raw_recursive_rm(clause: str) -> bool:
"""Return True if a single clause contains a raw recursive rm."""
tokens = clause.split()
if not tokens:
return False
for i, tok in enumerate(tokens):
if tok != "rm" and not tok.endswith("/rm"):
continue
if i > 0 and tokens[i - 1] == "drone":
continue
if _has_recursive_flag(tokens[i + 1 :]):
return True
return False
def handle(hook_data: dict) -> dict:
"""Block raw recursive rm commands and teach drone rm.
Args:
hook_data: Parsed hook event dict from engine.
Returns:
Result dict with stdout (block JSON or empty) and exit_code.
"""
speak("rm gate")
try:
tool_name = hook_data.get("tool_name", "")
if tool_name != "Bash":
return _BLOCK_ALLOW
tool_input = hook_data.get("tool_input", {})
cmd = tool_input.get("command", "")
if not cmd:
return _BLOCK_ALLOW
scan = _strip_quotes(cmd)
for clause in _split_clauses(scan):
if _clause_has_raw_recursive_rm(clause):
return _block(RM_REDIRECT)
return _BLOCK_ALLOW
except Exception as exc:
logger.info("[HOOKS] rm_gate: unexpected error (allowing): %s", exc)
return _BLOCK_ALLOW
+4 -1
View File
@@ -139,9 +139,10 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
result = _run_hook(command, stdin_data, timeout_s=hook_timeout)
logger.info(
"[HOOKS] %s.%s exit=%d out=%db %dms",
"[HOOKS] %s.%s agent=%s exit=%d out=%db %dms",
event_type,
hook_name,
parsed.get("agent_type", "") or "main",
result["exit_code"],
len(result["stdout"]),
result["elapsed_ms"],
@@ -151,6 +152,8 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str:
"ts": time.time(),
"event": event_type,
"hook": hook_name,
"agent_type": parsed.get("agent_type", ""),
"agent_id": parsed.get("agent_id", ""),
"exit_code": result["exit_code"],
"elapsed_ms": result["elapsed_ms"],
"stdout_len": len(result["stdout"]),
+228
View File
@@ -0,0 +1,228 @@
# =================== AIPass ====================
# Name: test_rm_gate.py
# Version: 1.0.0
# Description: Tests for rm_gate security handler
# Branch: hooks
# Created: 2026-06-02
# Modified: 2026-06-02
# =============================================
"""Tests for handlers/security/rm_gate.py."""
import json
from unittest.mock import patch
from aipass.hooks.apps.handlers.security.rm_gate import (
_clause_has_raw_recursive_rm,
_has_recursive_flag,
_split_clauses,
_strip_quotes,
handle,
)
class TestStripQuotes:
def test_double_quotes(self):
assert _strip_quotes('rm -rf "/tmp/foo bar"') == 'rm -rf ""'
def test_single_quotes(self):
assert _strip_quotes("rm -rf '/tmp/foo bar'") == "rm -rf ''"
def test_escaped_quote_in_double(self):
assert _strip_quotes(r'echo "he said \"hi\""') == 'echo ""'
def test_no_quotes(self):
assert _strip_quotes("rm -rf /tmp/foo") == "rm -rf /tmp/foo"
def test_mixed_quotes(self):
result = _strip_quotes("""echo "hello" && rm -rf '/tmp/x'""")
assert "rm" in result
assert "/tmp/x" not in result
class TestSplitClauses:
def test_and_operator(self):
clauses = _split_clauses("cd /tmp && rm -rf foo")
assert any("rm" in c for c in clauses)
def test_semicolon(self):
clauses = _split_clauses("echo hi; rm -rf /tmp/x")
assert any("rm" in c for c in clauses)
def test_pipe(self):
clauses = _split_clauses("ls | rm -rf /tmp/x")
assert any("rm" in c for c in clauses)
def test_or_operator(self):
clauses = _split_clauses("true || rm -rf /tmp/x")
assert any("rm" in c for c in clauses)
def test_subshell(self):
clauses = _split_clauses("echo $(rm -rf /tmp/x)")
assert any("rm" in c for c in clauses)
def test_backtick_subshell(self):
clauses = _split_clauses("echo `rm -rf /tmp/x`")
assert any("rm" in c for c in clauses)
class TestHasRecursiveFlag:
def test_rf(self):
assert _has_recursive_flag(["-rf", "/tmp/x"]) is True
def test_fr(self):
assert _has_recursive_flag(["-fr", "/tmp/x"]) is True
def test_r_alone(self):
assert _has_recursive_flag(["-r", "/tmp/x"]) is True
def test_uppercase_r(self):
assert _has_recursive_flag(["-R", "/tmp/x"]) is True
def test_rfv(self):
assert _has_recursive_flag(["-rfv", "/tmp/x"]) is True
def test_recursive_long(self):
assert _has_recursive_flag(["--recursive", "/tmp/x"]) is True
def test_no_recursive(self):
assert _has_recursive_flag(["-f", "/tmp/x"]) is False
def test_after_double_dash(self):
assert _has_recursive_flag(["--", "-rf", "/tmp/x"]) is False
def test_empty(self):
assert _has_recursive_flag([]) is False
class TestClauseHasRawRecursiveRm:
def test_basic_rm_rf(self):
assert _clause_has_raw_recursive_rm("rm -rf /tmp/x") is True
def test_rm_fr(self):
assert _clause_has_raw_recursive_rm("rm -fr /tmp/x") is True
def test_rm_rfv(self):
assert _clause_has_raw_recursive_rm("rm -rfv /tmp/x") is True
def test_rm_recursive_long(self):
assert _clause_has_raw_recursive_rm("rm --recursive /tmp/x") is True
def test_rm_uppercase_r(self):
assert _clause_has_raw_recursive_rm("rm -R /tmp/x") is True
def test_drone_rm_not_blocked(self):
assert _clause_has_raw_recursive_rm("drone rm /tmp/x") is False
def test_non_recursive_rm(self):
assert _clause_has_raw_recursive_rm("rm file.txt") is False
def test_rm_force_only(self):
assert _clause_has_raw_recursive_rm("rm -f file.txt") is False
def test_sudo_rm_rf(self):
assert _clause_has_raw_recursive_rm("sudo rm -rf /tmp/x") is True
def test_env_prefix_rm_rf(self):
assert _clause_has_raw_recursive_rm("env VAR=val rm -rf /tmp/x") is True
def test_absolute_path_rm(self):
assert _clause_has_raw_recursive_rm("/usr/bin/rm -rf /tmp/x") is True
def test_empty_clause(self):
assert _clause_has_raw_recursive_rm("") is False
def test_whitespace_clause(self):
assert _clause_has_raw_recursive_rm(" ") is False
class TestHandle:
CWD = "/home/patrick/Projects/AIPass/src/aipass/hooks"
def _bash(self, command: str) -> dict:
return handle({"tool_name": "Bash", "tool_input": {"command": command}, "cwd": self.CWD})
def _assert_blocked(self, result: dict):
assert result["exit_code"] == 2
parsed = json.loads(result["stdout"])
assert parsed["decision"] == "block"
assert "drone rm" in parsed["reason"]
def _assert_allowed(self, result: dict):
assert result["exit_code"] == 0
assert result["stdout"] == ""
def test_block_rm_rf(self):
self._assert_blocked(self._bash("rm -rf /tmp/x"))
def test_block_rm_fr(self):
self._assert_blocked(self._bash("rm -fr /tmp/x"))
def test_block_rm_rfv(self):
self._assert_blocked(self._bash("rm -rfv /tmp/x"))
def test_block_rm_recursive_long(self):
self._assert_blocked(self._bash("rm --recursive /tmp/x"))
def test_block_rm_uppercase_r(self):
self._assert_blocked(self._bash("rm -R /tmp/x"))
def test_block_rm_r(self):
self._assert_blocked(self._bash("rm -r /tmp/x"))
def test_allow_drone_rm(self):
self._assert_allowed(self._bash("drone rm /tmp/x"))
def test_allow_non_recursive_rm(self):
self._assert_allowed(self._bash("rm file.txt"))
def test_allow_rm_force_only(self):
self._assert_allowed(self._bash("rm -f file.txt"))
def test_block_compound_cd_and_rm(self):
self._assert_blocked(self._bash("cd /etc && rm -rf ."))
def test_block_compound_semicolon(self):
self._assert_blocked(self._bash("echo hi; rm -rf /tmp/x"))
def test_block_subshell_rm(self):
self._assert_blocked(self._bash("echo $(rm -rf /tmp/x)"))
def test_block_sudo_rm_rf(self):
self._assert_blocked(self._bash("sudo rm -rf /tmp/x"))
def test_block_absolute_path_rm(self):
self._assert_blocked(self._bash("/usr/bin/rm -rf /tmp/x"))
def test_rm_in_quoted_string_allowed(self):
self._assert_allowed(self._bash('echo "rm -rf /tmp/x"'))
def test_rm_in_single_quoted_string_allowed(self):
self._assert_allowed(self._bash("echo 'rm -rf /tmp/x'"))
def test_non_bash_tool_allowed(self):
result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/x"}, "cwd": self.CWD})
self._assert_allowed(result)
def test_empty_command_allowed(self):
self._assert_allowed(self._bash(""))
def test_empty_hook_data(self):
result = handle({})
assert result["exit_code"] == 0
def test_no_tool_input(self):
result = handle({"tool_name": "Bash"})
assert result["exit_code"] == 0
@patch("aipass.hooks.apps.handlers.security.rm_gate.logger")
def test_exception_allows(self, mock_logger):
result = handle({"tool_name": "Bash", "tool_input": None, "cwd": self.CWD})
assert result["exit_code"] == 0
mock_logger.info.assert_called()
def test_block_variable_target(self):
self._assert_blocked(self._bash("rm -rf $DIR"))
def test_block_multiple_targets(self):
self._assert_blocked(self._bash("rm -rf /tmp/a /tmp/b"))
@@ -203,21 +203,16 @@ def _calculate_quick_status_standalone(sections: Dict) -> Dict:
"""
ai_mail = sections.get("ai_mail", {})
flow = sections.get("flow", {})
commons = sections.get("commons_activity", {})
new_mail_raw = ai_mail.get("new", ai_mail.get("unread", 0))
opened_raw = ai_mail.get("opened", 0)
active_plans_raw = flow.get("active_plans", 0)
commons_mentions_raw = commons.get("mentions", 0)
new_mail = len(new_mail_raw) if isinstance(new_mail_raw, list) else int(new_mail_raw or 0)
opened_mail = len(opened_raw) if isinstance(opened_raw, list) else int(opened_raw or 0)
active_plans = len(active_plans_raw) if isinstance(active_plans_raw, list) else int(active_plans_raw or 0)
commons_mentions = (
len(commons_mentions_raw) if isinstance(commons_mentions_raw, list) else int(commons_mentions_raw or 0)
)
action_required = new_mail > 0 or active_plans > 0 or commons_mentions > 0
action_required = new_mail > 0 or active_plans > 0
parts = []
if new_mail > 0:
@@ -226,14 +221,11 @@ def _calculate_quick_status_standalone(sections: Dict) -> Dict:
parts.append(f"{opened_mail} opened")
if active_plans > 0:
parts.append(f"{active_plans} active plans")
if commons_mentions > 0:
parts.append(f"{commons_mentions} mentions")
return {
"new_mail": new_mail,
"opened_mail": opened_mail,
"active_plans": active_plans,
"commons_mentions": commons_mentions,
"action_required": action_required,
"summary": ", ".join(parts) if parts else "All clear",
}
+220
View File
@@ -0,0 +1,220 @@
# AIPass Cross-OS Acceptance Checklist
**Purpose:** the definition of *"AIPass works on this OS."* Follow this top-to-bottom on any
fresh machine or VM (Windows / macOS / Linux) to verify a real end-to-end install — the things
automated CI structurally cannot reach (full install, interactive flows, background daemons,
audible hooks). Copy a **Run Record** (bottom of this file) per machine and fill it in.
> Code is truth. A green checkbox here means *you watched it work on that OS*, not that it should.
---
## The 3 layers of cross-OS confidence
| Layer | What | Where it runs | Catches |
|-------|------|---------------|---------|
| 1 — **Static** | `drone @seedgo` Windows-compat scan | any OS, no execution | POSIX-only patterns (`os.kill`, `start_new_session`, `/tmp`, `fcntl`…) before you run |
| 2 — **Automated** | `pytest tests/e2e` (the `e2e-wheel.yml` 4-tier gate) | CI on ubuntu+windows+macos, and locally | the wiring contract: clean-wheel install → `aipass init` scaffold → hook fires → `drone` routes |
| 3 — **Manual** | **this checklist** | a real box / VM, by hand | full install, interactive `init run`, daemons, sound, per-branch smoke |
Layers 1–2 are free and run on every push. **This file is layer 3** — the human acceptance pass
you do when you get hardware (a VM, a borrowed Mac, a new laptop).
---
## How to use
1. **Capture output.** Run your shell session into a log so any crash traceback is saved:
- Linux/macOS: `script -q aipass-crossos-$(uname -s).log` (then run the checks, `exit` when done)
- Windows PowerShell: `Start-Transcript -Path aipass-crossos-win.log` … `Stop-Transcript`
2. Work through **Phase 0 → 7**, then the **Per-branch matrix**.
3. For every check: run the command, compare to **Expected**, tick ⬜ → ✅ / ❌.
4. On ❌: note the exact error in the Run Record, check the **Known gap registry** (it may be a
tracked one), and if new, file it / email the owning branch.
5. Paste the completed Run Record into the PR / DPLAN-0194 thread.
**Legend:** ✅ pass · ❌ fail · ⏭️ skipped (state why) · ⚠️ known-gap watch item
---
## Phase 0 — Environment capture (record before anything)
| # | Capture | Command |
|---|---------|---------|
| 0.1 | OS + version | `uname -a` / Win: `cmd /c ver` + `systeminfo \| findstr /B /C:"OS"` |
| 0.2 | Arch | `uname -m` / Win: `echo %PROCESSOR_ARCHITECTURE%` |
| 0.3 | Python | `python3 --version` (Win: `python --version`) — **must be 3.10+** |
| 0.4 | Shell + terminal | which shell; is it Windows Terminal / cmd / PowerShell / iTerm? |
| 0.5 | `AIPASS_HOME` | `echo $AIPASS_HOME` (Win: `echo %AIPASS_HOME%`) — note set/unset |
| 0.6 | Git | `git --version` |
> **Why terminal matters:** the cp1252 stdout class of bug (fixed S190) only bites when stdout is
> a *legacy/captured* stream. Record whether you're on UTF-8-capable Windows Terminal vs legacy
> conhost — reds can differ.
---
## Phase 1 — Clean install ⬜
| # | Step | Expected | Watch |
|---|------|----------|-------|
| 1.1 | Fresh clone or wheel copy onto the box | files present | — |
| 1.2 | Run `setup.sh` (or `pip install -e ".[dev]"`) | exits 0; `.venv` created with pip | ⚠️ **Windows `.venv` symlink WinError 1314** — needs `os.symlink` guarded w/ copy/junction fallback (DPLAN-0194 gap) |
| 1.3 | `drone --version` and `aipass --version` | both print a version, exit 0 | ⚠️ `.venv/bin` vs `Scripts` path resolution |
---
## Phase 2 — Automated wiring (run the e2e suite on the real box) ⬜
| # | Step | Expected |
|---|------|----------|
| 2.1 | `pip install build pytest` | installed |
| 2.2 | `python -m pytest tests/e2e -v` | **14 passed** (T0 install / T1 init scaffold / T2a hook fire / T3 drone routing) |
> This re-runs the CI gate on *real* hardware. If CI is green but this is red, the difference is
> the machine (real console, real paths) — exactly what we're hunting.
---
## Phase 3 — `aipass init` (real scaffold + interactive) ⬜
| # | Command | Expected | Watch |
|---|---------|----------|-------|
| 3.1 | `aipass init /tmp/demo demo` (Win: a temp path) | creates `DEMO_REGISTRY.json`, `.aipass/`, `.claude/settings.json`, `src/demo/` + prints `✓ Project initialized` | ⚠️ cp1252 banner crash (fixed S190 — verify it stays fixed) |
| 3.2 | `aipass init run --dry-run` | shows the 12-stage plan, no writes | — |
| 3.3 | `aipass init run --non-interactive` (in a throwaway dir) | completes all stages headless | ⚠️ symlink/daemon steps |
| 3.4 | `aipass init run` (interactive, manual) | prompts render + accept input; completes | ⚠️ **interactive PTY** — never machine-tested; pexpect territory |
| 3.5 | `aipass doctor` | health report renders, exit 0 | — |
> **@aipass is its own process — the user-facing concierge / onboarding CLI** (`init`, `doctor`,
> scanner). It's the front door you run to *bootstrap* a project, so it's invoked directly as
> `aipass …` and is **by design not routed through `drone @aipass`** (unlike the other 12 branches).
---
## Phase 4 — `drone` routing ⬜
| # | Command | Expected |
|---|---------|----------|
| 4.1 | `drone systems` | lists all registered branches + modules |
| 4.2 | `drone @ai_mail --help` | help text, **exit 0** (real registry-branch subprocess — the T3 path) |
| 4.3 | `drone @seedgo --help` | help text, exit 0 (in-process module path) |
| 4.4 | `drone @ai_mail inbox` | inbox renders (empty is fine) |
---
## Phase 5 — Daemons / background processes ⬜
| # | Command | Expected | Watch |
|---|---------|----------|-------|
| 5.1 | `drone @ai_mail dispatch @devpulse "x-os test" "ping"` then `drone @ai_mail inbox` | mail sent; target woken | ⚠️ **`start_new_session=` POSIX kwarg** in ai_mail/flow daemon spawn |
| 5.2 | `drone @devpulse watchdog --help` then arm a watchdog | polls lock, exits clean | ⚠️ **`os.kill` POSIX-only**; inotify vs Win file-watch |
| 5.3 | `drone @prax monitor` (then quit) | live dashboard renders | ⚠️ interactive / curses-style on Win |
---
## Phase 6 — Hooks + sound ⬜
| # | Step | Expected | Watch |
|---|------|----------|-------|
| 6.1 | Trigger `rm_gate` (attempt a raw `rm -rf` via the agent / fire the bridge) | blocked; `src/aipass/hooks/logs/engine.jsonl` gains a record | — |
| 6.2 | A hook with sound fires | audible cue plays | ⚠️ **`aplay` is Linux-only** — needs `afplay` (macOS) / `winsound` (Windows) |
| 6.3 | `drone @hooks hookstatus` | per-project hook config renders | ⚠️ hardcoded `/tmp` paths |
| 6.4 | `drone @hooks hooksound` (mute/unmute) | toggles without error | — |
---
## Phase 7 — Interactive layer (manual, hardest to automate) ⬜
| # | Step | Expected | Watch |
|---|------|----------|-------|
| 7.1 | Launch an agent session in a terminal | starts, prompt usable | ⚠️ tmux (Linux/mac) vs Windows terminal multiplexer |
| 7.2 | `aipass init run` full interactive (if not done in 3.4) | all 12 stages accept input | ⚠️ PTY/pexpect |
| 7.3 | Any prompt-driven flow (`--bypass`/flags where available) | bypassable for headless | — |
---
## Per-branch smoke matrix (all 13 branches)
Run each branch's `drone @<branch> --help` first (the universal *resolve → subprocess → execute →
print* proof), then the listed **read-only** commands. ⚠️ = mutates state / side-effect — run only
deliberately. Every command should **exit 0** and render readable (non-mojibake) output on the OS
under test.
> **† aipass is the exception** — it's the standalone user-facing concierge CLI, invoked directly
> (`aipass …`), **not** routed through `drone`. So it has no `drone @aipass --help` row; smoke it
> with the `aipass` command itself. The other 12 branches all route through `drone`.
| Branch | `--help` ⬜ | Read-only smoke | Side-effect (run deliberately) |
|--------|:---------:|-----------------|--------------------------------|
| **drone** | ⬜ | `drone systems` · `drone list` · `drone --version` | `drone scan @<b>` · `drone activate @<b>` |
| **seedgo** | ⬜ | `drone @seedgo audit` · `drone @seedgo standards_query` · `drone @seedgo diagnostics` | `drone @seedgo audit aipass` (full scan) |
| **prax** | ⬜ | `drone @prax status` · `drone @prax dashboard` · `drone @prax log-audit` | `drone @prax monitor` (interactive) |
| **cli** | ⬜ | `drone @cli` · `drone @cli display` · `drone @cli templates` | `drone @cli display demo` |
| **ai_mail** | ⬜ | `drone @ai_mail inbox` · `drone @ai_mail sent` · `drone @ai_mail contacts` | ⚠️ `dispatch` / `email` / `reply` / `close` |
| **api** | ⬜ | `drone @api status` · `drone @api stats` · `drone @api models` · `drone @api list-providers` | ⚠️ `get-key` / `validate` / `call` (touch keys/network) |
| **flow** | ⬜ | `drone @flow list` · `drone @flow list open` | ⚠️ `create` / `close` / `restore` / `aggregate` |
| **spawn** | ⬜ | `drone @spawn --help` · `drone @spawn sync-registry` | ⚠️ `create` / `update` / `delete` (use `--dry-run`) |
| **trigger** | ⬜ | `drone @trigger errors` · `drone @trigger core` | ⚠️ `medic` toggle |
| **memory** | ⬜ | `drone @memory search "test"` · `drone @memory verify` | ⚠️ `rollover` · `watch` (daemon) |
| **aipass** † | (n/a) | `aipass --version` · `aipass --help` · `aipass init --help` · `aipass doctor` | ⚠️ `aipass init …` (scaffolds) |
| **hooks** | ⬜ | `drone @hooks hookstatus` · `drone @hooks engine` | ⚠️ `hooksound` (mute) · `claude` (bridge) |
| **devpulse** | ⬜ | `drone @devpulse feedback` · `drone @devpulse watchdog --help` | ⚠️ `watchdog agent @<b>` (arms wake) |
---
## Known cross-OS gap registry (living — update as fixed)
Tracks every confirmed/suspected portability gap so a red here is "expected, tracked" not a mystery.
Source of truth: **DPLAN-0194**. Status: ✅ fixed · 🔧 owner assigned · ❓ suspected/untested.
| # | Gap | OS | Symptom | Owner | Status |
|---|-----|----|---------| ------|--------|
| 1 | cp1252 stdout + Rich in CLI entry points | Win | `UnicodeEncodeError('charmap')` on `aipass init` banner & `drone @branch` print | aipass/drone | ✅ S190 (`reconfigure(utf-8)` at entry) |
| 2 | `.venv` symlink | Win | `setup.sh`/`init` → WinError 1314 (no symlink priv) | aipass | ❓ untested (CI left `AIPASS_HOME` unset) |
| 3 | `start_new_session=` kwarg | Win | daemon spawn throws (POSIX-only) | ai_mail / flow | ❓ |
| 4 | `os.kill` | Win | watchdog / daemon stop throws | flow + others | ❓ |
| 5 | `.venv/bin` vs `Scripts` | Win | path resolution misses console scripts | memory / drone / ai_mail | ❓ |
| 6 | hardcoded `/tmp` | Win | scratch-dir writes fail | hooks / seedgo | ❓ |
| 7 | `aplay`-only audio | Win/mac | hook sound silent / errors | hooks (+ template hooks) | ❓ (mac needs `afplay`, Win `winsound`) |
| 8 | `shell=True` usage | Win | quoting/semantics differ | hooks | ❓ |
| 9 | `route_command` masks errors | all | real exceptions printed as "Unknown command" | aipass | 🔧 recommended (not fixed — hid gap #1 for hours) |
> @seedgo's `windows_compat_check.py` already scans for several of these statically — extending it
> to flag the cp1252/entry-point pattern (#1) and the `aplay`/`/tmp`/`os.kill` patterns is **P2**.
---
## Run Record (copy one block per machine/run)
```
─────────────────────────────────────────────
AIPass Cross-OS Run Record
Machine/VM :
OS + version :
Arch :
Python :
Shell / term :
AIPASS_HOME :
Commit (drone @git log -1) :
Tester : Date :
─────────────────────────────────────────────
Phase 0 env capture .......... ✅ / ❌
Phase 1 clean install ........ ✅ / ❌ notes:
Phase 2 e2e suite (14/14) .... ✅ / ❌ notes:
Phase 3 aipass init .......... ✅ / ❌ notes:
Phase 4 drone routing ........ ✅ / ❌ notes:
Phase 5 daemons .............. ✅ / ❌ notes:
Phase 6 hooks + sound ........ ✅ / ❌ notes:
Phase 7 interactive .......... ✅ / ❌ notes:
Per-branch matrix (13) ....... ✅ / ❌ reds:
─────────────────────────────────────────────
New gaps found (file + assign):
Overall verdict: PASS / PARTIAL / FAIL
─────────────────────────────────────────────
```
---
*Layer 3 of cross-OS confidence. Pairs with `tests/e2e/` (layer 2) and `drone @seedgo` Windows-compat scan (layer 1). See DPLAN-0194 for the full strategy.*
+138
View File
@@ -0,0 +1,138 @@
# =================== AIPass ====================
# Name: conftest.py
# Description: Session-scoped fixtures for the cross-OS e2e wiring harness
# Version: 1.0.0
# Created: 2026-06-03
# =============================================
"""Session-scoped pytest fixtures for the cross-OS end-to-end WIRING harness.
These fixtures build the aipass wheel and install it into a FRESH, clean venv
(never the repo .venv, never ``pip install -e``) so the tests in this package
exercise the real installed package the way a contributor on any OS would.
CRITICAL cross-OS-harness rule: this harness must itself run on Windows. The
venv binary directory is ``Scripts`` on Windows and ``bin`` on POSIX, and the
script extension is ``.exe`` on Windows. We resolve those from ``os.name`` /
``sys.executable`` and never hardcode — the harness must NOT contain the very
bugs it tests for.
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
import pytest
# Repo root = three levels up from this file: <repo>/tests/e2e/conftest.py
REPO_ROOT = Path(__file__).resolve().parents[2]
def _venv_bin_dir(venv_root: Path) -> Path:
"""Return the venv directory that holds executables for THIS platform.
Windows venvs put scripts in ``Scripts``; POSIX venvs use ``bin``. This is
exactly the bin-vs-Scripts split the harness exists to expose, so the
harness must resolve it correctly itself.
"""
return venv_root / ("Scripts" if os.name == "nt" else "bin")
def _exe(name: str) -> str:
"""Append the Windows executable suffix to a console-script name."""
return f"{name}.exe" if os.name == "nt" else name
@dataclass(frozen=True)
class CleanVenv:
"""Paths into a clean venv with the aipass wheel installed."""
root: Path
python: Path
pip: Path
aipass: Path
drone: Path
site_packages: Path
@pytest.fixture(scope="session")
def wheel(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Build the aipass wheel from the repo root and return its path.
Uses ``python -m build --wheel`` (build backend = hatchling, package =
aipass 2.5.0). The outer environment running pytest only needs ``build``
and ``pytest`` installed — this fixture produces the wheel that the
clean-venv fixture then installs.
"""
dist_dir = tmp_path_factory.mktemp("wheel_dist")
result = subprocess.run(
[sys.executable, "-m", "build", "--wheel", "--outdir", str(dist_dir), str(REPO_ROOT)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(
f"wheel build failed (exit {result.returncode}).\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
wheels = sorted(dist_dir.glob("*.whl"))
if not wheels:
raise RuntimeError(f"no wheel produced in {dist_dir}. build output:\n{result.stdout}")
return wheels[0]
@pytest.fixture(scope="session")
def clean_venv(wheel: Path, tmp_path_factory: pytest.TempPathFactory) -> CleanVenv:
"""Create a fresh venv and install the wheel into it.
NOT the repo .venv, NOT an editable install — a brand-new venv with the
built wheel installed, so we test the wiring of the real package.
"""
venv_root = tmp_path_factory.mktemp("clean_venv")
# Build the venv with the current interpreter — no hardcoded "python".
result = subprocess.run(
[sys.executable, "-m", "venv", str(venv_root)],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"venv creation failed:\n{result.stdout}\n{result.stderr}")
bin_dir = _venv_bin_dir(venv_root)
py = bin_dir / _exe("python")
pip = bin_dir / _exe("pip")
install = subprocess.run(
[str(py), "-m", "pip", "install", str(wheel)],
capture_output=True,
text=True,
)
if install.returncode != 0:
raise RuntimeError(
f"wheel install into clean venv failed:\nSTDOUT:\n{install.stdout}\nSTDERR:\n{install.stderr}"
)
# Resolve site-packages from the venv's own interpreter — portable across
# OSes and python minor versions instead of guessing lib/pythonX.Y.
sp = subprocess.run(
[str(py), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
capture_output=True,
text=True,
)
if sp.returncode != 0:
raise RuntimeError(f"could not resolve site-packages:\n{sp.stdout}\n{sp.stderr}")
site_packages = Path(sp.stdout.strip())
return CleanVenv(
root=venv_root,
python=py,
pip=pip,
aipass=bin_dir / _exe("aipass"),
drone=bin_dir / _exe("drone"),
site_packages=site_packages,
)
+378
View File
@@ -0,0 +1,378 @@
# =================== AIPass ====================
# Name: test_wiring.py
# Description: Cross-OS end-to-end WIRING tests against the installed wheel
# Version: 1.0.0
# Created: 2026-06-03
# =============================================
"""Cross-OS end-to-end WIRING tests (FPLAN-0239, P1 of DPLAN-0194).
This proves AIPass *wiring* — not units-with-mocks — by building the wheel,
installing it into a clean venv (see ``conftest.py``), and asserting a 4-tier
ladder against the real installed package:
T0 install + console scripts exist and run
T1 ``aipass init`` scaffolds a project correctly
T2a a hook actually fires, blocks, and leaves a sentinel record
T3 ``drone`` resolves a real branch and executes it via subprocess
It is RED-FIRST: it is expected to fail on Windows in known places (symlink
init, bin-vs-Scripts, /tmp). The harness itself is written to be cross-OS so
those reds reflect AIPass bugs, not harness bugs.
"""
from __future__ import annotations
import json
import subprocess
import sys
import uuid
from collections.abc import Iterator
from pathlib import Path
import pytest
from conftest import REPO_ROOT, CleanVenv
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:
"""Run a subprocess capturing text output with a bounded timeout."""
kwargs.setdefault("capture_output", True)
kwargs.setdefault("text", True)
kwargs.setdefault("timeout", 60)
return subprocess.run(cmd, **kwargs)
# ===========================================================================
# TIER 0 — clean-venv wheel install + binaries
# ===========================================================================
def test_t0_wheel_built(wheel: Path) -> None:
"""The wheel built and is named for aipass."""
assert wheel.exists()
assert wheel.suffix == ".whl"
assert wheel.name.startswith("aipass-")
def test_t0_venv_has_pip(clean_venv: CleanVenv) -> None:
"""Clean venv has a working pip — not a silently-broken venv (ref #495)."""
result = _run([str(clean_venv.python), "-m", "pip", "--version"])
assert result.returncode == 0, result.stderr
def test_t0_console_scripts_exist(clean_venv: CleanVenv) -> None:
"""Both ``aipass`` and ``drone`` console scripts are installed."""
assert clean_venv.aipass.exists(), f"missing aipass at {clean_venv.aipass}"
assert clean_venv.drone.exists(), f"missing drone at {clean_venv.drone}"
def test_t0_drone_version_runs(clean_venv: CleanVenv) -> None:
"""``drone --version`` runs (entry point imports cleanly)."""
result = _run([str(clean_venv.drone), "--version"])
assert result.returncode == 0, result.stderr
def test_t0_aipass_init_help_runs(clean_venv: CleanVenv) -> None:
"""``aipass init --help`` runs (the init entry point imports cleanly)."""
result = _run([str(clean_venv.aipass), "init", "--help"])
assert result.returncode == 0, result.stderr
# ===========================================================================
# TIER 1 — aipass init scaffolds correctly
# ===========================================================================
@pytest.fixture(scope="module")
def init_project(clean_venv: CleanVenv, tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Run ``aipass init <proj> demo`` in a clean neutral dir, return the project.
``aipass init`` REFUSES when a ``*_REGISTRY.json`` sits in or above CWD
(``_guard_init``), so we run it from a pristine tmp dir whose parents have
no registry. ``AIPASS_HOME`` is left UNSET so the .venv-symlink step (a
Windows-only failure tracked separately) is skipped — this test must pass
on Linux.
"""
neutral = tmp_path_factory.mktemp("neutral_init_cwd")
proj = neutral / "proj"
env = {
"PATH": _path_env(),
"HOME": str(neutral),
}
if sys.platform == "win32":
# Windows needs a few base vars for subprocess/venv tooling to work.
import os as _os
for key in ("SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "PATHEXT", "COMSPEC"):
if key in _os.environ:
env[key] = _os.environ[key]
result = subprocess.run(
[str(clean_venv.aipass), "init", str(proj), "demo"],
cwd=str(neutral),
env=env,
capture_output=True,
text=True,
timeout=120,
)
assert result.returncode == 0, (
f"aipass init failed (exit {result.returncode}).\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}"
)
return proj
def _path_env() -> str:
"""Minimal PATH for the init subprocess (portable across OSes)."""
import os
return os.environ.get("PATH", "")
def test_t1_registry_created_and_valid(init_project: Path) -> None:
"""DEMO_REGISTRY.json exists, is valid JSON, and names DEMO."""
registry = init_project / "DEMO_REGISTRY.json"
assert registry.is_file(), f"missing {registry}"
data = json.loads(registry.read_text(encoding="utf-8"))
assert data["metadata"]["name"] == "DEMO"
def test_t1_claude_settings_deny_enterplanmode(init_project: Path) -> None:
""".claude/settings.json exists and its permissions.deny blocks EnterPlanMode."""
settings = init_project / ".claude" / "settings.json"
assert settings.is_file(), f"missing {settings}"
data = json.loads(settings.read_text(encoding="utf-8"))
assert "EnterPlanMode" in data["permissions"]["deny"]
def test_t1_src_package_scaffolded(init_project: Path) -> None:
"""src/demo/__init__.py is created."""
assert (init_project / "src" / "demo" / "__init__.py").is_file()
def test_t1_gitignore_mentions_venv(init_project: Path) -> None:
""".gitignore mentions .venv."""
gitignore = init_project / ".gitignore"
assert gitignore.is_file()
assert ".venv" in gitignore.read_text(encoding="utf-8")
def test_t1_no_trinity_no_passport(init_project: Path) -> None:
"""Projects are NOT citizens: no .trinity/ dir and no passport.json."""
assert not (init_project / ".trinity").exists()
assert not (init_project / ".trinity" / "passport.json").exists()
# ===========================================================================
# TIER 2a — synthetic hook fire (module form, sentinel UUID, engine.jsonl)
# ===========================================================================
_RM_GATE_CONFIG = {
"hooks_enabled": True,
"PreToolUse": {
"rm_gate": {
"enabled": True,
"handler": "aipass.hooks.apps.handlers.security.rm_gate.handle",
"matcher": "Bash",
}
},
}
def _fire_hook(clean_venv: CleanVenv, work: Path, command: str, agent_id: str) -> subprocess.CompletedProcess:
"""Invoke the Claude bridge in MODULE form for a single PreToolUse event.
Module form (``python -m aipass.hooks...``) is venv-portable and avoids the
bin/Scripts split — the very bug this suite tests for.
"""
payload = json.dumps(
{
"tool_name": "Bash",
"tool_input": {"command": command},
"agent_id": agent_id,
}
)
env = _hook_env(work)
return subprocess.run(
[
str(clean_venv.python),
"-m",
"aipass.hooks.apps.handlers.bridges.claude",
"PreToolUse:rm_gate",
],
input=payload,
cwd=str(work),
env=env,
capture_output=True,
text=True,
timeout=60,
)
def _hook_env(work: Path) -> dict:
"""Build a minimal env for the hook subprocess, with AIPASS_HOME isolated."""
import os
env = dict(os.environ)
env["AIPASS_HOME"] = str(work)
return env
def _engine_log(clean_venv: CleanVenv) -> Path | None:
"""Glob the installed package for aipass/hooks/logs/engine.jsonl."""
hits = sorted(clean_venv.site_packages.glob("aipass/hooks/logs/engine.jsonl"))
return hits[0] if hits else None
@pytest.fixture(scope="module")
def hook_workspace(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A tmp workspace holding an isolated .aipass/hooks.json with only rm_gate."""
work = tmp_path_factory.mktemp("hook_ws")
aipass_dir = work / ".aipass"
aipass_dir.mkdir(parents=True, exist_ok=True)
(aipass_dir / "hooks.json").write_text(json.dumps(_RM_GATE_CONFIG), encoding="utf-8")
return work
def test_t2a_rm_gate_blocks(clean_venv: CleanVenv, hook_workspace: Path) -> None:
"""rm -rf is blocked via {"decision":"block"} on STDOUT with exit code 0.
Corrected contract (NOT exit 2): the bridge writes the engine's block JSON
to stdout and exits 0.
"""
sentinel = f"e2e-block-{uuid.uuid4()}"
proc = _fire_hook(clean_venv, hook_workspace, "rm -rf /tmp/x", sentinel)
assert proc.returncode == 0, f"expected exit 0, got {proc.returncode}. stderr:\n{proc.stderr}"
decision = json.loads(proc.stdout)
assert decision.get("decision") == "block", f"stdout was: {proc.stdout!r}"
# Oracle: the engine log must hold a record with OUR sentinel AND hook==rm_gate.
log = _engine_log(clean_venv)
assert log is not None and log.is_file(), "engine.jsonl not found in installed package"
assert _log_has_sentinel_for_hook(log, sentinel, "rm_gate"), (
f"no engine.jsonl record found for sentinel {sentinel} + hook rm_gate"
)
def _log_has_sentinel_for_hook(log: Path, sentinel: str, hook: str) -> bool:
"""Return True if any JSONL record has this agent_id AND this hook name.
Asserts on the sentinel, never line counts — the log is shared with live
sessions.
"""
for line in log.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
if rec.get("agent_id") == sentinel and rec.get("hook") == hook:
return True
return False
def test_t2a_rm_gate_allows_echo(clean_venv: CleanVenv, hook_workspace: Path) -> None:
"""A harmless command is allowed: exit 0 and decision is not "block"."""
sentinel = f"e2e-allow-{uuid.uuid4()}"
proc = _fire_hook(clean_venv, hook_workspace, "echo hi", sentinel)
assert proc.returncode == 0, proc.stderr
if proc.stdout.strip():
try:
decision = json.loads(proc.stdout)
assert decision.get("decision") != "block", f"unexpected block: {proc.stdout!r}"
except json.JSONDecodeError:
# Non-JSON / empty output is also a valid "allow" signal.
pass
# ===========================================================================
# TIER 3 — drone routing (real resolve -> subprocess -> execute)
# ===========================================================================
@pytest.fixture(scope="module")
def routing_root(clean_venv: CleanVenv) -> Iterator[Path]:
"""Generate a minimal registry at the repo root pointing at real branches.
The repo's own AIPASS_REGISTRY.json is mode 0600 / host-absolute and won't
relocate (its paths are the developer's home, absent in CI), so we GENERATE
a minimal registry — faithful to what setup.sh produces — pointing at REAL
branch dirs under this checkout.
drone validates path containment against the registry's PARENT dir, so the
registry must sit at the repo root for the ``src/aipass/*`` branch paths to
validate. We back up any existing registry and restore it on teardown so a
local run never mutates the working tree permanently.
We target ``ai_mail`` — a real BRANCH (not an in-process drone module) — so
``drone @ai_mail --help`` exercises the full resolve -> subprocess ->
execute path, the proof we never previously got green.
"""
src = REPO_ROOT / "src" / "aipass"
registry = {
"metadata": {"name": "AIPASS", "version": "1.0.0", "total_branches": 3},
"branches": [
{"name": "ai_mail", "path": str(src / "ai_mail"), "status": "active"},
{"name": "seedgo", "path": str(src / "seedgo"), "status": "active"},
{"name": "drone", "path": str(src / "drone"), "status": "active"},
],
}
registry_path = REPO_ROOT / "AIPASS_REGISTRY.json"
backup = registry_path.read_bytes() if registry_path.exists() else None
try:
registry_path.write_text(json.dumps(registry, indent=2), encoding="utf-8")
yield REPO_ROOT
finally:
if backup is not None:
registry_path.write_bytes(backup)
elif registry_path.exists():
registry_path.unlink()
def _drone_env() -> dict:
"""Env for drone subprocesses (inherits PATH etc.)."""
import os
return dict(os.environ)
def test_t3_drone_systems_lists_branch(clean_venv: CleanVenv, routing_root: Path) -> None:
"""``drone systems`` reads the registry and lists a known branch."""
proc = _run(
[str(clean_venv.drone), "systems"],
cwd=str(routing_root),
env=_drone_env(),
)
assert proc.returncode == 0, f"drone systems failed:\n{proc.stdout}\n{proc.stderr}"
out = proc.stdout.lower()
assert "seedgo" in out or "ai_mail" in out, f"no known branch listed:\n{proc.stdout}"
def test_t3_drone_routes_to_real_branch(clean_venv: CleanVenv, routing_root: Path) -> None:
"""``drone @ai_mail --help`` resolves -> subprocesses -> returns help text.
This is the real integration proof: ai_mail is a registry branch (not an
in-process drone module), so a non-empty help body proves drone resolved
the @name, found apps/ai_mail.py, ran it in a subprocess, and captured its
output.
"""
proc = _run(
[str(clean_venv.drone), "@ai_mail", "--help"],
cwd=str(routing_root),
env=_drone_env(),
)
combined = (proc.stdout + proc.stderr).lower()
assert proc.returncode == 0, f"drone @ai_mail --help failed:\n{proc.stdout}\n{proc.stderr}"
assert proc.stdout.strip(), f"no help text returned:\nSTDOUT:\n{proc.stdout}\nSTDERR:\n{proc.stderr}"
assert "ai_mail" in combined or "mail" in combined, f"help text unexpected:\n{proc.stdout}"