diff --git a/.aipass/hooks.json b/.aipass/hooks.json index 71f9fa42..b7e2d45f 100644 --- a/.aipass/hooks.json +++ b/.aipass/hooks.json @@ -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", diff --git a/.aipass/project_hooks.json b/.aipass/project_hooks.json index 4059135c..2521ade5 100644 --- a/.aipass/project_hooks.json +++ b/.aipass/project_hooks.json @@ -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" } }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac490eb0..8f427a39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/e2e-wheel.yml b/.github/workflows/e2e-wheel.yml new file mode 100644 index 00000000..727027f2 --- /dev/null +++ b/.github/workflows/e2e-wheel.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index adcf75c2..df795222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 ` 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 ` + 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 diff --git a/Dockerfile.test b/Dockerfile.test index 445e0847..3cd9753f 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -1,4 +1,4 @@ -FROM ubuntu:24.04 +FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae77ff1dc54 ENV DEBIAN_FRONTEND=noninteractive diff --git a/README.md b/README.md index 12593f47..947edb80 100644 --- a/README.md +++ b/README.md @@ -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)

AIPass

Persistent Agent Workspace

AI agents that remember, collaborate, and never start from zero.

-

- OpenSSF Scorecard -

![demo](assets/demo.gif) diff --git a/pyproject.toml b/pyproject.toml index c708d42d..fa59439f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/setup.sh b/setup.sh index 3f1e9c18..abc55d35 100755 --- a/setup.sh +++ b/setup.sh @@ -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*)", diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index 8a9e3fad..46857638 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -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 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" } ] } diff --git a/src/aipass/aipass/apps/aipass.py b/src/aipass/aipass/apps/aipass.py index a3a8427f..1c4a5daf 100644 --- a/src/aipass/aipass/apps/aipass.py +++ b/src/aipass/aipass/apps/aipass.py @@ -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 # ============================================================================= diff --git a/src/aipass/aipass/apps/handlers/init/bootstrap.py b/src/aipass/aipass/apps/handlers/init/bootstrap.py index 948fbfa1..034ad7ec 100644 --- a/src/aipass/aipass/apps/handlers/init/bootstrap.py +++ b/src/aipass/aipass/apps/handlers/init/bootstrap.py @@ -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", diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index 15b67944..ac8d0943 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -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 diff --git a/src/aipass/aipass/apps/modules/doctor_fix.py b/src/aipass/aipass/apps/modules/doctor_fix.py index 750ef1ba..2427e7ce 100644 --- a/src/aipass/aipass/apps/modules/doctor_fix.py +++ b/src/aipass/aipass/apps/modules/doctor_fix.py @@ -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 diff --git a/src/aipass/aipass/apps/modules/doctor_wire.py b/src/aipass/aipass/apps/modules/doctor_wire.py index 69281dcf..ca639f5a 100644 --- a/src/aipass/aipass/apps/modules/doctor_wire.py +++ b/src/aipass/aipass/apps/modules/doctor_wire.py @@ -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"): diff --git a/src/aipass/aipass/apps/modules/handoff.py b/src/aipass/aipass/apps/modules/handoff.py index 038fee42..7056a287 100644 --- a/src/aipass/aipass/apps/modules/handoff.py +++ b/src/aipass/aipass/apps/modules/handoff.py @@ -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: diff --git a/src/aipass/aipass/apps/modules/help_chat.py b/src/aipass/aipass/apps/modules/help_chat.py index 68cc98fa..7b75166b 100644 --- a/src/aipass/aipass/apps/modules/help_chat.py +++ b/src/aipass/aipass/apps/modules/help_chat.py @@ -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 [/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 diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index 2bb75248..2077aac4 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -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 diff --git a/src/aipass/aipass/apps/modules/profile.py b/src/aipass/aipass/apps/modules/profile.py index d3b637f6..f029d252 100644 --- a/src/aipass/aipass/apps/modules/profile.py +++ b/src/aipass/aipass/apps/modules/profile.py @@ -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 ") diff --git a/src/aipass/aipass/tests/test_doctor.py b/src/aipass/aipass/tests/test_doctor.py index b7057f85..c59a8bf2 100644 --- a/src/aipass/aipass/tests/test_doctor.py +++ b/src/aipass/aipass/tests/test_doctor.py @@ -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 diff --git a/src/aipass/aipass/tests/test_doctor_fix.py b/src/aipass/aipass/tests/test_doctor_fix.py index 82dbf530..ed6c23c6 100644 --- a/src/aipass/aipass/tests/test_doctor_fix.py +++ b/src/aipass/aipass/tests/test_doctor_fix.py @@ -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.""" diff --git a/src/aipass/aipass/tests/test_help_chat.py b/src/aipass/aipass/tests/test_help_chat.py index 7d920291..ed014412 100644 --- a/src/aipass/aipass/tests/test_help_chat.py +++ b/src/aipass/aipass/tests/test_help_chat.py @@ -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.""" diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index 47d09bc9..e0817c1c 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -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: diff --git a/src/aipass/aipass/tests/test_json_handler.py b/src/aipass/aipass/tests/test_json_handler.py index dbbc6141..4bb54b77 100644 --- a/src/aipass/aipass/tests/test_json_handler.py +++ b/src/aipass/aipass/tests/test_json_handler.py @@ -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): diff --git a/src/aipass/aipass/tests/test_profile.py b/src/aipass/aipass/tests/test_profile.py index 16ed0f61..7fb8774e 100644 --- a/src/aipass/aipass/tests/test_profile.py +++ b/src/aipass/aipass/tests/test_profile.py @@ -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"): diff --git a/src/aipass/api/requirements.project.txt b/src/aipass/api/requirements.project.txt index 2cf77723..03fbdbcd 100644 --- a/src/aipass/api/requirements.project.txt +++ b/src/aipass/api/requirements.project.txt @@ -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 diff --git a/src/aipass/devpulse/prototypes/crosos_e2e_proto.sh b/src/aipass/devpulse/prototypes/crosos_e2e_proto.sh new file mode 100644 index 00000000..48ff24a4 --- /dev/null +++ b/src/aipass/devpulse/prototypes/crosos_e2e_proto.sh @@ -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" <&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:-}" + +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 diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index 3a634e38..d3103bfa 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -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", diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py index 0343a515..386e7261 100644 --- a/src/aipass/drone/apps/drone.py +++ b/src/aipass/drone/apps/drone.py @@ -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 ", "Remove a custom command") + table.add_row("rm [...]", "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 [...]`` — 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) diff --git a/src/aipass/drone/apps/handlers/git/branches_handler.py b/src/aipass/drone/apps/handlers/git/branches_handler.py index c632c384..16d5487f 100644 --- a/src/aipass/drone/apps/handlers/git/branches_handler.py +++ b/src/aipass/drone/apps/handlers/git/branches_handler.py @@ -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} diff --git a/src/aipass/drone/apps/handlers/registry_handler.py b/src/aipass/drone/apps/handlers/registry_handler.py index 382e59b9..d1459d07 100644 --- a/src/aipass/drone/apps/handlers/registry_handler.py +++ b/src/aipass/drone/apps/handlers/registry_handler.py @@ -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 diff --git a/src/aipass/drone/apps/handlers/rm_handler.py b/src/aipass/drone/apps/handlers/rm_handler.py new file mode 100644 index 00000000..0c7a2ab2 --- /dev/null +++ b/src/aipass/drone/apps/handlers/rm_handler.py @@ -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 diff --git a/src/aipass/drone/apps/modules/git_module.py b/src/aipass/drone/apps/modules/git_module.py index f3a7f523..e8cfe599 100644 --- a/src/aipass/drone/apps/modules/git_module.py +++ b/src/aipass/drone/apps/modules/git_module.py @@ -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 [--all | files] Commit changes (selective or --all)\n" " checkout 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 [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()) diff --git a/src/aipass/drone/apps/modules/resolver.py b/src/aipass/drone/apps/modules/resolver.py index 7a366637..6b161526 100644 --- a/src/aipass/drone/apps/modules/resolver.py +++ b/src/aipass/drone/apps/modules/resolver.py @@ -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): diff --git a/src/aipass/drone/apps/modules/rm.py b/src/aipass/drone/apps/modules/rm.py new file mode 100644 index 00000000..572aff2f --- /dev/null +++ b/src/aipass/drone/apps/modules/rm.py @@ -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 [...]") + 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]") diff --git a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py index 3e132426..0ff1a214 100644 --- a/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py +++ b/src/aipass/drone/apps/plugins/devpulse_ops/merge_plugin.py @@ -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 diff --git a/src/aipass/drone/cli.py b/src/aipass/drone/cli.py index b403da0f..78331765 100644 --- a/src/aipass/drone/cli.py +++ b/src/aipass/drone/cli.py @@ -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 diff --git a/src/aipass/drone/tests/test_git_module.py b/src/aipass/drone/tests/test_git_module.py index 16e2e830..fff1eb6f 100644 --- a/src/aipass/drone/tests/test_git_module.py +++ b/src/aipass/drone/tests/test_git_module.py @@ -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"] diff --git a/src/aipass/drone/tests/test_resolver.py b/src/aipass/drone/tests/test_resolver.py index 6becf4f8..cf828479 100644 --- a/src/aipass/drone/tests/test_resolver.py +++ b/src/aipass/drone/tests/test_resolver.py @@ -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() diff --git a/src/aipass/drone/tests/test_rm.py b/src/aipass/drone/tests/test_rm.py new file mode 100644 index 00000000..159e4cbc --- /dev/null +++ b/src/aipass/drone/tests/test_rm.py @@ -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/ 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/.""" + 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/ 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/ 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): + """/.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/ 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() diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py index ca740dca..4849cd3b 100644 --- a/src/aipass/flow/apps/handlers/dashboard/push_central.py +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -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, }, ) diff --git a/src/aipass/flow/tests/test_push_central.py b/src/aipass/flow/tests/test_push_central.py index 28eccdf1..e4c9d743 100644 --- a/src/aipass/flow/tests/test_push_central.py +++ b/src/aipass/flow/tests/test_push_central.py @@ -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" diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index 0cc5435b..f08d720a 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -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."}, diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index aac8db71..b108928c 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -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* --- diff --git a/src/aipass/hooks/apps/handlers/security/rm_gate.py b/src/aipass/hooks/apps/handlers/security/rm_gate.py new file mode 100644 index 00000000..f51cc878 --- /dev/null +++ b/src/aipass/hooks/apps/handlers/security/rm_gate.py @@ -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 # 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 diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index fa615f90..7f656ae9 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -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"]), diff --git a/src/aipass/hooks/tests/test_rm_gate.py b/src/aipass/hooks/tests/test_rm_gate.py new file mode 100644 index 00000000..78633f33 --- /dev/null +++ b/src/aipass/hooks/tests/test_rm_gate.py @@ -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")) diff --git a/src/aipass/prax/apps/handlers/dashboard/operations.py b/src/aipass/prax/apps/handlers/dashboard/operations.py index 6e5d443f..ae553b54 100644 --- a/src/aipass/prax/apps/handlers/dashboard/operations.py +++ b/src/aipass/prax/apps/handlers/dashboard/operations.py @@ -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", } diff --git a/tests/CROSS_OS_TESTING.md b/tests/CROSS_OS_TESTING.md new file mode 100644 index 00000000..367c7b2c --- /dev/null +++ b/tests/CROSS_OS_TESTING.md @@ -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 @ --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 @` · `drone activate @` | +| **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 @` (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.* diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..b475cd33 --- /dev/null +++ b/tests/e2e/conftest.py @@ -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: /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, + ) diff --git a/tests/e2e/test_wiring.py b/tests/e2e/test_wiring.py new file mode 100644 index 00000000..b470656a --- /dev/null +++ b/tests/e2e/test_wiring.py @@ -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 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}"