From e97130ffbc2b55a318218ea4447106bb85674c44 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 11:27:35 -0700 Subject: [PATCH 01/20] =?UTF-8?q?docs(playbook)+memory:=20git=20law=20?= =?UTF-8?q?=E2=80=94=20local=3Dtruth,=20never=20checkout=20main=20/=20move?= =?UTF-8?q?=20heads=20lightly;=20squash=20vs=20ff=20realign=20corrected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../templates/playbook_plans/sunday_merge.md | 44 ++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/aipass/flow/templates/playbook_plans/sunday_merge.md b/src/aipass/flow/templates/playbook_plans/sunday_merge.md index 7fbec6bb..261fb6d9 100644 --- a/src/aipass/flow/templates/playbook_plans/sunday_merge.md +++ b/src/aipass/flow/templates/playbook_plans/sunday_merge.md @@ -21,6 +21,38 @@ the vectorized trail. Close when done. --- +## The Law (read first — these prevent the recurring git scares) + +1. **Local files on `dev` are the truth.** Git is just a transfer mechanism to the + remote. We live on `dev`, permanently. +2. **`main` is a remote push-target, nothing more.** Local `main` can be 7000 commits + behind — it does **not** matter. The only thing ever done to local main is a + *cosmetic* pull. Never build on it, never read files from it. +3. **NEVER check out `main`, and NEVER reset a HEAD.** Checking out main swaps your whole + working tree to main's (often stale) content — that is the file-revert scare. The flow + never needs it. Stay on `dev`. + - To reference main for a tag, use **`origin/main`** (the remote ref), never local main. + - ⚠️ Your IDE's "switch to main" / "sync main" button does a `git checkout main` — **don't + click it.** If you want local main fresh, `drone @git sync` **from dev** is safe (it + stays on dev); the IDE button is not. + +### Why `dev` shows "behind main" after a merge — and why it's fine + +`drone @git merge` does a **squash** merge: GitHub bundles dev's commits into **one brand-new +commit** on main. Your `dev` branch keeps its **original** commits. Git compares by commit +*identity*, not content — so it sees "main has 1 commit dev doesn't" → the UI shows +**"dev is 1 behind main."** + +- **The files are identical. It is 100% cosmetic. You can always move forward** — the next + `dev-pr` compares real file changes and works perfectly regardless of this graph quirk. +- Because it's a squash (not a fast-forward), **`dev` is NOT an ancestor of `main`**, so + `git merge --ff-only main` will **fail**. Do not use it after a squash merge. +- **Optional**, only if you want the graph to show dev even/ahead: back-merge with + `git merge origin/main` while on dev (via `drone @git`) — creates a merge commit, dev moves + *ahead*, push normally (NO force, NO reset). **Do NOT rebase** (rewrites dev, needs force-push). + +--- + ## 1. Pre-flight - [ ] On `dev`, working tree understood: `drone @git status --all` @@ -62,8 +94,9 @@ The PR gate (verified against `.github/workflows/`): ## 6. Post-merge realign -- [ ] Pull main locally: `drone @git sync` -- [ ] If merged via GitHub UI (bypassing `drone @git merge`), fast-forward dev to main so dev doesn't fall behind / revert main-only commits (e.g. Dependabot): dev is an ancestor → `git merge --ff-only main` is clean (via `drone @git`) +- [ ] **Expect `dev` to show "1 behind main" — that's the squash artifact, it's cosmetic, keep going.** See "Why dev shows behind main" up top. Do NOT reach for `--ff-only` (it fails after a squash) or a rebase/reset. +- [ ] **Stay on `dev`. Do not check out `main`.** Local main being behind is fine and expected — it's a push-target, not a thing to maintain. +- [ ] (Optional, cosmetic only) If you want the graph to show dev even/ahead: back-merge `git merge origin/main` on dev (via `drone @git`), then normal push. Never rebase, never reset, never checkout main. - [ ] Dependabot / other PRs targeting main: they go green once main has the fix + bots rebase — check after the push ## 7. Release tag (only if cutting a release) @@ -84,10 +117,11 @@ How the release fires (verified `publish.yml`): a `v*` **git tag push** runs bui Steps: - [ ] Bump the version in **BOTH** files (they must match the tag, or `__version__` ships wrong): `pyproject.toml` `version` **and** `src/aipass/__init__.py` `__version__`. Do it **on dev so it rides into the PR** (then main's merge commit carries the right version). ⚠️ These two drift easily — `__init__.py` is the one that gets forgotten. - [ ] Confirm the CHANGELOG top section is the release notes you want -- [ ] After merge + `drone @git sync`, get the **real** merged-main sha: `git rev-parse HEAD`. **Verify the version on that exact commit BEFORE tagging:** `git show HEAD:pyproject.toml | grep '^version'` and `git show HEAD:src/aipass/__init__.py | grep __version__` — both must equal the tag. -- [ ] **Push the tag — MANUAL (drone has no `tag` verb; devpulse can't push tags):** user runs it, via `!` or terminal. **Two SEPARATE lines, paste the real sha (no `<…>` placeholders, no `&&`):** +- [ ] Get the **real** merged-main sha from the **remote ref** (stay on dev — never checkout main): `git fetch origin` then `git rev-parse origin/main`. **Verify the version on that exact commit BEFORE tagging:** `git show origin/main:pyproject.toml | grep '^version'` and `git show origin/main:src/aipass/__init__.py | grep __version__` — both must equal the tag. (If the user merged via the GitHub UI, their local `main` ref is stale until `git fetch` — always fetch first, always tag `origin/main`, never local `main`.) +- [ ] **Push the tag — MANUAL (drone has no `tag` verb; devpulse can't push tags):** user runs it, via `!` or terminal. **Tag the remote ref directly so a stale local main can't poison it. Separate lines, no `&&`:** ``` - git tag v + git fetch origin + git tag v origin/main git push origin v ``` - [ ] Verify PyPI shows the new version + the GitHub Release appeared (`curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`) From ff9cc3f3b537da7fd51deec1947e10a124945782 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 11:29:48 -0700 Subject: [PATCH 02/20] =?UTF-8?q?docs(playbook):=20strengthen=20=E2=80=94?= =?UTF-8?q?=20never=20move=20HEAD=20lightly=20(Patrick's=20rule)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flow/templates/playbook_plans/sunday_merge.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/aipass/flow/templates/playbook_plans/sunday_merge.md b/src/aipass/flow/templates/playbook_plans/sunday_merge.md index 261fb6d9..3649beab 100644 --- a/src/aipass/flow/templates/playbook_plans/sunday_merge.md +++ b/src/aipass/flow/templates/playbook_plans/sunday_merge.md @@ -28,9 +28,18 @@ the vectorized trail. Close when done. 2. **`main` is a remote push-target, nothing more.** Local `main` can be 7000 commits behind — it does **not** matter. The only thing ever done to local main is a *cosmetic* pull. Never build on it, never read files from it. -3. **NEVER check out `main`, and NEVER reset a HEAD.** Checking out main swaps your whole - working tree to main's (often stale) content — that is the file-revert scare. The flow - never needs it. Stay on `dev`. +3. **NEVER move HEAD lightly.** Any HEAD move — `checkout` (switch branch), `reset` + (move backward), `rebase` onto a different base — changes what's in the working tree and + *always* causes confusion, even when the work is technically safe. Treat every HEAD move + as a deliberate, narrated step, never a reflex. In normal flow you should rarely move HEAD + at all: you commit (HEAD advances on dev) and push. That's it. + - **NEVER check out `main`, NEVER reset a HEAD.** Checking out main swaps your whole + working tree to main's (often stale) content — that's the file-revert scare. The flow + never needs it. Stay on `dev`. + - The only routine, safe HEAD advance is a **fast-forward of dev** when dev is purely + behind main (`git rev-list --left-right --count dev...origin/main` shows `0` ahead) — + done via `drone @git sync` **from dev** (stays on dev). If dev shows any commits *ahead*, + it's not a pure FF — stop and think, don't force it. - To reference main for a tag, use **`origin/main`** (the remote ref), never local main. - ⚠️ Your IDE's "switch to main" / "sync main" button does a `git checkout main` — **don't click it.** If you want local main fresh, `drone @git sync` **from dev** is safe (it From a53ea93b1789b1b45fa77261999c12d384e76b04 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 21:27:23 -0700 Subject: [PATCH 03/20] =?UTF-8?q?fix(ai=5Fmail):=20preserve=20multi-line?= =?UTF-8?q?=20reply/send=20bodies=20=E2=80=94=20join=20args[1:]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reply and send silently truncated multi-line bodies to the first CLI arg. handle_reply(args[1]) and parse_send_args(rest[1]) dropped args[2:]/rest[2:] when a body word-split into multiple args. Now join all remaining args. Backwards-compatible; single-arg messages unchanged. +6 tests (718 total). Found via @hooks replies arriving as first-line-only (60/48 chars). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai_mail/apps/handlers/email/send_args.py | 2 +- src/aipass/ai_mail/apps/modules/email.py | 3 +- .../docs/s84_multiline_reply_truncation.md | 47 +++++++++++++++ src/aipass/ai_mail/tests/test_email_module.py | 59 +++++++++++++++++++ src/aipass/ai_mail/tests/test_reply.py | 35 +++++++++++ src/aipass/ai_mail/tests/test_send_helpers.py | 31 ++++++++++ 6 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md diff --git a/src/aipass/ai_mail/apps/handlers/email/send_args.py b/src/aipass/ai_mail/apps/handlers/email/send_args.py index f03010ca..77752b14 100644 --- a/src/aipass/ai_mail/apps/handlers/email/send_args.py +++ b/src/aipass/ai_mail/apps/handlers/email/send_args.py @@ -103,7 +103,7 @@ def parse_send_args(args: List[str]) -> Dict[str, Any]: if recipients and len(rest) >= 2: mode = "direct" subject = rest[0] - message = rest[1] + message = " ".join(rest[1:]) elif not recipients and not rest: mode = "interactive" subject = None diff --git a/src/aipass/ai_mail/apps/modules/email.py b/src/aipass/ai_mail/apps/modules/email.py index 90e9e28b..fd3c5372 100644 --- a/src/aipass/ai_mail/apps/modules/email.py +++ b/src/aipass/ai_mail/apps/modules/email.py @@ -277,7 +277,8 @@ def handle_reply(args: List[str]) -> bool: if not original: error(f"Message not found: {args[0]}") return True - success, message, reply_id = send_reply(branch_path, original, args[1]) + reply_message = " ".join(args[1:]) + success, message, reply_id = send_reply(branch_path, original, reply_message) if success: console.print(f"[green]{message}[/green]") else: diff --git a/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md b/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md new file mode 100644 index 00000000..474564f6 --- /dev/null +++ b/src/aipass/ai_mail/docs/s84_multiline_reply_truncation.md @@ -0,0 +1,47 @@ +# S84: Multi-line Reply Body Truncation — Root Cause & Fix + +## Bug + +Reply and send commands silently truncate multi-line message bodies to the first argument. + +**Reported:** @devpulse dispatch 7b6a70b9 (2026-06-08) +**Evidence:** @hooks sent two replies with full multi-line bodies; both arrived in devpulse inbox as first line only (60 chars / 48 chars). @memory's reply arrived intact (951 chars). + +## Root Cause + +Two code paths only captured the second positional CLI argument as the message body, dropping everything after it: + +1. **`email.py:handle_reply`** (line 280): + ```python + send_reply(branch_path, original, args[1]) # args[2:] silently dropped + ``` + +2. **`send_args.py:parse_send_args`** (line 106): + ```python + message = rest[1] # rest[2:] silently dropped + ``` + +When an agent's bash command produces multiple args from a message body (shell word-splitting on unquoted text, or subprocess argument handling), only the first segment survives. The rest is discarded with no warning. + +The entire Python delivery pipeline (reply.py, delivery.py, create.py) handles multi-line strings correctly — the truncation happens at the CLI argument boundary. + +## Why @memory Worked + +@memory's reply body was a single properly-quoted argument that arrived as one `args[1]` entry. @hooks' body was split into multiple args (likely unquoted or shell-expanded), so only the first piece reached `send_reply()`. + +## Fix + +Both locations now join all remaining args: + +1. **`email.py:handle_reply`**: `reply_message = " ".join(args[1:])` +2. **`send_args.py:parse_send_args`**: `message = " ".join(rest[1:])` + +Backwards-compatible: single-arg messages pass through unchanged. Multi-arg messages are reconstructed. + +## Tests Added (6) + +- `test_reply.py`: `test_send_reply_multiline_body_preserved` — multi-line body stored intact in delivery and sent copy +- `test_email_module.py`: `TestHandleReplyMultiArg` — handle_reply joins split args; single arg unchanged +- `test_send_helpers.py`: 3 tests — parse_send_args joins split message; single arg unchanged; embedded newlines preserved + +718 tests pass (712 + 6 new). diff --git a/src/aipass/ai_mail/tests/test_email_module.py b/src/aipass/ai_mail/tests/test_email_module.py index fd719161..2f683ecf 100644 --- a/src/aipass/ai_mail/tests/test_email_module.py +++ b/src/aipass/ai_mail/tests/test_email_module.py @@ -1761,3 +1761,62 @@ class TestSendInteractiveExtended: assert result is True assert any("@alpha" in p for p in printed) assert any("sent" in p.lower() for p in printed) + + +class TestHandleReplyMultiArg: + """Regression tests for multi-line reply body truncation (S84 fix).""" + + def test_reply_joins_split_args_into_body(self, tmp_path, monkeypatch): + """When shell splits body into multiple args, all are joined into message.""" + original = {"id": "msg1", "from": "@devpulse", "subject": "test dispatch"} + captured_msg = [] + + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email._resolve_branch_path", + lambda: tmp_path, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.get_email_by_id", + lambda inbox_file, msg_id: original, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.send_reply", + lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:], + ) + mock_console = MagicMock() + monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console) + _write_inbox(tmp_path) + + from aipass.ai_mail.apps.modules.email import handle_reply + + result = handle_reply(["msg1", "Line one", "Line two", "Line three"]) + assert result is True + assert len(captured_msg) == 1 + assert captured_msg[0] == "Line one Line two Line three" + + def test_reply_single_arg_unchanged(self, tmp_path, monkeypatch): + """Single-arg reply body remains unchanged (no extra spaces).""" + original = {"id": "msg1", "from": "@devpulse", "subject": "test"} + captured_msg = [] + + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email._resolve_branch_path", + lambda: tmp_path, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.get_email_by_id", + lambda inbox_file, msg_id: original, + ) + monkeypatch.setattr( + "aipass.ai_mail.apps.modules.email.send_reply", + lambda bp, orig, msg: (captured_msg.append(msg), "Reply sent", "r1")[1:], + ) + mock_console = MagicMock() + monkeypatch.setattr("aipass.ai_mail.apps.modules.email.console", mock_console) + _write_inbox(tmp_path) + + from aipass.ai_mail.apps.modules.email import handle_reply + + result = handle_reply(["msg1", "Complete single-line reply"]) + assert result is True + assert captured_msg[0] == "Complete single-line reply" diff --git a/src/aipass/ai_mail/tests/test_reply.py b/src/aipass/ai_mail/tests/test_reply.py index f289c87c..77fd7401 100644 --- a/src/aipass/ai_mail/tests/test_reply.py +++ b/src/aipass/ai_mail/tests/test_reply.py @@ -268,3 +268,38 @@ def test_send_reply_re_prefix_not_duplicated(tmp_path): assert success is True # Should keep "RE: Already replied", not "RE: RE: Already replied" assert deliver_calls[0][1]["subject"] == "RE: Already replied" + + +def test_send_reply_multiline_body_preserved(tmp_path): + """Multi-line reply body is stored intact, not truncated to first line.""" + from_branch_path = tmp_path / "hooks" + from_branch_path.mkdir() + sender_info = {"email": "@hooks", "name": "HOOKS"} + target_branch = {"email": "@devpulse", "name": "DEVPULSE", "path": str(tmp_path / "devpulse")} + original = _make_original_email() + + deliver_calls = [] + + def mock_deliver(to_branch, email_data): + deliver_calls.append((to_branch, email_data)) + return (True, "") + + multiline_body = "Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding" + + with ( + patch(_PATCH_BRANCH_DETECTION, return_value=sender_info), + patch(_PATCH_DELIVERY, side_effect=mock_deliver), + patch(_PATCH_ALL_BRANCHES, return_value=[target_branch]), + patch(_PATCH_CLOSE_ARCHIVE, return_value=(True, "closed")), + ): + success, _message, _reply_id = send_reply(from_branch_path, original, multiline_body) + + assert success is True + assert deliver_calls[0][1]["message"] == multiline_body + + sent_folder = from_branch_path / ".ai_mail.local" / "sent" + sent_files = list(sent_folder.glob("*.json")) + assert len(sent_files) == 1 + with open(sent_files[0], "r", encoding="utf-8") as f: + sent_data = json.load(f) + assert sent_data["message"] == multiline_body diff --git a/src/aipass/ai_mail/tests/test_send_helpers.py b/src/aipass/ai_mail/tests/test_send_helpers.py index 5b16014b..3e467f7a 100644 --- a/src/aipass/ai_mail/tests/test_send_helpers.py +++ b/src/aipass/ai_mail/tests/test_send_helpers.py @@ -357,3 +357,34 @@ def test_resolve_dispatch_target_tilde_path(): result = resolve_dispatch_target("~/Projects/flow", True, get_branch_info_fn=None) assert result == "@flow" + + +# ---- parse_send_args multi-arg message tests (S84 fix) -------- + + +def test_parse_send_args_joins_split_message(): + """When message body is split into multiple args, all are joined.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + result = parse_send_args(["@target", "Subject", "Line one", "Line two", "Line three"]) + assert result["mode"] == "direct" + assert result["subject"] == "Subject" + assert result["message"] == "Line one Line two Line three" + + +def test_parse_send_args_single_message_unchanged(): + """Single message arg is not altered.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + result = parse_send_args(["@target", "Subject", "Complete body here"]) + assert result["mode"] == "direct" + assert result["message"] == "Complete body here" + + +def test_parse_send_args_multiline_body_preserved(): + """A single arg with embedded newlines passes through intact.""" + from aipass.ai_mail.apps.handlers.email.send_args import parse_send_args + + body = "First line\nSecond line\nThird line" + result = parse_send_args(["@target", "Subject", body]) + assert result["message"] == body From d24887b7f5de0c60c2461b1d3f2ec51ab30b9c8f Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 23:07:24 -0700 Subject: [PATCH 04/20] feat(memory): template-conformance normalize + auto-heal on triggers (DPLAN-0200) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory files now reconcile to template, not just clean known fields: - normalize.py: rewrote from field-targeted cleanup to template-conformance — strips ANY key not in the template at every level (root/metadata/limits/status). Kills legacy orphans (old 'st' blocks, active_tasks, current_lines, max_lines) that field-targeted cleanup was blind to. Fixed _MEMORY_ROOT path (parents[3]) that silently skipped template loading in production. - memory_watcher.py: wired normalize_memory_file into both scan paths (check_and_rollover + on_modified) with a write-loop guard — drift now self-heals on every trigger, no manual run needed. - line_counter.py: stop writing current_lines (entry-count is the only metric). - LOCAL/OBSERVATIONS templates: removed line-count fields. Entry-count is the sole rollover metric, both files, all branches. 873 tests pass, seedgo 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../apps/handlers/monitor/memory_watcher.py | 50 ++++----- .../memory/apps/handlers/schema/normalize.py | 105 ++++++++++-------- .../apps/handlers/tracking/line_counter.py | 10 +- .../memory/templates/LOCAL.template.json | 3 +- .../templates/OBSERVATIONS.template.json | 5 +- src/aipass/memory/tests/test_handlers.py | 55 ++++----- src/aipass/memory/tests/test_watcher.py | 74 ++++++++++++ 7 files changed, 190 insertions(+), 112 deletions(-) diff --git a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py index 18905626..2ee993cf 100644 --- a/src/aipass/memory/apps/handlers/monitor/memory_watcher.py +++ b/src/aipass/memory/apps/handlers/monitor/memory_watcher.py @@ -43,11 +43,11 @@ except ImportError: FileSystemEventHandler = object # type: ignore[assignment,misc] logger.info("Optional dependency 'watchdog' not available") -# Handler imports (relative within package) -from aipass.memory.apps.handlers.tracking.line_counter import update_line_count -from aipass.memory.apps.handlers.monitor.detector import check_single_file -from aipass.prax.apps.modules.logger import get_system_logger -from aipass.memory.apps.handlers.json import json_handler +# Handler imports (relative within package — after conditional watchdog block) +from aipass.memory.apps.handlers.tracking.line_counter import update_line_count # noqa: E402 +from aipass.memory.apps.handlers.monitor.detector import check_single_file # noqa: E402 +from aipass.prax.apps.modules.logger import get_system_logger # noqa: E402 +from aipass.memory.apps.handlers.json import json_handler # noqa: E402 logger = get_system_logger() @@ -200,8 +200,6 @@ def check_and_rollover() -> Dict[str, Any]: return results # Check each branch for memory files over limit - # Also sync current_lines metadata to keep it accurate - lines_synced = 0 for branch_path in branch_paths: branch = Path(branch_path) # Find memory files in .trinity/ subdirectory @@ -213,33 +211,23 @@ def check_and_rollover() -> Dict[str, Any]: results["files_checked"] += 1 try: - line_count = len(memory_file.read_text(encoding="utf-8").splitlines()) + # Auto-heal: reconcile file against template (strips orphan keys) + from aipass.memory.apps.handlers.schema.normalize import normalize_memory_file - # Sync current_lines metadata if stale - try: - import json as _json - - _data = _json.loads(memory_file.read_text(encoding="utf-8")) - meta_lines = _data.get("document_metadata", {}).get("status", {}).get("current_lines") - if meta_lines != line_count: - sync_result = update_line_count(memory_file) - if sync_result.get("success"): - lines_synced += 1 - except Exception as e: - logger.warning(f"[memory_watcher] Non-critical metadata sync failed for {memory_file}: {e}") + normalize_memory_file(memory_file) # Use detector for trigger decision (handles both v1 line-based and v2 entry-count) from aipass.memory.apps.handlers.monitor.detector import _should_rollover - triggered, _, _, _, _ = _should_rollover(memory_file) + triggered, current_lines, _, _, _ = _should_rollover(memory_file) if triggered: results["files_over_limit"].append( - {"file": str(memory_file), "lines": line_count, "threshold": 0} + {"file": str(memory_file), "lines": current_lines, "threshold": 0} ) except Exception as e: logger.warning(f"[memory_watcher] Failed to read memory file {memory_file}: {e}") - results["lines_synced"] = lines_synced + results["lines_synced"] = 0 # Trigger rollover if any files are over limit if results["files_over_limit"]: @@ -537,19 +525,23 @@ class MemoryFileWatcher(FileSystemEventHandler): # type: ignore[misc] logger.info(f"[memory_watcher] Detected modification: {file_path.name}") - # Step 1: Update line count metadata + # Step 1: Auto-heal schema drift (strips orphan keys) + from aipass.memory.apps.handlers.schema.normalize import normalize_memory_file + + norm_result = normalize_memory_file(file_path) + if norm_result.get("changes"): + self._recent_modifications.add(file_key) + + # Step 2: Update health check metadata update_result = update_line_count(file_path) if not update_result["success"]: logger.error( - f"[memory_watcher] Failed to update line count for {file_path.name}: {update_result.get('error')}" + f"[memory_watcher] Failed to update metadata for {file_path.name}: {update_result.get('error')}" ) return - current_lines = update_result.get("lines", 0) - logger.info(f"[memory_watcher] Updated {file_path.name}: {current_lines} lines") - - # Step 2: Check if rollover needed + # Step 3: Check if rollover needed check_result = check_single_file(file_path) if not check_result["success"]: diff --git a/src/aipass/memory/apps/handlers/schema/normalize.py b/src/aipass/memory/apps/handlers/schema/normalize.py index 897c0d9d..bd39f6dc 100644 --- a/src/aipass/memory/apps/handlers/schema/normalize.py +++ b/src/aipass/memory/apps/handlers/schema/normalize.py @@ -1,24 +1,17 @@ # =================== AIPass ==================== # Name: normalize.py # Description: Memory File Schema Normalizer -# Version: 0.2.0 +# Version: 0.3.0 # Created: 2026-01-22 -# Modified: 2026-03-06 +# Modified: 2026-06-08 # ============================================= """ Memory File Schema Normalizer -Fixes inconsistent schema in memory JSON files: -1. Moves root-level 'limits' into document_metadata.limits -2. Removes redundant root-level 'status' -3. Removes auto_compress_at (redundant with max_lines) -4. Ensures document_metadata.status has current_lines - -Supports two schema versions: - v1 (schema_version <2.0.0): { "limits": { "max_lines": N } } - v2 (schema_version >=2.0.0): { "limits": { "max_sessions": N, "max_key_learnings": N, - "session_summary_max_chars": N, "learning_value_max_chars": N } } +Reconciles memory files against their canonical template schema. +Strips any key not present in the template at every level (root, +document_metadata, limits, status). Template = the whole truth. """ import json @@ -31,6 +24,36 @@ from aipass.memory.apps.handlers.json import json_handler logger = get_system_logger() +_MEMORY_ROOT = Path(__file__).parents[3] # normalize.py -> schema/ -> handlers/ -> apps/ -> memory/ + + +def _load_template(file_path: Path) -> Dict[str, Any] | None: + """Load the matching template for a memory file (local or observations).""" + templates_dir = _MEMORY_ROOT / "templates" + name = file_path.name.lower() + + if "local" in name: + tmpl_path = templates_dir / "LOCAL.template.json" + elif "observation" in name: + tmpl_path = templates_dir / "OBSERVATIONS.template.json" + else: + return None + + try: + with open(tmpl_path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + logger.warning(f"[normalize] Failed to load template {tmpl_path}: {e}") + return None + + +def _strip_orphan_keys(data: Dict, allowed: set, level_name: str, changes: list) -> None: + """Remove keys from data that aren't in the allowed set.""" + orphans = set(data.keys()) - allowed + for key in orphans: + del data[key] + changes.append(f"Stripped orphan '{key}' from {level_name}") + def _find_repo_root() -> Path: """Walk up from this file to find repo root (contains AIPASS_REGISTRY.json).""" @@ -43,7 +66,7 @@ def _find_repo_root() -> Path: def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, Any]: """ - Normalize schema for a single memory file. + Normalize a memory file against its canonical template. Args: file_path: Path to memory JSON file @@ -71,59 +94,53 @@ def normalize_memory_file(file_path: Path, dry_run: bool = False) -> Dict[str, A metadata = data["document_metadata"] - # 1. Move root 'limits' into document_metadata.limits + # Legacy fix: move root 'limits' into document_metadata.limits if "limits" in data and "limits" not in metadata: metadata["limits"] = data.pop("limits") changes.append("Moved root 'limits' into document_metadata") elif "limits" in data and "limits" in metadata: - # Both exist - merge, preferring document_metadata values root_limits = data.pop("limits") for key, val in root_limits.items(): if key not in metadata["limits"]: metadata["limits"][key] = val changes.append("Merged root 'limits' into document_metadata.limits") - # 2. Remove root 'status' (redundant) + # Legacy fix: move root 'status' into document_metadata.status if "status" in data: - root_status = data.pop("status") - # If document_metadata.status doesn't have current_lines, copy it + data.pop("status") if "status" not in metadata: metadata["status"] = {} - if "current_lines" not in metadata["status"] and "current_lines" in root_status: - metadata["status"]["current_lines"] = root_status["current_lines"] changes.append("Removed redundant root 'status'") - # 3. Remove auto_compress_at from document_metadata.status (redundant with max_lines) - if "status" in metadata and "auto_compress_at" in metadata["status"]: - del metadata["status"]["auto_compress_at"] - changes.append("Removed redundant 'auto_compress_at'") - - # 4. Remove unused limits fields (max_word_count, max_token_count - no code uses these) - # Preserve v2 fields: max_sessions, max_key_learnings, session_summary_max_chars, learning_value_max_chars, - # max_observations, max_lines, note - if "limits" in metadata: - for unused_field in ["max_word_count", "max_token_count"]: - if unused_field in metadata["limits"]: - del metadata["limits"][unused_field] - changes.append(f"Removed unused '{unused_field}'") - - # 4. Ensure status has required fields + # Ensure status has required fields if "status" not in metadata: metadata["status"] = {} - if "current_lines" not in metadata["status"]: - # Count actual lines - try: - with open(file_path, "r", encoding="utf-8") as f: - metadata["status"]["current_lines"] = len(f.readlines()) - changes.append("Added current_lines count") - except Exception as e: - logger.warning(f"[normalize] Failed to count lines in {file_path}: {e}") - if "last_health_check" not in metadata["status"]: metadata["status"]["last_health_check"] = datetime.now().strftime("%Y-%m-%d") changes.append("Added last_health_check") + # Template-conformance: strip orphan keys at every level + template = _load_template(file_path) + if template is not None: + tmpl_meta = template.get("document_metadata", {}) + + # Root level + _strip_orphan_keys(data, set(template.keys()), "root", changes) + + # document_metadata level + _strip_orphan_keys(metadata, set(tmpl_meta.keys()), "document_metadata", changes) + + # limits level + tmpl_limits = tmpl_meta.get("limits", {}) + if "limits" in metadata: + _strip_orphan_keys(metadata["limits"], set(tmpl_limits.keys()), "limits", changes) + + # status level + tmpl_status = tmpl_meta.get("status", {}) + if "status" in metadata: + _strip_orphan_keys(metadata["status"], set(tmpl_status.keys()), "status", changes) + # Write if changes made and not dry run if changes and not dry_run: try: diff --git a/src/aipass/memory/apps/handlers/tracking/line_counter.py b/src/aipass/memory/apps/handlers/tracking/line_counter.py index 1ae44339..a6464126 100644 --- a/src/aipass/memory/apps/handlers/tracking/line_counter.py +++ b/src/aipass/memory/apps/handlers/tracking/line_counter.py @@ -62,24 +62,20 @@ def _count_physical_lines(file_path: Path) -> int: def update_line_count(file_path: Path) -> Dict[str, Any]: """ - Update current_lines in document_metadata.status - - Reads file, counts lines, updates metadata field using safe json_handler. + Update health check metadata after file modification. Args: file_path: Path to memory JSON file Returns: - Dict with success status and updated line count + Dict with success status """ if not file_path.exists(): return {"success": False, "error": f"File not found: {file_path}"} - # Count lines line_count = _count_physical_lines(file_path) - # Update metadata using safe handler (atomic write) - result = update_metadata(file_path, current_lines=line_count, last_health_check=datetime.now().strftime("%Y-%m-%d")) + result = update_metadata(file_path, last_health_check=datetime.now().strftime("%Y-%m-%d")) if not result["success"]: return {"success": False, "error": f"Failed to update metadata: {result['error']}"} diff --git a/src/aipass/memory/templates/LOCAL.template.json b/src/aipass/memory/templates/LOCAL.template.json index c050ba85..3ffb46d2 100644 --- a/src/aipass/memory/templates/LOCAL.template.json +++ b/src/aipass/memory/templates/LOCAL.template.json @@ -23,8 +23,7 @@ }, "status": { "health": "healthy", - "last_health_check": "{{DATE}}", - "current_lines": 0 + "last_health_check": "{{DATE}}" } }, "key_learnings": {}, diff --git a/src/aipass/memory/templates/OBSERVATIONS.template.json b/src/aipass/memory/templates/OBSERVATIONS.template.json index 15d36d99..31a7afb1 100644 --- a/src/aipass/memory/templates/OBSERVATIONS.template.json +++ b/src/aipass/memory/templates/OBSERVATIONS.template.json @@ -13,12 +13,11 @@ "{{BRANCHNAME}}" ], "limits": { - "max_lines": 600, - "note": "DO NOT trim, prune, or delete entries. Auto-rollover to @memory when max_lines exceeded." + "max_observations": 25, + "note": "DO NOT trim, prune, or delete entries. Auto-rollover to @memory when max_observations exceeded." }, "status": { "health": "healthy", - "current_lines": 0, "last_health_check": "{{DATE}}" } }, diff --git a/src/aipass/memory/tests/test_handlers.py b/src/aipass/memory/tests/test_handlers.py index 7f8cd6da..ba7e0750 100644 --- a/src/aipass/memory/tests/test_handlers.py +++ b/src/aipass/memory/tests/test_handlers.py @@ -350,7 +350,7 @@ class TestCountPhysicalLines: def test_counts_lines_correctly(self, monkeypatch, tmp_path): lc, _ = _import_line_counter(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" f.write_text("line1\nline2\nline3\n", encoding="utf-8") assert lc._count_physical_lines(f) == 3 @@ -376,7 +376,7 @@ class TestUpdateLineCount: def test_updates_line_count_successfully(self, monkeypatch, tmp_path): lc, mocks = _import_line_counter(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" f.write_text('{\n "a": 1\n}\n', encoding="utf-8") result = lc.update_line_count(f) @@ -387,7 +387,7 @@ class TestUpdateLineCount: def test_reports_failure_when_metadata_update_fails(self, monkeypatch, tmp_path): lc, mocks = _import_line_counter(monkeypatch) mocks["memory_files"].update_metadata.return_value = {"success": False, "error": "write error"} - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" f.write_text("{}\n", encoding="utf-8") result = lc.update_line_count(f) @@ -413,12 +413,12 @@ class TestNormalizeMemoryFile: def test_moves_root_limits_into_metadata(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { - "document_metadata": {"status": {"current_lines": 10}}, - "limits": {"max_lines": 600}, + "document_metadata": {"status": {}}, + "limits": {"max_sessions": 20}, "sessions": [], }, ) @@ -427,19 +427,19 @@ class TestNormalizeMemoryFile: data = json.loads(f.read_text(encoding="utf-8")) assert "limits" not in {k for k in data if k != "document_metadata"} - assert data["document_metadata"]["limits"]["max_lines"] == 600 + assert data["document_metadata"]["limits"]["max_sessions"] == 20 def test_merges_root_limits_preserving_metadata_values(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { "document_metadata": { - "limits": {"max_lines": 500}, - "status": {"current_lines": 10}, + "limits": {"max_sessions": 20}, + "status": {}, }, - "limits": {"max_lines": 600, "extra_field": 42}, + "limits": {"max_sessions": 30, "max_key_learnings": 25}, "sessions": [], }, ) @@ -447,19 +447,19 @@ class TestNormalizeMemoryFile: assert result["success"] is True data = json.loads(f.read_text(encoding="utf-8")) - # metadata value (500) wins over root value (600) - assert data["document_metadata"]["limits"]["max_lines"] == 500 - # extra_field from root gets merged in - assert data["document_metadata"]["limits"]["extra_field"] == 42 + # metadata value (20) wins over root value (30) + assert data["document_metadata"]["limits"]["max_sessions"] == 20 + # valid key from root gets merged in + assert data["document_metadata"]["limits"]["max_key_learnings"] == 25 def test_removes_root_status(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { - "document_metadata": {"status": {"current_lines": 10}}, - "status": {"health": "ok", "current_lines": 5}, + "document_metadata": {"status": {"last_health_check": "2026-01-01"}}, + "status": {"health": "ok"}, "sessions": [], }, ) @@ -472,12 +472,12 @@ class TestNormalizeMemoryFile: def test_removes_auto_compress_at(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { "document_metadata": { - "status": {"current_lines": 10, "auto_compress_at": 500}, + "status": {"auto_compress_at": 500, "last_health_check": "2026-01-01"}, }, "sessions": [], }, @@ -490,7 +490,7 @@ class TestNormalizeMemoryFile: def test_dry_run_does_not_write(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" original = { "document_metadata": {}, "limits": {"max_lines": 600}, @@ -508,13 +508,13 @@ class TestNormalizeMemoryFile: def test_no_changes_when_already_normalized(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { "document_metadata": { "limits": {"max_sessions": 20}, - "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + "status": {"last_health_check": "2026-03-31"}, }, "sessions": [], }, @@ -525,13 +525,13 @@ class TestNormalizeMemoryFile: def test_removes_unused_limit_fields(self, monkeypatch, tmp_path): norm, _ = _import_normalize(monkeypatch) - f = tmp_path / "test.json" + f = tmp_path / "test.local.json" self._write_json( f, { "document_metadata": { - "limits": {"max_lines": 600, "max_word_count": 9999, "max_token_count": 5000}, - "status": {"current_lines": 10, "last_health_check": "2026-03-31"}, + "limits": {"max_sessions": 20, "max_lines": 600, "max_word_count": 9999, "max_token_count": 5000}, + "status": {"last_health_check": "2026-03-31"}, }, "sessions": [], }, @@ -542,7 +542,8 @@ class TestNormalizeMemoryFile: data = json.loads(f.read_text(encoding="utf-8")) assert "max_word_count" not in data["document_metadata"]["limits"] assert "max_token_count" not in data["document_metadata"]["limits"] - assert data["document_metadata"]["limits"]["max_lines"] == 600 + assert "max_lines" not in data["document_metadata"]["limits"] + assert data["document_metadata"]["limits"]["max_sessions"] == 20 class TestTodosOperational: diff --git a/src/aipass/memory/tests/test_watcher.py b/src/aipass/memory/tests/test_watcher.py index 1fe7bd4b..35096477 100644 --- a/src/aipass/memory/tests/test_watcher.py +++ b/src/aipass/memory/tests/test_watcher.py @@ -71,6 +71,21 @@ def _prepare_watcher_mocks(monkeypatch): monkeypatch.setitem(sys.modules, "watchdog.observers", mock_watchdog_observers) monkeypatch.setitem(sys.modules, "watchdog.events", mock_watchdog_events) + # Mock normalize (lazy import inside on_modified and check_and_rollover) + mock_normalize_memory_file = MagicMock(return_value={"success": True, "changes": []}) + mock_normalize = MagicMock() + mock_normalize.normalize_memory_file = mock_normalize_memory_file + monkeypatch.setitem( + sys.modules, + "aipass.memory.apps.handlers.schema", + MagicMock(), + ) + monkeypatch.setitem( + sys.modules, + "aipass.memory.apps.handlers.schema.normalize", + mock_normalize, + ) + # Mock rollover orchestrator (lazy import inside on_modified) mock_execute_rollover = MagicMock(return_value={"success": True}) mock_orchestrator = MagicMock() @@ -92,6 +107,7 @@ def _prepare_watcher_mocks(monkeypatch): "observer_instance": mock_observer_instance, "observer_cls": mock_observer_cls, "execute_rollover": mock_execute_rollover, + "normalize_memory_file": mock_normalize_memory_file, } @@ -495,3 +511,61 @@ class TestMemoryFileWatcherOnModified: watcher.on_modified(event) mocks["execute_rollover"].assert_called_once() + + def test_normalize_called_on_modification(self, monkeypatch): + """on_modified calls normalize_memory_file before update_line_count.""" + mod, mocks = _import_watcher(monkeypatch) + + watcher = mod.MemoryFileWatcher() + event = MagicMock() + event.is_directory = False + event.src_path = "/some/branch/.trinity/local.json" + + watcher.on_modified(event) + + mocks["normalize_memory_file"].assert_called_once() + mocks["update_line_count"].assert_called_once() + + def test_normalize_changes_guard_write_loop(self, monkeypatch): + """When normalize makes changes, file_key is added to _recent_modifications to prevent write-loop.""" + mod, mocks = _import_watcher(monkeypatch) + + mocks["normalize_memory_file"].return_value = { + "success": True, + "changes": ["Stripped orphan 'stale_key' from root"], + } + + watcher = mod.MemoryFileWatcher() + event = MagicMock() + event.is_directory = False + event.src_path = "/some/branch/.trinity/local.json" + + watcher.on_modified(event) + + from pathlib import Path as _P + + file_key = str(_P("/some/branch/.trinity/local.json")) + assert file_key in watcher._recent_modifications + + def test_normalize_no_changes_no_guard(self, monkeypatch): + """When normalize makes no changes, _recent_modifications is not populated by normalize step.""" + mod, mocks = _import_watcher(monkeypatch) + + mocks["normalize_memory_file"].return_value = { + "success": True, + "changes": [], + } + + watcher = mod.MemoryFileWatcher() + event = MagicMock() + event.is_directory = False + event.src_path = "/some/branch/.trinity/local.json" + + watcher.on_modified(event) + + from pathlib import Path as _P + + file_key = str(_P("/some/branch/.trinity/local.json")) + # file_key should NOT be in _recent_modifications from normalize (may be from rollover) + mocks["check_single_file"].return_value = {"success": True, "should_rollover": False} + assert file_key not in watcher._recent_modifications From 2bccf0311e20ca90d5b6a83ed1158570b516e78c Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Mon, 8 Jun 2026 23:07:36 -0700 Subject: [PATCH 05/20] =?UTF-8?q?feat(hooks):=20prompt=20injection=20caden?= =?UTF-8?q?ce=20=E2=80=94=20fire=20loaders=20every=20Nth=20turn,=20config-?= =?UTF-8?q?tunable=20(DPLAN-0200,=20FPLAN-0249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop re-injecting the global + branch prompts every turn (~3k tokens/turn). They now fire together every 5th turn; the prior injection persists in context between fires. Identity + email stay every-turn. - apps/modules/cadence.py: per-session turn counter (/tmp/aipass-cadence- {session_id}.json), should_fire(loader)/reset_counter(), DEFAULTS + deep-merge config (api provider.py pattern). 'drone @hooks cadence' introspection. - global_loader.py + branch_loader.py: cadence guard via importlib (crash- isolated); non-fire turn returns empty. - compact.py: PreCompact resets counter to -1 -> next turn = 0 = all fire (rebuild context after compaction). New session = fresh counter = all fire. - hooks_json/custom_config/cadence_config.json: tunable knob (period/offsets/ enabled), one file, no code edits. Data lives in the json home, not the code dir. Missing file = code DEFAULTS = safe. - .seedgo/bypass: documented stdlib-json config read (json_handler N/A for a dispatch engine). 435 tests pass (26 new), seedgo 100%, pyright 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/aipass/hooks/.seedgo/bypass.json | 13 +- src/aipass/hooks/README.md | 4 +- .../hooks/apps/handlers/lifecycle/compact.py | 8 + .../apps/handlers/prompt/branch_loader.py | 9 + .../apps/handlers/prompt/global_loader.py | 9 + src/aipass/hooks/apps/modules/cadence.py | 182 ++++++++ .../hooks/docs/cadence_investigation.md | 109 +++++ src/aipass/hooks/tests/test_branch_loader.py | 38 +- src/aipass/hooks/tests/test_cadence.py | 425 ++++++++++++++++++ src/aipass/hooks/tests/test_global_loader.py | 21 +- 10 files changed, 800 insertions(+), 18 deletions(-) create mode 100644 src/aipass/hooks/apps/modules/cadence.py create mode 100644 src/aipass/hooks/docs/cadence_investigation.md create mode 100644 src/aipass/hooks/tests/test_cadence.py diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index e0e72134..d08e1023 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -64,6 +64,10 @@ {"file": "apps/handlers/lifecycle/auto_process.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."}, {"file": "apps/handlers/lifecycle/auto_process.py", "standard": "json_structure", "reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."}, + {"file": "apps/modules/cadence.py", "standard": "dead_code", "reason": "Cadence module — should_fire() called from global_loader.py and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."}, + {"file": "apps/modules/cadence.py", "standard": "unused_function", "reason": "should_fire() called from global_loader.py + branch_loader.py; reset_counter() called from compact.py. All callers are dynamically dispatched handlers."}, + {"file": "apps/modules/cadence.py", "standard": "json_structure", "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage."}, + {"file": "apps/handlers/notification/announce.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."}, {"file": "apps/handlers/notification/announce.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 (Notification.notification_sound)."}, {"file": "apps/handlers/notification/announce.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."}, @@ -91,6 +95,8 @@ {"file": "apps/modules/hookstatus.py", "standard": "json_structure", "reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."}, + {"file": "apps/modules/cadence.py", "standard": "modules", "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."}, + {"file": "apps/hooks.py", "standard": "unused_function", "reason": "print_introspection() called by drone's discovery system, not internal code."}, {"file": "apps/sound.py", "standard": "unused_function", "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."}, @@ -203,7 +209,12 @@ {"file": "tests/test_auto_process.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, {"file": "tests/test_auto_process.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, {"file": "tests/test_auto_process.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_auto_process.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."} + {"file": "tests/test_auto_process.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, + + {"file": "tests/test_cadence.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, + {"file": "tests/test_cadence.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, + {"file": "tests/test_cadence.py", "standard": "encapsulation", "reason": "Tests import modules and handlers directly to test implementation details."}, + {"file": "tests/test_cadence.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."} ], "notes": { "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index 6cc926c6..17cfac04 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -25,6 +25,7 @@ Every hook event flows through one engine. Platform bridges normalize the event | `drone @hooks hooksound` | Show current sound mute status | | `drone @hooks hooksound off` | Mute all hook sounds | | `drone @hooks hooksound on` | Unmute all hook sounds | +| `drone @hooks cadence` | Show prompt injection cadence config and state | | `drone @hooks --help` | Full help reference | | `drone @hooks --version` | Version info | @@ -47,6 +48,7 @@ src/aipass/hooks/ │ ├── hooks.py # Entry point (drone @hooks) │ ├── sound.py # Shared sound utilities (speak, play, mute) │ ├── modules/ +│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating) │ │ ├── engine.py # Core dispatch — routes events to handlers │ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) │ │ └── hookstatus.py # Config viewer (drone @hooks status) @@ -77,7 +79,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 385 tests across 20 test files +└── tests/ # 435 tests across 21 test files ``` ## How It Works diff --git a/src/aipass/hooks/apps/handlers/lifecycle/compact.py b/src/aipass/hooks/apps/handlers/lifecycle/compact.py index 1f4111e5..2ac7e88a 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/compact.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/compact.py @@ -84,6 +84,14 @@ def handle(hook_data: dict) -> dict: """Inject live branch state for post-compact recovery.""" speak("pre compact") + try: + import importlib + + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + cadence.reset_counter() + except Exception as exc: + logger.info("[HOOKS] compact: cadence reset failed: %s", exc) + try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) branch_dir = _find_branch_dir(cwd) diff --git a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py index 4d2a63c2..bbde785e 100644 --- a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py @@ -32,6 +32,15 @@ def handle(hook_data: dict) -> dict: """Load branch prompt and private integration prompts.""" speak("branch prompt") + try: + import importlib + + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + if not cadence.should_fire("branch"): + return {"stdout": "", "exit_code": 0} + except Exception as exc: + logger.info("[HOOKS] branch_loader: cadence check failed, firing anyway: %s", exc) + try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) branch_root = _find_branch_root(cwd) diff --git a/src/aipass/hooks/apps/handlers/prompt/global_loader.py b/src/aipass/hooks/apps/handlers/prompt/global_loader.py index e9d74eab..ab7dafc4 100644 --- a/src/aipass/hooks/apps/handlers/prompt/global_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/global_loader.py @@ -33,6 +33,15 @@ def handle(hook_data: dict) -> dict: """Load global prompt — project-local if outside AIPass, AIPass-internal if inside.""" speak("global prompt") + try: + import importlib + + cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + if not cadence.should_fire("global"): + return {"stdout": "", "exit_code": 0} + except Exception as exc: + logger.info("[HOOKS] global_loader: cadence check failed, firing anyway: %s", exc) + try: aipass_home = os.environ.get("AIPASS_HOME", "") cwd = str(Path.cwd()) diff --git a/src/aipass/hooks/apps/modules/cadence.py b/src/aipass/hooks/apps/modules/cadence.py new file mode 100644 index 00000000..2d4342ec --- /dev/null +++ b/src/aipass/hooks/apps/modules/cadence.py @@ -0,0 +1,182 @@ +# =================== AIPass ==================== +# Name: cadence.py +# Version: 1.0.0 +# Description: Per-session turn counter for prompt injection cadence (DPLAN-0200) +# Branch: hooks +# Layer: apps/modules +# Created: 2026-06-08 +# Modified: 2026-06-08 +# ============================================= + +"""Turn counter for prompt injection cadence — fires loaders every Nth turn.""" + +import json +import os +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.prax.apps.modules.logger import system_logger as logger + +CONSOLE = err_console + +_GUARD_DIR = Path("/tmp") +_BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent +_CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json" + +HELP_COMMANDS = [ + ("cadence", "Show prompt injection cadence config and state"), +] + +DEFAULTS = { + "enabled": True, + "period": 5, + "loaders": { + "global": {"offset": 0}, + "branch": {"offset": 0}, + }, +} + +_turn: int | None = None +_config: dict | None = None + + +def _deep_merge(base: dict, updates: dict) -> dict: + """Deep merge updates into base (modifies base in-place).""" + for key, value in updates.items(): + if isinstance(value, dict) and key in base and isinstance(base[key], dict): + _deep_merge(base[key], value) + else: + base[key] = value + return base + + +def _load_config() -> dict: + global _config + if _config is not None: + return _config + + import copy + + result = copy.deepcopy(DEFAULTS) + + if _CONFIG_PATH.is_file(): + try: + overrides = json.loads(_CONFIG_PATH.read_text(encoding="utf-8")) + _deep_merge(result, overrides) + except (json.JSONDecodeError, OSError) as exc: + logger.info("[HOOKS] cadence: config load failed, using defaults: %s", exc) + + _config = result + return result + + +def _state_path() -> Path | None: + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + if not session_id: + return None + return _GUARD_DIR / f"aipass-cadence-{session_id}.json" + + +def _load_and_increment() -> int: + """Load turn counter from /tmp, increment, write back. Cached per-process.""" + global _turn + if _turn is not None: + return _turn + + path = _state_path() + if path is None: + _turn = 0 + return 0 + + count = 0 + if path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + count = data.get("turn", 0) + 1 + except (json.JSONDecodeError, OSError) as exc: + logger.info("[HOOKS] cadence: state read failed, resetting: %s", exc) + count = 0 + + try: + path.write_text(json.dumps({"turn": count}), encoding="utf-8") + except OSError as exc: + logger.info("[HOOKS] cadence: state write failed: %s", exc) + + _turn = count + return count + + +def should_fire(loader_name: str) -> bool: + """Check if a loader should fire this turn. Always True on turn 0 or if cadence disabled.""" + config = _load_config() + + if not config.get("enabled", True): + return True + + period = config.get("period", 5) + if period <= 0: + return True + + loader_config = config.get("loaders", {}).get(loader_name, {}) + offset = loader_config.get("offset", 0) + + turn = _load_and_increment() + + if turn == 0: + return True + + return (turn % period) == offset + + +def reset_counter() -> None: + """Reset counter to -1 so next turn reads 0 (all loaders fire). Called from PreCompact.""" + path = _state_path() + if path is None: + return + try: + path.write_text(json.dumps({"turn": -1}), encoding="utf-8") + logger.info("[HOOKS] cadence: counter reset for post-compact re-injection") + except OSError as exc: + logger.info("[HOOKS] cadence: reset write failed: %s", exc) + + +# ============================================================================= +# MODULE INTERFACE (drone @hooks routing) +# ============================================================================= + + +def print_introspection() -> None: + """Print cadence config and current state.""" + config = _load_config() + CONSOLE.print("[bold cyan]cadence[/bold cyan] Module") + CONSOLE.print(f" Enabled: {config.get('enabled', True)}") + CONSOLE.print(f" Period: {config.get('period', 5)} turns") + loaders = config.get("loaders", {}) + for name, lcfg in loaders.items(): + CONSOLE.print(f" Loader '{name}': offset={lcfg.get('offset', 0)}") + path = _state_path() + if path and path.exists(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + CONSOLE.print(f" Current turn: {data.get('turn', '?')}") + except (json.JSONDecodeError, OSError) as exc: + logger.info("[HOOKS] cadence: state read for introspection failed: %s", exc) + CONSOLE.print(" Current turn: (unreadable)") + else: + CONSOLE.print(" Current turn: (no state file)") + CONSOLE.print(f" Config file: {_CONFIG_PATH}") + + +def handle_command(command: str, args: list) -> bool: + """Route cadence commands from drone @hooks.""" + if command in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]cadence[/bold cyan] — Prompt injection cadence control") + CONSOLE.print() + CONSOLE.print(" drone @hooks cadence Show cadence config and current turn state") + return True + + if command == "cadence": + if not args: + print_introspection() + return True + return False diff --git a/src/aipass/hooks/docs/cadence_investigation.md b/src/aipass/hooks/docs/cadence_investigation.md new file mode 100644 index 00000000..50a43c57 --- /dev/null +++ b/src/aipass/hooks/docs/cadence_investigation.md @@ -0,0 +1,109 @@ +# Cadence Investigation — DPLAN-0200 + +Per-turn injection cadence mechanism for prompt loaders (global_loader, branch_loader, identity). +Investigation only — no build. Findings for @devpulse. + +--- + +## 1. Per-session turn counter — session keying + +YES, fully reliable. `CLAUDE_CODE_SESSION_ID` is available as an env var to every hook invocation (confirmed live: UUID format, stable across turns, unique per session). This is the same mechanism `auto_process.py` already uses for its once-per-session guard (`auto_process.py:24`, `/tmp` sentinel keyed by session_id). + +`hook_data` (the parsed stdin JSON) does NOT contain session_id — it comes from the env var only. For UserPromptSubmit, hook_data contains `{"user_prompt": "..."}` and sometimes other fields, but session keying must use `os.environ`. + +A new session gets a new UUID — fresh counter automatically. No inheritance risk. + +## 2. Counter state location + +Engine has NO per-session state infrastructure today. `auto_process.py`'s `/tmp` sentinel is the closest precedent — existence-only, no data payload. + +**Recommended:** `/tmp/aipass-cadence-{session_id}.json` — tiny JSON file (`{"turn": N}`), one per session, naturally cleaned on reboot. Same `/tmp` pattern as auto_process but with a data payload instead of touch-only. + +This is net-new state. The engine doesn't need to know about it — a shared cadence module handles it. + +## 3. Mechanism sketch — feasible, confirmed + +Shared module: `apps/handlers/prompt/cadence.py` + +Key insight: the bridge spawns ONE Python process per UserPromptSubmit dispatch, and the engine runs all handlers SEQUENTIALLY within that process (`engine.py:114` loop). So a module-level cache ensures the counter increments exactly ONCE per turn, even though 3 handlers call into it. + +```python +# cadence.py sketch +_turn = None # process-level cache, reset each dispatch = each turn + +def _load_and_increment(): + global _turn + if _turn is not None: + return _turn # already incremented this dispatch + path = _state_path() # /tmp/aipass-cadence-{session_id}.json + if path is None: + _turn = 0 + return 0 + count = 0 + if path.exists(): + data = json.loads(path.read_text()) + count = data.get("turn", 0) + 1 + path.write_text(json.dumps({"turn": count})) + _turn = count + return count + +def should_fire(offset, period=5): + turn = _load_and_increment() + if turn == 0: + return True # first turn ALWAYS fires + return (turn % period) == offset +``` + +Each loader adds ONE guard line: + +```python +from aipass.hooks.apps.handlers.prompt.cadence import should_fire + +def handle(hook_data): + if not should_fire(offset=0): # different offset per loader + return {"stdout": "", "exit_code": 0} + # ... existing logic unchanged +``` + +**Stagger example (period=5):** + +| Loader | Offset | Fires on turns | +|--------|--------|----------------| +| global_loader | 0 | 0, 5, 10, 15... | +| branch_loader | 2 | 0, 2, 7, 12... | +| identity | 4 | 0, 4, 9, 14... | + +Turn 0 = ALL fire (first turn guarantee). After that, max 1 loader per turn, each refreshed every 5 turns, staggered so they never collide. + +## 4. Edge cases + +**FIRST TURN:** Handled — `should_fire` returns True unconditionally when `turn==0`. Agent always gets full context on session start. + +**CONCURRENT SESSIONS:** Safe — counter file is keyed by session_id. Two sessions in different branches use different files, no conflict. + +**COMPACTION — CRITICAL INTERACTION:** When Claude Code compacts, injected prompts from prior turns get summarized or dropped from context. If a loader's next fire is 3-4 turns away post-compaction, the agent operates without that prompt content until it re-fires. + +**Fix:** Add a PreCompact handler (or extend the existing `compact.py`) that RESETS the cadence counter to -1. On the next UserPromptSubmit after compaction, `_load_and_increment` reads `-1+1=0`, and turn 0 = all loaders fire. Cost: one extra full-injection turn after each compaction, which is exactly right — the agent needs the prompts re-injected after losing context. + +**FILE I/O COST:** Negligible. One stat + read + write of ~15 bytes per turn. Way cheaper than the ~3,750 tokens saved. + +## 5. Robustness assessment — SOLID + +**Strengths:** +- Pattern is simple and deterministic. No async, no races, no distributed state. +- Sequential dispatch (`engine.py`) guarantees no concurrent access to the counter file within a single turn. +- `/tmp` cleanup on reboot = no accumulation. Session files are tiny and ephemeral. +- Module-level cache (`_turn`) prevents double-increment even if called from multiple handlers. +- Testable in isolation — mock `os.environ` + `/tmp` path, assert `should_fire` returns correctly. + +**One fragility to flag:** if Claude Code ever changes to dispatch UserPromptSubmit handlers in PARALLEL (separate processes), the module-level cache breaks and you'd get 3 increments per turn. Current architecture is sequential — but worth a comment noting the assumption. Mitigation: use file locking (`fcntl.flock`) if parallelism ever arrives, but don't build it now. + +No other fragility concerns. The mechanism is as robust as `auto_process.py`'s session guard, which has been running reliably since S10. + +--- + +## Summary + +Fully feasible. Shared `cadence.py` module, `/tmp` state file keyed by session_id, modulo+offset check per handler, turn-0 guarantee, PreCompact counter reset for compaction safety. Ready for implementation when DPLAN-0200 greenlights it. + +*Investigation by @hooks, 2026-06-08* diff --git a/src/aipass/hooks/tests/test_branch_loader.py b/src/aipass/hooks/tests/test_branch_loader.py index cc219d64..e0b1c376 100644 --- a/src/aipass/hooks/tests/test_branch_loader.py +++ b/src/aipass/hooks/tests/test_branch_loader.py @@ -10,7 +10,14 @@ """Tests for handlers/prompt/branch_loader.py.""" from pathlib import Path -from unittest.mock import patch +from unittest.mock import patch, MagicMock + + +def _mock_cadence_fires(): + """Return a mock cadence module where should_fire always returns True.""" + mock = MagicMock() + mock.should_fire.return_value = True + return mock class TestBranchLoaderHandler: @@ -25,7 +32,8 @@ class TestBranchLoaderHandler: prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8") with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert "Branch Context:" in result["stdout"] @@ -42,7 +50,8 @@ class TestBranchLoaderHandler: private.write_text("# Private Integration\nSecret stuff", encoding="utf-8") with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Private Integration" in result["stdout"] @@ -59,7 +68,8 @@ class TestBranchLoaderHandler: (integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8") with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Branch prompt" in result["stdout"] assert "Compass prompt" in result["stdout"] @@ -68,7 +78,8 @@ class TestBranchLoaderHandler: from aipass.hooks.apps.handlers.prompt.branch_loader import handle with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" @@ -80,7 +91,8 @@ class TestBranchLoaderHandler: nested.mkdir(parents=True) with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(nested)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(nested)}) assert result["stdout"] == "" @@ -96,7 +108,8 @@ class TestBranchLoaderHandler: nested.mkdir(parents=True) with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(nested)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(nested)}) assert "Found it" in result["stdout"] @@ -104,8 +117,9 @@ class TestBranchLoaderHandler: from aipass.hooks.apps.handlers.prompt.branch_loader import handle with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): - result = handle({}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" @@ -117,7 +131,8 @@ class TestBranchLoaderHandler: trinity.mkdir() with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" @@ -131,6 +146,7 @@ class TestBranchLoaderHandler: (aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8") with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Source:" in result["stdout"] diff --git a/src/aipass/hooks/tests/test_cadence.py b/src/aipass/hooks/tests/test_cadence.py new file mode 100644 index 00000000..bb000231 --- /dev/null +++ b/src/aipass/hooks/tests/test_cadence.py @@ -0,0 +1,425 @@ +# =================== AIPass ==================== +# Name: test_cadence.py +# Version: 1.0.0 +# Description: Tests for cadence module (DPLAN-0200) +# Branch: hooks +# Created: 2026-06-08 +# Modified: 2026-06-08 +# ============================================= + +"""Tests for apps/modules/cadence.py.""" + +import json +import importlib +from unittest.mock import patch + +MODULE = "aipass.hooks.apps.modules.cadence" + + +def _reset_module_globals(): + """Reset module-level caches between tests.""" + import aipass.hooks.apps.modules.cadence as mod + + mod._turn = None + mod._config = None + + +class TestShouldFire: + def setup_method(self): + _reset_module_globals() + + def test_turn_0_always_fires(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + assert json.loads(state_file.read_text())["turn"] == 0 + + def test_turn_0_fires_all_loaders(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + assert should_fire("branch") is True + + def test_non_fire_turn_returns_false(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 0})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is False + + def test_fire_turn_returns_true(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 3})) + + config = tmp_path / "cadence.json" + config.write_text(json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 4}}})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("global") is True + + def test_cadence_disabled_always_fires(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 1})) + + config = tmp_path / "cadence.json" + config.write_text(json.dumps({"enabled": False})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("global") is True + + def test_no_session_id_fires(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {}, clear=False), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + env = dict(__import__("os").environ) + env.pop("CLAUDE_CODE_SESSION_ID", None) + with patch.dict("os.environ", env, clear=True): + assert should_fire("global") is True + + def test_counter_increments_once_per_process(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 3})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + should_fire("global") + should_fire("branch") + data = json.loads(state_file.read_text()) + assert data["turn"] == 4 + + def test_period_zero_always_fires(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 2})) + + config = tmp_path / "cadence.json" + config.write_text(json.dumps({"enabled": True, "period": 0})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("global") is True + + def test_stagger_offsets(self, tmp_path): + config = tmp_path / "cadence.json" + config.write_text( + json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 0}, "branch": {"offset": 2}}}) + ) + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 4})) + + from aipass.hooks.apps.modules.cadence import should_fire + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", config), + ): + assert should_fire("global") is True + assert should_fire("branch") is False + + def test_unknown_loader_uses_offset_zero(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 4})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("unknown_loader") is True + + +class TestResetCounter: + def setup_method(self): + _reset_module_globals() + + def test_reset_writes_minus_one(self, tmp_path): + from aipass.hooks.apps.modules.cadence import reset_counter + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 7})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + reset_counter() + + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + def test_reset_then_next_turn_is_zero(self, tmp_path): + from aipass.hooks.apps.modules.cadence import reset_counter, should_fire + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 7})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + reset_counter() + + _reset_module_globals() + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + data = json.loads(state_file.read_text()) + assert data["turn"] == 0 + + def test_reset_no_session_id_is_noop(self, tmp_path): + from aipass.hooks.apps.modules.cadence import reset_counter + + with patch(f"{MODULE}._GUARD_DIR", tmp_path): + env = dict(__import__("os").environ) + env.pop("CLAUDE_CODE_SESSION_ID", None) + with patch.dict("os.environ", env, clear=True): + reset_counter() + + assert not list(tmp_path.glob("aipass-cadence-*")) + + def test_reset_creates_file_if_missing(self, tmp_path): + from aipass.hooks.apps.modules.cadence import reset_counter + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + reset_counter() + + state_file = tmp_path / "aipass-cadence-test-session.json" + assert state_file.exists() + assert json.loads(state_file.read_text())["turn"] == -1 + + +class TestConfig: + def setup_method(self): + _reset_module_globals() + + def test_defaults_used_when_no_config_file(self, tmp_path): + from aipass.hooks.apps.modules.cadence import _load_config + + with patch(f"{MODULE}._CONFIG_PATH", tmp_path / "nonexistent.json"): + config = _load_config() + + assert config["enabled"] is True + assert config["period"] == 5 + assert config["loaders"]["global"]["offset"] == 0 + assert config["loaders"]["branch"]["offset"] == 0 + + def test_config_deep_merges_over_defaults(self, tmp_path): + from aipass.hooks.apps.modules.cadence import _load_config + + config_file = tmp_path / "cadence.json" + config_file.write_text(json.dumps({"period": 10, "loaders": {"global": {"offset": 3}}})) + + with patch(f"{MODULE}._CONFIG_PATH", config_file): + config = _load_config() + + assert config["period"] == 10 + assert config["loaders"]["global"]["offset"] == 3 + assert config["loaders"]["branch"]["offset"] == 0 + assert config["enabled"] is True + + def test_bad_config_falls_back_to_defaults(self, tmp_path): + from aipass.hooks.apps.modules.cadence import _load_config + + config_file = tmp_path / "cadence.json" + config_file.write_text("not valid json{{{") + + with patch(f"{MODULE}._CONFIG_PATH", config_file): + config = _load_config() + + assert config["period"] == 5 + + +class TestDeepMerge: + def test_nested_merge(self): + from aipass.hooks.apps.modules.cadence import _deep_merge + + base = {"a": 1, "b": {"c": 2, "d": 3}} + updates = {"b": {"c": 99}, "e": 4} + result = _deep_merge(base, updates) + + assert result["a"] == 1 + assert result["b"]["c"] == 99 + assert result["b"]["d"] == 3 + assert result["e"] == 4 + + def test_overwrites_non_dict(self): + from aipass.hooks.apps.modules.cadence import _deep_merge + + base = {"a": [1, 2]} + result = _deep_merge(base, {"a": [3]}) + assert result["a"] == [3] + + +class TestPerSessionIsolation: + def setup_method(self): + _reset_module_globals() + + def test_different_sessions_use_different_files(self, tmp_path): + from aipass.hooks.apps.modules.cadence import should_fire + + state_a = tmp_path / "aipass-cadence-session-a.json" + state_a.write_text(json.dumps({"turn": 4})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-a"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + should_fire("global") + data_a = json.loads(state_a.read_text()) + assert data_a["turn"] == 5 + + _reset_module_globals() + + state_b = tmp_path / "aipass-cadence-session-b.json" + assert not state_b.exists() + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "session-b"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + data_b = json.loads(state_b.read_text()) + assert data_b["turn"] == 0 + + +class TestModuleInterface: + def setup_method(self): + _reset_module_globals() + + def test_handle_command_cadence_returns_true(self): + from aipass.hooks.apps.modules.cadence import handle_command + + with patch(f"{MODULE}.print_introspection"): + assert handle_command("cadence", []) is True + + def test_handle_command_unknown_returns_false(self): + from aipass.hooks.apps.modules.cadence import handle_command + + assert handle_command("other", []) is False + + def test_print_introspection_runs(self, tmp_path): + from aipass.hooks.apps.modules.cadence import print_introspection + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + print_introspection() + + +class TestCompactIntegration: + def setup_method(self): + _reset_module_globals() + + def test_compact_handler_resets_cadence(self, tmp_path): + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 7})) + + import aipass.hooks.apps.modules.cadence as cadence_mod + + with ( + patch.object(cadence_mod, "_GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + ): + mock_cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") + mock_cadence.reset_counter() + + data = json.loads(state_file.read_text()) + assert data["turn"] == -1 + + +class TestLoaderCadenceGuard: + def setup_method(self): + _reset_module_globals() + + def test_global_loader_skips_on_non_fire_turn(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.global_loader import handle + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 0})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"), + ): + result = handle({}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 + + def test_branch_loader_skips_on_non_fire_turn(self, tmp_path): + from aipass.hooks.apps.handlers.prompt.branch_loader import handle + + state_file = tmp_path / "aipass-cadence-test-session.json" + state_file.write_text(json.dumps({"turn": 0})) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"), + ): + result = handle({}) + + assert result["stdout"] == "" + assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_global_loader.py b/src/aipass/hooks/tests/test_global_loader.py index b20b86d8..3b93c26b 100644 --- a/src/aipass/hooks/tests/test_global_loader.py +++ b/src/aipass/hooks/tests/test_global_loader.py @@ -9,7 +9,14 @@ """Tests for handlers/prompt/global_loader.py.""" -from unittest.mock import patch +from unittest.mock import patch, MagicMock + + +def _mock_cadence_fires(): + """Return a mock cadence module where should_fire always returns True.""" + mock = MagicMock() + mock.should_fire.return_value = True + return mock class TestGlobalLoaderHandler: @@ -24,7 +31,8 @@ class TestGlobalLoaderHandler: with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - result = handle({}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert "AIPass Global" in result["stdout"] @@ -36,7 +44,8 @@ class TestGlobalLoaderHandler: monkeypatch.chdir(tmp_path) with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - result = handle({}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" @@ -51,7 +60,8 @@ class TestGlobalLoaderHandler: with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - result = handle({}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "content" @@ -69,7 +79,8 @@ class TestGlobalLoaderHandler: with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}): - result = handle({}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert "Project Prompt" in result["stdout"] From fc4b2635049cb1a952ab6e1203bb7bba329a323e Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 16:28:26 -0700 Subject: [PATCH 06/20] fix(hooks): cadence separate-process race + action-gated sound + auto_fix diagnostics regression (DPLAN-0200, FPLAN-0249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cadence redo — verified against the live execution model, not unit tests: - Counter now advances exactly once per real turn (mtime debounce + transcript-size token + flock). Fixes the separate-process leapfrog where global/branch loaders double-incremented and fired erratically. - Structured [HOOKS] cadence fired|skipped logging; prax monitor renders hook events distinctly for live visibility. - Action-gated sound: handlers return a 'sound' key the engine plays only on real action — skipped loaders are silent (no more false piper every turn). - Fixed auto_fix.py: leftover speak() NameError (swallowed by broad except) meant diagnostics silently never ran on any edit. Removed; sound moved to the error path. - Tests rewritten to model separate-process execution (leapfrog regression test added); sound assertions across all refactored handlers. 438 pass. prax: hook fire/skip event rendering in the live monitor. 913 pass. README: hardcoded metrics (version/tests/PRs/standards) -> live PyPI+codecov badges and qualitative wording; killed the 33-vs-36 drift. CHANGELOG W24. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 34 ++++ README.md | 28 ++-- src/aipass/ai_mail/tests/test_reply.py | 4 +- .../hooks/apps/handlers/lifecycle/auto_fix.py | 5 +- .../hooks/apps/handlers/lifecycle/compact.py | 5 +- .../hooks/apps/handlers/lifecycle/rollover.py | 7 +- .../apps/handlers/prompt/branch_loader.py | 7 +- .../apps/handlers/prompt/global_loader.py | 7 +- .../hooks/apps/handlers/prompt/identity.py | 5 +- .../hooks/apps/handlers/security/edit_gate.py | 29 +++- .../hooks/apps/handlers/security/git_gate.py | 5 +- .../hooks/apps/handlers/security/rm_gate.py | 5 +- .../apps/handlers/security/subagent_gate.py | 5 +- src/aipass/hooks/apps/modules/cadence.py | 152 +++++++++++++++--- src/aipass/hooks/apps/modules/engine.py | 9 ++ src/aipass/hooks/docs/cadence_redo_brief.md | 44 +++++ src/aipass/hooks/tests/test_auto_fix.py | 55 ++++--- src/aipass/hooks/tests/test_branch_loader.py | 53 +++--- src/aipass/hooks/tests/test_cadence.py | 110 ++++++++++--- src/aipass/hooks/tests/test_compact.py | 33 ++-- src/aipass/hooks/tests/test_global_loader.py | 31 ++-- src/aipass/hooks/tests/test_identity.py | 28 ++-- src/aipass/hooks/tests/test_rollover.py | 32 ++-- src/aipass/hooks/tests/test_subagent_gate.py | 39 +++-- .../prax/apps/handlers/monitoring/__init__.py | 3 +- .../apps/handlers/monitoring/log_watcher.py | 52 +++++- .../handlers/monitoring/unified_stream.py | 22 +++ src/aipass/prax/apps/modules/monitor.py | 3 + src/aipass/prax/tests/test_log_watcher.py | 102 ++++++++++++ 29 files changed, 667 insertions(+), 247 deletions(-) create mode 100644 src/aipass/hooks/docs/cadence_redo_brief.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f83c1f6a..3e0ece7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,40 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ## [2026.W24] - 2026-06-08 +### Added + +- **Prompt-injection cadence — fire the big loaders every Nth turn.** The global + and branch prompts are large and were re-injected on *every* turn even though a + prior copy stays in the conversation. They now fire together every 5th turn + (config-tunable via `hooks_json/custom_config/cadence_config.json`), with a + per-session turn counter that resets on a new session and after compaction so + context is always rebuilt when it's actually needed. Identity and the mail flag + stay every-turn (tiny, want freshness). Cuts recurring per-turn context cost. +- **Hook fire/skip observability.** Cadence emits a structured + `[HOOKS] cadence fired|skipped loader= turn= period= offset=` line; the prax + monitor renders hook events distinctly so the cadence is visible live. + +### Changed + +- **Action-gated hook sound.** Piper now speaks only when a hook actually *does* + something — handlers return a `sound` key the engine plays, instead of + announcing on every invocation. Skipped loaders are silent. Quieter and honest. +- **README: hardcoded metrics → live badges + qualitative.** Version is now a + live PyPI badge, test/PR counts replaced with a codecov coverage badge (75% + minimum) and qualitative wording — no more stale numbers to hand-maintain. + +### Fixed + +- **Cadence counter separate-process race.** Each `UserPromptSubmit` hook runs as + its own OS process, so a module-level turn cache double-incremented and the + loaders leapfrogged (firing erratically instead of together). Fixed with an + mtime debounce + transcript-size token + `flock` so the counter advances exactly + once per real turn, verified against the live execution model. +- **`auto_fix` ran no diagnostics.** A leftover `speak()` call (its import removed + in the sound refactor) raised `NameError` on every edit, swallowed by the + handler's broad `except` — so auto-fix silently surfaced nothing on any + `.py`/`.json` edit. Removed the dead call; diagnostics run again. + ### Security - **Least-privilege token on the `e2e-wheel` workflow.** `e2e-wheel.yml` was the diff --git a/README.md b/README.md index 8cc57502..91ba5f31 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ my-project/ ├── .aipass/ # Project config + prompts ├── .claude/ # Hooks (injected automatically) ├── src/my_project/ -│ └── my-agent/ +│ └── my_agent/ │ ├── .trinity/ # Identity + memory (3 JSON files) │ ├── .ai_mail.local/ # Local mailbox │ ├── apps/ # Your agent's code @@ -91,7 +91,7 @@ That's it. Your agent has identity, memory, a mailbox, and access to every AIPas ```bash aipass init # Just the scaffold (no guided setup) -aipass init agent my-agent # Add another agent +aipass init agent my_agent # Add another agent aipass doctor # Check system health ``` @@ -112,8 +112,8 @@ claude # Talk to the orchestrator ```bash # Things you can do: -aipass doctor # Check system health (15+ checks) -drone @seedgo audit aipass # Run 36 quality checks across all agents +aipass doctor # Check system health +drone @seedgo audit aipass # Run automated quality checks across all agents drone @flow create . "Add user auth" # Create a work plan drone @ai_mail dispatch @agent "Sub" "Body" # Send task + wake an agent ``` @@ -141,7 +141,7 @@ drone @branch command [args] # Every agent, every task. Drone handles routing ``` ```bash -drone @seedgo audit my-project # Run quality checks on everything +drone @seedgo audit my_project # Run quality checks on everything drone @flow create . "Refactor auth module" # Create a work plan drone @ai_mail dispatch @agent "Archive old sessions" "Find sessions older than 30 days" ``` @@ -161,7 +161,7 @@ AIPass ships with 13 core agents that maintain and develop the framework itself devpulse (orchestrator) ├── aipass — concierge + onboarding (aipass init, doctor, profile) ├── drone — command routing + @agent resolution - ├── seedgo — 36 automated quality standards + ├── seedgo — automated quality standards ├── prax — real-time monitoring across all agents ├── ai_mail — agent-to-agent communication + task dispatch ├── flow — plan lifecycle, templates, auto-archival @@ -195,9 +195,9 @@ These agents work on the **same filesystem, same project, same time** — no san | Agent | Role | |-------|------| -| [**seedgo**](src/aipass/seedgo/README.md) | 36 automated quality standards, enforced across all agents | +| [**seedgo**](src/aipass/seedgo/README.md) | Automated quality standards, enforced across all agents | | [**prax**](src/aipass/prax/README.md) | Real-time monitoring, logs, dashboards | -| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — 6 template types, auto-archival, vector verification | +| [**flow**](src/aipass/flow/README.md) | Plan lifecycle — multiple template types, auto-archival, vector verification | | [**hooks**](src/aipass/hooks/README.md) | Hook engine — per-project config, sound control, event dispatch | | [**trigger**](src/aipass/trigger/README.md) | Event-driven automation + self-healing | | [**cli**](src/aipass/cli/README.md) | Terminal formatting and rich output | @@ -212,7 +212,7 @@ AIPass is built and tested with **Claude Code** on Linux/WSL. | CLI | Autonomous Mode | Status | |-----|----------------|--------| -| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested | +| [Claude Code](https://code.claude.com/docs) | `claude -p "prompt" --permission-mode bypassPermissions` | Fully tested | | [Codex](https://github.com/openai/codex) | `codex exec "prompt" --dangerously-bypass-approvals-and-sandbox` | Experimental | setup.sh auto-detects which CLIs are installed and configures hooks for each. @@ -225,11 +225,11 @@ setup.sh auto-detects which CLIs are installed and configures hooks for each. | Metric | Value | |--------|-------| -| Version | 2.4.0 | +| Version | [![PyPI](https://img.shields.io/pypi/v/aipass?label=)](https://pypi.org/project/aipass/) | | Agents | 13 core + user-created | -| Quality standards | 36 automated checks | -| Tests | 8,400+ (across all agents) | -| PRs merged | 600+ (human-AI collaboration) | +| Quality | Automated standards enforced across every agent | +| Coverage | [![codecov](https://codecov.io/gh/AIOSAI/AIPass/graph/badge.svg)](https://codecov.io/gh/AIOSAI/AIPass) — 75% minimum, CI-gated | +| Tests | Extensive — every agent ships its own suite | Each agent documents its own operational status in its branch README — what works, what doesn't, and why. @@ -238,7 +238,7 @@ Each agent documents its own operational status in its branch README — what wo ## Requirements - Python 3.10+ -- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) +- [Claude Code](https://code.claude.com/docs) - Linux, macOS, or WSL (all CI-tested) - `sudo` access optional (for `/usr/local/bin` symlinks — falls back to `~/.local/bin` without sudo) - API keys optional (OpenRouter/OpenAI — for optional add-on agents) diff --git a/src/aipass/ai_mail/tests/test_reply.py b/src/aipass/ai_mail/tests/test_reply.py index 77fd7401..fb2ee290 100644 --- a/src/aipass/ai_mail/tests/test_reply.py +++ b/src/aipass/ai_mail/tests/test_reply.py @@ -284,7 +284,9 @@ def test_send_reply_multiline_body_preserved(tmp_path): deliver_calls.append((to_branch, email_data)) return (True, "") - multiline_body = "Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding" + multiline_body = ( + "Investigation: cadence findings\n\nDetails:\n1. First finding\n2. Second finding\n3. Third finding" + ) with ( patch(_PATCH_BRANCH_DETECTION, return_value=sender_info), diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py index 8dfe62be..cd9f9b6e 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -16,7 +16,6 @@ import subprocess import sys from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger EDIT_TOOLS = {"Edit", "Write", "MultiEdit", "NotebookEdit"} @@ -321,8 +320,6 @@ def handle(hook_data: dict) -> dict: if ext in SKIP_EXTENSIONS: return {"stdout": "", "exit_code": 0} - speak("auto fix diagnostics") - errors: list[str] = [] if file_path.endswith(".py"): @@ -358,7 +355,7 @@ def handle(hook_data: dict) -> dict: }, "systemMessage": f"[AUTO-FIX] {len(errors)} error(s) — fix before continuing", } - return {"stdout": json.dumps(result), "exit_code": 0} + return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto fix diagnostics"} result = {"systemMessage": "[diagnostics] ok"} return {"stdout": json.dumps(result), "exit_code": 0} diff --git a/src/aipass/hooks/apps/handlers/lifecycle/compact.py b/src/aipass/hooks/apps/handlers/lifecycle/compact.py index 2ac7e88a..2046c6d7 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/compact.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/compact.py @@ -15,7 +15,6 @@ import os import subprocess from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -82,8 +81,6 @@ def _get_git_info() -> str | None: def handle(hook_data: dict) -> dict: """Inject live branch state for post-compact recovery.""" - speak("pre compact") - try: import importlib @@ -130,7 +127,7 @@ def handle(hook_data: dict) -> dict: "- Match the conversation tone from before compaction" ) - return {"stdout": "\n\n".join(sections), "exit_code": 0} + return {"stdout": "\n\n".join(sections), "exit_code": 0, "sound": "pre compact"} except Exception as exc: logger.info("[HOOKS] compact: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/rollover.py b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py index 185961e9..5c806e73 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/rollover.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/rollover.py @@ -15,7 +15,6 @@ import os import subprocess from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -129,10 +128,8 @@ def _run_rollover(repo_root: Path) -> tuple[bool, str]: return False, str(exc) -def handle(hook_data: dict) -> dict: +def handle(hook_data: dict) -> dict: # noqa: ARG001 """Check memory files for overflow and trigger rollover if needed.""" - speak("pre compact rollover") - try: repo_root = _find_repo_root() if not repo_root: @@ -151,7 +148,7 @@ def handle(hook_data: dict) -> dict: else: logger.info("[HOOKS] rollover: failed — %s", output[:200]) - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "pre compact rollover"} except Exception as exc: logger.info("[HOOKS] rollover: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py index bbde785e..43d0a1cf 100644 --- a/src/aipass/hooks/apps/handlers/prompt/branch_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/branch_loader.py @@ -12,7 +12,6 @@ from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -30,13 +29,11 @@ def _find_branch_root(cwd: str) -> Path | None: def handle(hook_data: dict) -> dict: """Load branch prompt and private integration prompts.""" - speak("branch prompt") - try: import importlib cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") - if not cadence.should_fire("branch"): + if not cadence.should_fire("branch", hook_data): return {"stdout": "", "exit_code": 0} except Exception as exc: logger.info("[HOOKS] branch_loader: cadence check failed, firing anyway: %s", exc) @@ -63,7 +60,7 @@ def handle(hook_data: dict) -> dict: if not parts: return {"stdout": "", "exit_code": 0} - return {"stdout": "\n".join(parts), "exit_code": 0} + return {"stdout": "\n".join(parts), "exit_code": 0, "sound": "branch prompt"} except Exception as exc: logger.info("[HOOKS] branch_loader: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/prompt/global_loader.py b/src/aipass/hooks/apps/handlers/prompt/global_loader.py index ab7dafc4..d6fe0004 100644 --- a/src/aipass/hooks/apps/handlers/prompt/global_loader.py +++ b/src/aipass/hooks/apps/handlers/prompt/global_loader.py @@ -13,7 +13,6 @@ import os from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -31,13 +30,11 @@ def _find_project_prompt() -> Path | None: def handle(hook_data: dict) -> dict: """Load global prompt — project-local if outside AIPass, AIPass-internal if inside.""" - speak("global prompt") - try: import importlib cadence = importlib.import_module("aipass.hooks.apps.modules.cadence") - if not cadence.should_fire("global"): + if not cadence.should_fire("global", hook_data): return {"stdout": "", "exit_code": 0} except Exception as exc: logger.info("[HOOKS] global_loader: cadence check failed, firing anyway: %s", exc) @@ -55,7 +52,7 @@ def handle(hook_data: dict) -> dict: return {"stdout": "", "exit_code": 0} content = prompt_file.read_text(encoding="utf-8") - return {"stdout": content, "exit_code": 0} + return {"stdout": content, "exit_code": 0, "sound": "global prompt"} except Exception as exc: logger.info("[HOOKS] global_loader: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/prompt/identity.py b/src/aipass/hooks/apps/handlers/prompt/identity.py index 463c41db..76a08fac 100644 --- a/src/aipass/hooks/apps/handlers/prompt/identity.py +++ b/src/aipass/hooks/apps/handlers/prompt/identity.py @@ -13,7 +13,6 @@ import json from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -69,8 +68,6 @@ def _format_identity(data: dict) -> str: def handle(hook_data: dict) -> dict: """Inject branch identity from passport.json into prompt context.""" - speak("identity") - try: cwd = hook_data.get("cwd", "") or str(Path.cwd()) passport = _find_passport(cwd) @@ -82,7 +79,7 @@ def handle(hook_data: dict) -> dict: if not output: return {"stdout": "", "exit_code": 0} - return {"stdout": f"\n{output}", "exit_code": 0} + return {"stdout": f"\n{output}", "exit_code": 0, "sound": "identity"} except Exception as exc: logger.info("[HOOKS] identity: unexpected error: %s", exc) diff --git a/src/aipass/hooks/apps/handlers/security/edit_gate.py b/src/aipass/hooks/apps/handlers/security/edit_gate.py index 5135f56e..9e7902ed 100644 --- a/src/aipass/hooks/apps/handlers/security/edit_gate.py +++ b/src/aipass/hooks/apps/handlers/security/edit_gate.py @@ -14,7 +14,6 @@ import json import os from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -50,8 +49,6 @@ def handle(hook_data: dict) -> dict: Returns: Result dict with stdout (block JSON or empty) and exit_code. """ - speak("edit gate") - try: tool_name = hook_data.get("tool_name", "") tool_input = hook_data.get("tool_input", {}) @@ -66,7 +63,7 @@ def handle(hook_data: dict) -> dict: fp = Path(file_path) if fp.name == "inbox.json" and ".ai_mail.local" in fp.parts: reason = 'Direct writes to inbox.json are blocked.\nUse: drone @ai_mail email @ "Subject" "Body"' - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "edit gate"} cwd = hook_data.get("cwd", "") or os.getcwd() package = _get_package_from_cwd(cwd) @@ -80,7 +77,11 @@ def handle(hook_data: dict) -> dict: f"Dispatched agent confined to own branch: '{cwd_branch}' " f"cannot write to '{target_branch}' in daemon mode." ) - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return { + "stdout": json.dumps({"decision": "block", "reason": reason}), + "exit_code": 2, + "sound": "edit gate", + } repo_root = None for parent in Path(cwd).parents: if (parent / ".git").exists(): @@ -91,7 +92,11 @@ def handle(hook_data: dict) -> dict: resolved = str(fp.resolve()) if not fp.is_absolute() else str(fp) if not resolved.startswith(allowed_prefix): reason = f"Dispatched agent restricted to {allowed_prefix}. Cannot write to: {file_path}" - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return { + "stdout": json.dumps({"decision": "block", "reason": reason}), + "exit_code": 2, + "sound": "edit gate", + } target_branch = _get_branch(str(fp.resolve()) if not fp.is_absolute() else str(fp), package) @@ -101,7 +106,11 @@ def handle(hook_data: dict) -> dict: f"Cross-branch write blocked: '{cwd_branch}' cannot write to '{target_branch}'.\n" f"Trusted cross-writers: {', '.join(TRUSTED_CROSS_WRITERS)}" ) - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return { + "stdout": json.dumps({"decision": "block", "reason": reason}), + "exit_code": 2, + "sound": "edit gate", + } if not file_path.endswith(".py"): return {"stdout": "", "exit_code": 0} @@ -140,7 +149,11 @@ def handle(hook_data: dict) -> dict: error_summary = "\n".join(f" L{e['line']}: {e['message']}" for e in errors[:5]) reason = f"Fix {len(errors)} error(s) in {Path(errored_file).name} before editing other files:\n{error_summary}" - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return { + "stdout": json.dumps({"decision": "block", "reason": reason}), + "exit_code": 2, + "sound": "edit gate", + } except Exception as exc: logger.info("[HOOKS] edit_gate: unexpected error (allowing): %s", exc) diff --git a/src/aipass/hooks/apps/handlers/security/git_gate.py b/src/aipass/hooks/apps/handlers/security/git_gate.py index f2643398..f7bfb000 100644 --- a/src/aipass/hooks/apps/handlers/security/git_gate.py +++ b/src/aipass/hooks/apps/handlers/security/git_gate.py @@ -15,7 +15,6 @@ import os import re from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -133,7 +132,7 @@ def _all_git_reads(scan: str) -> bool: def _block(reason: str) -> dict: - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "git gate"} def _check_bash(tool_input: dict) -> dict: @@ -170,8 +169,6 @@ def handle(hook_data: dict) -> dict: Returns: Result dict with stdout (block JSON or empty) and exit_code. """ - speak("git gate") - try: tool_name = hook_data.get("tool_name", "") tool_input = hook_data.get("tool_input", {}) diff --git a/src/aipass/hooks/apps/handlers/security/rm_gate.py b/src/aipass/hooks/apps/handlers/security/rm_gate.py index f51cc878..c17d54f3 100644 --- a/src/aipass/hooks/apps/handlers/security/rm_gate.py +++ b/src/aipass/hooks/apps/handlers/security/rm_gate.py @@ -13,7 +13,6 @@ import json import re -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -28,7 +27,7 @@ _BLOCK_ALLOW = {"stdout": "", "exit_code": 0} def _block(reason: str) -> dict: - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "rm gate"} def _strip_quotes(cmd: str) -> str: @@ -84,8 +83,6 @@ def handle(hook_data: dict) -> dict: 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": diff --git a/src/aipass/hooks/apps/handlers/security/subagent_gate.py b/src/aipass/hooks/apps/handlers/security/subagent_gate.py index a6a64309..c4d99a6f 100644 --- a/src/aipass/hooks/apps/handlers/security/subagent_gate.py +++ b/src/aipass/hooks/apps/handlers/security/subagent_gate.py @@ -15,14 +15,13 @@ import os import subprocess from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger _ALLOW = {"stdout": "", "exit_code": 0} def _block(reason: str) -> dict: - return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2} + return {"stdout": json.dumps({"decision": "block", "reason": reason}), "exit_code": 2, "sound": "subagent gate"} def _get_package_from_cwd(cwd: str) -> str: @@ -152,8 +151,6 @@ def _check_hook_readme_accountability(cwd: str, repo_root: Path) -> str | None: def handle(hook_data: dict) -> dict: """Check modified files against seedgo standards on subagent stop.""" - speak("subagent stop gate") - try: cwd = hook_data.get("cwd", "") or os.getcwd() repo_root = _find_repo_root(cwd) diff --git a/src/aipass/hooks/apps/modules/cadence.py b/src/aipass/hooks/apps/modules/cadence.py index 2d4342ec..a656045e 100644 --- a/src/aipass/hooks/apps/modules/cadence.py +++ b/src/aipass/hooks/apps/modules/cadence.py @@ -1,6 +1,6 @@ # =================== AIPass ==================== # Name: cadence.py -# Version: 1.0.0 +# Version: 2.0.0 # Description: Per-session turn counter for prompt injection cadence (DPLAN-0200) # Branch: hooks # Layer: apps/modules @@ -8,20 +8,33 @@ # Modified: 2026-06-08 # ============================================= -"""Turn counter for prompt injection cadence — fires loaders every Nth turn.""" +"""Turn counter for prompt injection cadence — fires loaders every Nth turn. + +Multi-process safe: each UserPromptSubmit hook runs as a separate OS process. +Uses fcntl.flock + mtime debounce + per-turn token to ensure the counter +advances exactly once per real user turn. +""" import json import os +import time from pathlib import Path from aipass.cli.apps.modules import err_console from aipass.prax.apps.modules.logger import system_logger as logger +try: + import fcntl +except ImportError: + fcntl = None # type: ignore[assignment] + logger.info("[HOOKS] cadence: fcntl unavailable (Windows)") + CONSOLE = err_console _GUARD_DIR = Path("/tmp") _BRANCH_ROOT = Path(__file__).resolve().parent.parent.parent _CONFIG_PATH = _BRANCH_ROOT / "hooks_json" / "custom_config" / "cadence_config.json" +_DEBOUNCE_S = 2.0 HELP_COMMANDS = [ ("cadence", "Show prompt injection cadence config and state"), @@ -77,8 +90,61 @@ def _state_path() -> Path | None: return _GUARD_DIR / f"aipass-cadence-{session_id}.json" -def _load_and_increment() -> int: - """Load turn counter from /tmp, increment, write back. Cached per-process.""" +def _get_turn_token(hook_data: dict) -> int: + """Per-turn token from transcript_path size (monotonic, identical across siblings).""" + tp = hook_data.get("transcript_path", "") + if not tp: + return 0 + try: + return os.path.getsize(tp) + except OSError as exc: + logger.info("[HOOKS] cadence: transcript stat failed: %s", exc) + return 0 + + +def _lock(fd) -> None: + """Acquire exclusive lock (no-op on Windows).""" + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_EX) + + +def _unlock(fd) -> None: + """Release exclusive lock (no-op on Windows).""" + if fcntl is not None: + fcntl.flock(fd, fcntl.LOCK_UN) + + +def _close_fd(fd) -> None: + """Unlock and close a file descriptor safely.""" + try: + _unlock(fd) + fd.close() + except OSError as exc: + logger.info("[HOOKS] cadence: fd cleanup failed: %s", exc) + + +def _mtime_age(fd) -> float: + """Seconds since file was last modified, via the open fd.""" + try: + return time.time() - os.fstat(fd.fileno()).st_mtime + except OSError as exc: + logger.info("[HOOKS] cadence: fstat failed, assuming stale: %s", exc) + return _DEBOUNCE_S + 1 + + +def _should_increment(stored_turn: int, stored_token: int, token: int, fd) -> bool: + """Decide whether to increment the counter. Extracted for nesting depth.""" + if stored_turn < 0: + return True + if _mtime_age(fd) < _DEBOUNCE_S: + return False + if token == stored_token and token != 0: + return False + return True + + +def _load_and_increment(hook_data: dict) -> int: + """Load turn counter, increment exactly once per real turn. Multi-process safe.""" global _turn if _turn is not None: return _turn @@ -88,25 +154,42 @@ def _load_and_increment() -> int: _turn = 0 return 0 - count = 0 - if path.exists(): - try: - data = json.loads(path.read_text(encoding="utf-8")) - count = data.get("turn", 0) + 1 - except (json.JSONDecodeError, OSError) as exc: - logger.info("[HOOKS] cadence: state read failed, resetting: %s", exc) - count = 0 + token = _get_turn_token(hook_data) + fd = None try: - path.write_text(json.dumps({"turn": count}), encoding="utf-8") - except OSError as exc: - logger.info("[HOOKS] cadence: state write failed: %s", exc) + fd = open(path, "a+") # noqa: SIM115 + _lock(fd) + fd.seek(0) + content = fd.read() - _turn = count - return count + data = json.loads(content) if content.strip() else {} + stored_turn = data.get("turn", -1) + stored_token = data.get("token", -1) + + if _should_increment(stored_turn, stored_token, token, fd): + new_turn = max(stored_turn + 1, 0) + fd.seek(0) + fd.truncate() + fd.write(json.dumps({"turn": new_turn, "token": token})) + fd.flush() + else: + new_turn = stored_turn + + _close_fd(fd) + fd = None + _turn = new_turn + return new_turn + + except (OSError, json.JSONDecodeError) as exc: + logger.info("[HOOKS] cadence: state access failed: %s", exc) + if fd is not None: + _close_fd(fd) + _turn = 0 + return 0 -def should_fire(loader_name: str) -> bool: +def should_fire(loader_name: str, hook_data: dict | None = None) -> bool: """Check if a loader should fire this turn. Always True on turn 0 or if cadence disabled.""" config = _load_config() @@ -120,12 +203,24 @@ def should_fire(loader_name: str) -> bool: loader_config = config.get("loaders", {}).get(loader_name, {}) offset = loader_config.get("offset", 0) - turn = _load_and_increment() + turn = _load_and_increment(hook_data or {}) - if turn == 0: - return True + fired = turn == 0 or (turn % period) == offset - return (turn % period) == offset + session_id = os.environ.get("CLAUDE_CODE_SESSION_ID", "") + session_short = session_id[:8] if session_id else "none" + action = "fired" if fired else "skipped" + logger.info( + "[HOOKS] cadence %s loader=%s turn=%d period=%d offset=%d session=%s", + action, + loader_name, + turn, + period, + offset, + session_short, + ) + + return fired def reset_counter() -> None: @@ -133,11 +228,22 @@ def reset_counter() -> None: path = _state_path() if path is None: return + + fd = None try: - path.write_text(json.dumps({"turn": -1}), encoding="utf-8") + fd = open(path, "a+") # noqa: SIM115 + _lock(fd) + fd.seek(0) + fd.truncate() + fd.write(json.dumps({"turn": -1, "token": -1})) + fd.flush() + _close_fd(fd) + fd = None logger.info("[HOOKS] cadence: counter reset for post-compact re-injection") except OSError as exc: logger.info("[HOOKS] cadence: reset write failed: %s", exc) + if fd is not None: + _close_fd(fd) # ============================================================================= diff --git a/src/aipass/hooks/apps/modules/engine.py b/src/aipass/hooks/apps/modules/engine.py index c57a2a95..c46727aa 100644 --- a/src/aipass/hooks/apps/modules/engine.py +++ b/src/aipass/hooks/apps/modules/engine.py @@ -72,6 +72,7 @@ def _run_handler(handler_path: str, hook_data: dict) -> dict: return { "exit_code": result.get("exit_code", 0), "stdout": result.get("stdout", ""), + "sound": result.get("sound", ""), "stderr": "", "elapsed_ms": round(elapsed_ms, 1), } @@ -166,6 +167,14 @@ def dispatch(event_type: str, stdin_data: str, config: dict) -> str: } ) + if result.get("sound"): + try: + from aipass.hooks.apps.sound import speak + + speak(result["sound"]) + except Exception as exc: + logger.info("[HOOKS] sound playback failed for %s.%s: %s", event_type, hook_name, exc) + # Exit code 2: crash vs intentional block if result["exit_code"] == 2: is_intentional_block = False diff --git a/src/aipass/hooks/docs/cadence_redo_brief.md b/src/aipass/hooks/docs/cadence_redo_brief.md new file mode 100644 index 00000000..08b5911a --- /dev/null +++ b/src/aipass/hooks/docs/cadence_redo_brief.md @@ -0,0 +1,44 @@ +# Cadence REDO brief — DPLAN-0200 WS-B (FPLAN-0249 reopen) + +Your cadence build passed 435 tests but is **BROKEN in the live environment** — confirmed by direct observation + 3 research sub-agents. The 435 tests lied because they modeled the **wrong execution model**. Fix-forward: commit 2bccf03 stays, build on top, no history surgery. + +## ROOT CAUSE (confirmed) +Each loader runs as a **separate OS process**. `settings.json` registers distinct commands: `claude.py UserPromptSubmit:global_prompt`, `:branch_prompt`, `:identity_injector`, `:email_notification`, `:auto_process` — 5 separate python subprocesses. The module-level `_turn` cache assumed **sequential single-process** dispatch (cadence_investigation.md:28 and :99 flagged this as THE fragility "if Claude Code ever parallelizes" — it was ALREADY true). So `global` increments the /tmp counter to N, `branch` (separate process) to N+1 → counter races +2/turn → the two loaders **leapfrog** → firing is erratic, never "both every 5th". Live proof: counter 33 → 35 → 37 across single turns. + +## FIX 1 — DEDUP THE COUNTER (keystone) +The counter must advance **exactly once per real user turn** regardless of how many sibling processes call it. + +- **PRIMARY — mtime/recency debounce** on the /tmp state file. In `_load_and_increment`, before incrementing, `stat` the file; if last-modified < ~2000 ms ago, treat as the SAME turn → re-read current turn, return WITHOUT incrementing. (The 5 siblings spawn near-simultaneously; the first increments, the rest reuse.) +- **BACKSTOP — per-turn token** = `transcript_path` SIZE / line-count (it grows by one entry per turn, identical across all siblings, monotonic). Only increment if BOTH the debounce window elapsed AND the token changed. Kills the two realistic failure modes (pathologically fast turn; identical-prompt collision). +- **REQUIRED — fcntl.flock** around the read-modify-write. The siblings are truly simultaneous; without the lock, two can both read old-mtime and both increment. +- **SPECIAL-CASE turn < 0** (post-compact reset): ALWAYS increment — don't let the debounce swallow the post-compact turn-0 all-fire guarantee. +- **STDIN FIELDS (corrected — the doc is WRONG):** UserPromptSubmit stdin = `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `prompt`. The field is `prompt`, NOT `user_prompt`; `session_id` IS present. Thread the token from `engine.py`'s parsed dict into `should_fire(loader_name, hook_data)`. Keep the `session_id`-keyed /tmp filename as the partition key (already correct). The `_turn` module cache may remain as an intra-process micro-opt but must NOT be the dedup authority. +- **Correct cadence_investigation.md** outdated claims (user_prompt, no-session_id, single-process). + +## FIX 2 — PRAX-VISIBLE FIRE/SKIP LOGGING (Patrick wants to SEE it in the monitor) +Cadence already imports prax `system_logger`, and `system_logs/hooks_cadence.log` is ALREADY tailed live by `drone @prax monitor run` as `[HOOKS]`. The gap: `should_fire` logs nothing on the decision. Emit ONE structured INFO line at the `should_fire` choke point (covers all loaders, one site): + +``` +[HOOKS] cadence loader= action= turn= period=

offset= session=<8char> +``` + +Use `.info` (SystemLogger has no `.debug`). ALSO gate/dedup the "counter reset" log — it spammed ~8x per cluster; confirm PreCompact reset fires EXACTLY once and logs once. + +## FIX 3 — ACTION-GATED SOUND (the false signal Patrick HEARD) +Right now `speak("global prompt")` / `speak("branch prompt")` is the FIRST line of each loader, BEFORE the `should_fire` check — so piper announces every turn even when the loader SKIPS injection. The voice lies. Patrick's rule: **if global/branch SKIP, they must be SILENT — sound ONLY on actual injection.** + +Build the **system-wide** version (Patrick wants it right for ALL hooks): handlers return an explicit `sound` key in their result dict, e.g. `{"stdout": content, "sound": "global prompt", "exit_code": 0}`; the engine plays it at `engine.py:208` inside the `if result["stdout"]:` block (or whenever the `sound` key is present) — ONE integration point, every hook auto action-gated + self-identifying. Remove the scattered leading `speak()` calls from the loaders. Preserve the gates/notifications that legitimately emit empty stdout (let them set the `sound` key explicitly). `is_muted()` still short-circuits. + +Sound architecture for reference: `hooks/apps/sound.py` `speak()`/`play()` → piper → aplay; mute flag `/tmp/aipass-hooks-muted`. + +## TEST PLAN (this is what 435 green MISSED — required) +- **Model separate-process execution:** simulate N independent processes each calling `_load_and_increment` for the same turn (no shared module cache) and assert the counter advances EXACTLY ONCE. REWRITE `test_cadence.py:113` `test_counter_increments_once_per_process` (it encodes the invalid single-process assumption). +- Assert the leapfrog is gone: two loaders in the same turn see the SAME turn number — both fire on offset-0 turns, both skip otherwise. +- Assert reset → next turn = 0 = all fire (the turn<0 special-case survives the debounce). +- Assert SKIP = silent (no `sound` key) AND logs `action=skipped` (not fired). +- Assert flock prevents double-increment under simulated simultaneity. + +## ACCEPTANCE +Multi-process simulation tests green + seedgo 100% + pyright 0. But do **NOT** claim "works" from unit tests alone — that is exactly what failed. devpulse will LIVE-VERIFY next session (prax monitor shows correct fire/skip, sound only on inject, counter advances once/turn). Report what you built + test results. NO git commits (devpulse commits). Reply via dispatch if blocked. + +Track in your FPLAN (reopen FPLAN-0249). This is the careful re-do — get it right, verify against the REAL execution model. diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py index ed9ec706..14c3ace2 100644 --- a/src/aipass/hooks/tests/test_auto_fix.py +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -7,7 +7,12 @@ # Modified: 2026-05-22 # ============================================= -"""Tests for handlers/lifecycle/auto_fix.py.""" +"""Tests for handlers/lifecycle/auto_fix.py. + +NOTE: sound is action-gated via the result "sound" key — it is set to +"auto fix diagnostics" only on the error-surfacing path; clean and skip +paths stay silent (no "sound" key). +""" import json import tempfile @@ -61,32 +66,31 @@ class TestAutoFixSkips: def test_skip_unknown_extension(self): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle - with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak"): - result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}}) + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/file.xyz"}}) assert result["stdout"] == "" assert result["exit_code"] == 0 + assert "sound" not in result class TestAutofixPython: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + def test_python_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}}) assert result["exit_code"] == 0 parsed = json.loads(result["stdout"]) assert parsed["systemMessage"] == "[diagnostics] ok" + assert "sound" not in result - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks") - def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + def test_python_syntax_error(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle mock_py.return_value = ["SYNTAX: invalid syntax at line 5"] @@ -96,25 +100,25 @@ class TestAutofixPython: assert "additionalContext" in parsed.get("hookSpecificOutput", {}) assert "SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] assert "1 error(s)" in parsed["systemMessage"] + assert result.get("sound") == "auto fix diagnostics" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks") - def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + def test_python_ruff_lint_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle mock_py.return_value = ["LINT: bad.py:10:1: F401 unused import"] result = handle({"tool_name": "Write", "tool_input": {"file_path": "/tmp/bad.py"}}) parsed = json.loads(result["stdout"]) assert "LINT" in parsed["hookSpecificOutput"]["additionalContext"] + assert result.get("sound") == "auto fix diagnostics" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo, mock_speak): + def test_python_pyright_errors(self, mock_py, mock_ruff_s, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle with patch( @@ -125,12 +129,12 @@ class TestAutofixPython: parsed = json.loads(result["stdout"]) assert "TYPE: L42" in parsed["hookSpecificOutput"]["additionalContext"] + assert result.get("sound") == "auto fix diagnostics" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright, mock_speak): + def test_seedgo_violations_surfaced(self, mock_py, mock_ruff_s, mock_pyright): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle with patch( @@ -141,15 +145,15 @@ class TestAutofixPython: parsed = json.loads(result["stdout"]) assert "SEEDGO: missing file header" in parsed["hookSpecificOutput"]["additionalContext"] + assert result.get("sound") == "auto fix diagnostics" class TestAutoFixStateFile: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + def test_state_file_written_on_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle mock_ruff_s.return_value = [{"line": 5, "message": "F401: unused import"}] @@ -160,8 +164,9 @@ class TestAutoFixStateFile: try: with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path): - handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}}) + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/errors.py"}}) + assert result.get("sound") == "auto fix diagnostics" assert state_path.exists() state = json.loads(state_path.read_text(encoding="utf-8")) assert len(state["errors"]) == 2 @@ -171,12 +176,11 @@ class TestAutoFixStateFile: if state_path.exists(): state_path.unlink() - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_pyright_check", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_ruff_lint_structured", return_value=[]) @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix._run_python_checks", return_value=[]) - def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo, mock_speak): + def test_state_file_cleared_on_no_errors(self, mock_py, mock_ruff_s, mock_pyright, mock_seedgo): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as tf: @@ -185,8 +189,9 @@ class TestAutoFixStateFile: try: with patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.STATE_FILE", state_path): - handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}}) + result = handle({"tool_name": "Edit", "tool_input": {"file_path": "/tmp/clean.py"}}) + assert "sound" not in result assert not state_path.exists() finally: if state_path.exists(): @@ -194,8 +199,7 @@ class TestAutoFixStateFile: class TestAutoFixJson: - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") - def test_json_valid(self, mock_speak, tmp_path): + def test_json_valid(self, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle json_file = tmp_path / "good.json" @@ -204,9 +208,9 @@ class TestAutoFixJson: result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}}) parsed = json.loads(result["stdout"]) assert parsed["systemMessage"] == "[diagnostics] ok" + assert "sound" not in result - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") - def test_json_invalid_syntax(self, mock_speak, tmp_path): + def test_json_invalid_syntax(self, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle json_file = tmp_path / "bad.json" @@ -215,9 +219,9 @@ class TestAutoFixJson: result = handle({"tool_name": "Write", "tool_input": {"file_path": str(json_file)}}) parsed = json.loads(result["stdout"]) assert "JSON SYNTAX" in parsed["hookSpecificOutput"]["additionalContext"] + assert result.get("sound") == "auto fix diagnostics" - @patch("aipass.hooks.apps.handlers.lifecycle.auto_fix.speak") - def test_json_corruption_detected(self, mock_speak, tmp_path): + def test_json_corruption_detected(self, tmp_path): from aipass.hooks.apps.handlers.lifecycle.auto_fix import handle json_file = tmp_path / "corrupt.json" @@ -226,6 +230,7 @@ class TestAutoFixJson: result = handle({"tool_name": "Edit", "tool_input": {"file_path": str(json_file)}}) parsed = json.loads(result["stdout"]) assert "EMOJI CORRUPTION" in parsed["hookSpecificOutput"]["additionalContext"] + assert result.get("sound") == "auto fix diagnostics" class TestAutoFixSubprocessChecks: diff --git a/src/aipass/hooks/tests/test_branch_loader.py b/src/aipass/hooks/tests/test_branch_loader.py index e0b1c376..41c0e280 100644 --- a/src/aipass/hooks/tests/test_branch_loader.py +++ b/src/aipass/hooks/tests/test_branch_loader.py @@ -31,13 +31,13 @@ class TestBranchLoaderHandler: prompt = aipass_dir / "aipass_local_prompt.md" prompt.write_text("# Test Branch\nSome instructions", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert "Branch Context:" in result["stdout"] assert "Some instructions" in result["stdout"] + assert result["sound"] == "branch prompt" def test_loads_private_integrations(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -49,11 +49,11 @@ class TestBranchLoaderHandler: private = integration / "private_prompt.md" private.write_text("# Private Integration\nSecret stuff", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Private Integration" in result["stdout"] + assert result["sound"] == "branch prompt" def test_loads_both_prompt_and_integrations(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -67,9 +67,8 @@ class TestBranchLoaderHandler: integration.mkdir(parents=True) (integration / "private_prompt.md").write_text("Compass prompt", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Branch prompt" in result["stdout"] assert "Compass prompt" in result["stdout"] @@ -77,11 +76,11 @@ class TestBranchLoaderHandler: def test_returns_empty_when_no_branch_root(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" + assert "sound" not in result def test_stops_at_repo_root(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -90,11 +89,11 @@ class TestBranchLoaderHandler: nested = tmp_path / "some" / "deep" / "path" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(nested)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(nested)}) assert result["stdout"] == "" + assert "sound" not in result def test_walks_up_to_find_branch(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -107,22 +106,23 @@ class TestBranchLoaderHandler: nested = tmp_path / "apps" / "handlers" / "security" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(nested)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(nested)}) assert "Found it" in result["stdout"] def test_empty_hook_data(self): from aipass.hooks.apps.handlers.prompt.branch_loader import handle - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): + # Path.cwd patch must be OUTSIDE the importlib patch — mock.patch uses + # importlib.import_module to resolve "pathlib", which the inner mock hijacks. + with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): with patch("importlib.import_module", return_value=_mock_cadence_fires()): - with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): - result = handle({}) + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_no_prompt_file_but_has_branch_root(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -130,11 +130,11 @@ class TestBranchLoaderHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert result["stdout"] == "" + assert "sound" not in result def test_includes_source_path_in_output(self, tmp_path): from aipass.hooks.apps.handlers.prompt.branch_loader import handle @@ -145,8 +145,7 @@ class TestBranchLoaderHandler: aipass_dir.mkdir() (aipass_dir / "aipass_local_prompt.md").write_text("content", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({"cwd": str(tmp_path)}) + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({"cwd": str(tmp_path)}) assert "Source:" in result["stdout"] diff --git a/src/aipass/hooks/tests/test_cadence.py b/src/aipass/hooks/tests/test_cadence.py index bb000231..016214ef 100644 --- a/src/aipass/hooks/tests/test_cadence.py +++ b/src/aipass/hooks/tests/test_cadence.py @@ -7,23 +7,42 @@ # Modified: 2026-06-08 # ============================================= -"""Tests for apps/modules/cadence.py.""" +"""Tests for apps/modules/cadence.py. + +Cadence runs MULTI-PROCESS in production: each UserPromptSubmit hook is a +separate OS process. Tests model that by resetting the module _turn cache +between calls (= new process) and aging the state file past the mtime +debounce window (= a real prior turn, not a sibling in the same turn). +""" import json import importlib +import os +import time from unittest.mock import patch MODULE = "aipass.hooks.apps.modules.cadence" def _reset_module_globals(): - """Reset module-level caches between tests.""" + """Reset module-level caches between tests (also = simulate a new process).""" import aipass.hooks.apps.modules.cadence as mod mod._turn = None mod._config = None +def _write_state(tmp_path, turn, token=-1, session="test-session", aged=True): + """Write a cadence state file. aged=True backdates mtime past the debounce + window so it reads as a PREVIOUS turn; aged=False = sibling in same turn.""" + state_file = tmp_path / f"aipass-cadence-{session}.json" + state_file.write_text(json.dumps({"turn": turn, "token": token})) + if aged: + old = time.time() - 10 + os.utime(state_file, (old, old)) + return state_file + + class TestShouldFire: def setup_method(self): _reset_module_globals() @@ -55,8 +74,7 @@ class TestShouldFire: def test_non_fire_turn_returns_false(self, tmp_path): from aipass.hooks.apps.modules.cadence import should_fire - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 0})) + _write_state(tmp_path, turn=0) with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), @@ -68,8 +86,7 @@ class TestShouldFire: def test_fire_turn_returns_true(self, tmp_path): from aipass.hooks.apps.modules.cadence import should_fire - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 3})) + _write_state(tmp_path, turn=3) config = tmp_path / "cadence.json" config.write_text(json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 4}}})) @@ -110,11 +127,12 @@ class TestShouldFire: with patch.dict("os.environ", env, clear=True): assert should_fire("global") is True - def test_counter_increments_once_per_process(self, tmp_path): + def test_counter_increments_once_across_sibling_processes(self, tmp_path): + """Each loader is a SEPARATE OS process. The counter must advance + exactly once per real turn no matter how many siblings call it.""" from aipass.hooks.apps.modules.cadence import should_fire - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 3})) + state_file = _write_state(tmp_path, turn=3) with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), @@ -122,10 +140,61 @@ class TestShouldFire: patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), ): should_fire("global") - should_fire("branch") + for _ in range(4): # 4 more siblings, each a fresh process + _reset_module_globals() + should_fire("branch") data = json.loads(state_file.read_text()) assert data["turn"] == 4 + def test_sibling_processes_agree_on_turn_no_leapfrog(self, tmp_path): + """The S210 live bug: global saw turn N, branch saw N+1 — they + leapfrogged and never both fired. Both siblings must see the SAME + turn and make the SAME decision.""" + from aipass.hooks.apps.modules.cadence import should_fire + + _write_state(tmp_path, turn=4) # next real turn = 5 = fire (5 % 5 == 0) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + _reset_module_globals() # branch runs as a separate process + assert should_fire("branch") is True + + def test_token_backstop_blocks_double_increment(self, tmp_path): + """Even past the debounce window, an unchanged transcript token means + no new turn happened — the counter must not advance.""" + from aipass.hooks.apps.modules.cadence import should_fire + + transcript = tmp_path / "transcript.jsonl" + transcript.write_text("x" * 100) + state_file = _write_state(tmp_path, turn=3, token=100) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + should_fire("global", {"transcript_path": str(transcript)}) + assert json.loads(state_file.read_text())["turn"] == 3 + + def test_reset_special_case_survives_debounce(self, tmp_path): + """turn < 0 (post-compact reset) must ALWAYS increment to 0, even when + the reset just happened (fresh mtime would normally debounce).""" + from aipass.hooks.apps.modules.cadence import should_fire + + state_file = _write_state(tmp_path, turn=-1, aged=False) + + with ( + patch(f"{MODULE}._GUARD_DIR", tmp_path), + patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), + patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), + ): + assert should_fire("global") is True + assert json.loads(state_file.read_text())["turn"] == 0 + def test_period_zero_always_fires(self, tmp_path): from aipass.hooks.apps.modules.cadence import should_fire @@ -148,8 +217,7 @@ class TestShouldFire: json.dumps({"enabled": True, "period": 5, "loaders": {"global": {"offset": 0}, "branch": {"offset": 2}}}) ) - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 4})) + _write_state(tmp_path, turn=4) from aipass.hooks.apps.modules.cadence import should_fire @@ -164,8 +232,7 @@ class TestShouldFire: def test_unknown_loader_uses_offset_zero(self, tmp_path): from aipass.hooks.apps.modules.cadence import should_fire - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 4})) + _write_state(tmp_path, turn=4) with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), @@ -312,8 +379,7 @@ class TestPerSessionIsolation: def test_different_sessions_use_different_files(self, tmp_path): from aipass.hooks.apps.modules.cadence import should_fire - state_a = tmp_path / "aipass-cadence-session-a.json" - state_a.write_text(json.dumps({"turn": 4})) + state_a = _write_state(tmp_path, turn=4, session="session-a") with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), @@ -391,35 +457,35 @@ class TestLoaderCadenceGuard: _reset_module_globals() def test_global_loader_skips_on_non_fire_turn(self, tmp_path): + """Skip = empty stdout AND no sound key — a skipped loader is SILENT.""" from aipass.hooks.apps.handlers.prompt.global_loader import handle - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 0})) + _write_state(tmp_path, turn=0) # next turn = 1 = skip with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), - patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"), ): result = handle({}) assert result["stdout"] == "" assert result["exit_code"] == 0 + assert "sound" not in result def test_branch_loader_skips_on_non_fire_turn(self, tmp_path): + """Skip = empty stdout AND no sound key — a skipped loader is SILENT.""" from aipass.hooks.apps.handlers.prompt.branch_loader import handle - state_file = tmp_path / "aipass-cadence-test-session.json" - state_file.write_text(json.dumps({"turn": 0})) + _write_state(tmp_path, turn=0) # next turn = 1 = skip with ( patch(f"{MODULE}._GUARD_DIR", tmp_path), patch.dict("os.environ", {"CLAUDE_CODE_SESSION_ID": "test-session"}), patch(f"{MODULE}._CONFIG_PATH", tmp_path / "cadence.json"), - patch("aipass.hooks.apps.handlers.prompt.branch_loader.speak"), ): result = handle({}) assert result["stdout"] == "" assert result["exit_code"] == 0 + assert "sound" not in result diff --git a/src/aipass/hooks/tests/test_compact.py b/src/aipass/hooks/tests/test_compact.py index 9e704546..c33663ee 100644 --- a/src/aipass/hooks/tests/test_compact.py +++ b/src/aipass/hooks/tests/test_compact.py @@ -29,25 +29,25 @@ class TestCompactHandler: ), encoding="utf-8", ) - with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"): - result = handle({"cwd": str(tmp_path)}) + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value="Git branch: dev"): + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert "POST-COMPACT RECOVERY" in result["stdout"] assert "Git branch: dev" in result["stdout"] assert "did stuff" in result["stdout"] assert "STATUS.local.md" not in result["stdout"] + assert result["sound"] == "pre compact" def test_returns_recovery_when_no_branch_dir(self): from aipass.hooks.apps.handlers.lifecycle.compact import handle - with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): - result = handle({"cwd": "/tmp/nonexistent"}) + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + result = handle({"cwd": "/tmp/nonexistent"}) assert result["exit_code"] == 0 assert "POST-COMPACT RECOVERY" in result["stdout"] + assert result["sound"] == "pre compact" def test_dispatched_agent_gets_save_warning(self, tmp_path): from aipass.hooks.apps.handlers.lifecycle.compact import handle @@ -55,10 +55,9 @@ class TestCompactHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): - with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}): - result = handle({"cwd": str(tmp_path)}) + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": "dispatched"}): + result = handle({"cwd": str(tmp_path)}) assert "SAVE STATE NOW" in result["stdout"] assert "STATUS.local.md" not in result["stdout"] @@ -69,10 +68,9 @@ class TestCompactHandler: trinity = tmp_path / ".trinity" trinity.mkdir() - with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): - with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}): - result = handle({"cwd": str(tmp_path)}) + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + with patch.dict("os.environ", {"AIPASS_SESSION_TYPE": ""}): + result = handle({"cwd": str(tmp_path)}) assert "Recovery Protocol" in result["stdout"] assert "STATUS.local.md" not in result["stdout"] @@ -80,9 +78,8 @@ class TestCompactHandler: def test_empty_hook_data(self): from aipass.hooks.apps.handlers.lifecycle.compact import handle - with patch("aipass.hooks.apps.handlers.lifecycle.compact.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): - with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))): - result = handle({}) + with patch("aipass.hooks.apps.handlers.lifecycle.compact._get_git_info", return_value=None): + with patch("pathlib.Path.cwd", return_value=MagicMock(parts=("/", "tmp"))): + result = handle({}) assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_global_loader.py b/src/aipass/hooks/tests/test_global_loader.py index 3b93c26b..30b3c01c 100644 --- a/src/aipass/hooks/tests/test_global_loader.py +++ b/src/aipass/hooks/tests/test_global_loader.py @@ -29,26 +29,26 @@ class TestGlobalLoaderHandler: prompt.write_text("# AIPass Global\nContext here", encoding="utf-8") monkeypatch.chdir(tmp_path) - with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): - with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({}) + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert "AIPass Global" in result["stdout"] assert "Context here" in result["stdout"] + assert result["sound"] == "global prompt" def test_returns_empty_when_file_missing(self, tmp_path, monkeypatch): from aipass.hooks.apps.handlers.prompt.global_loader import handle monkeypatch.chdir(tmp_path) - with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): - with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({}) + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_empty_hook_data(self, tmp_path, monkeypatch): from aipass.hooks.apps.handlers.prompt.global_loader import handle @@ -58,10 +58,9 @@ class TestGlobalLoaderHandler: (aipass_dir / "aipass_global_prompt.md").write_text("content", encoding="utf-8") monkeypatch.chdir(tmp_path) - with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): - with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({}) + with patch.dict("os.environ", {"AIPASS_HOME": str(tmp_path)}): + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "content" @@ -77,10 +76,10 @@ class TestGlobalLoaderHandler: (aipass_dir / "aipass_global_prompt.md").write_text("# Project Prompt", encoding="utf-8") monkeypatch.chdir(project) - with patch("aipass.hooks.apps.handlers.prompt.global_loader.speak"): - with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}): - with patch("importlib.import_module", return_value=_mock_cadence_fires()): - result = handle({}) + with patch.dict("os.environ", {"AIPASS_HOME": "/some/other/path"}): + with patch("importlib.import_module", return_value=_mock_cadence_fires()): + result = handle({}) assert result["exit_code"] == 0 assert "Project Prompt" in result["stdout"] + assert result["sound"] == "global prompt" diff --git a/src/aipass/hooks/tests/test_identity.py b/src/aipass/hooks/tests/test_identity.py index 58aab74a..3a1fb062 100644 --- a/src/aipass/hooks/tests/test_identity.py +++ b/src/aipass/hooks/tests/test_identity.py @@ -40,22 +40,22 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(tmp_path)}) + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert "devpulse Identity" in result["stdout"] assert "orchestration_hub" in result["stdout"] assert "Pragmatic" in result["stdout"] + assert result["sound"] == "identity" def test_returns_empty_when_no_passport(self, tmp_path): from aipass.hooks.apps.handlers.prompt.identity import handle - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(tmp_path)}) + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_walks_up_to_find_passport(self, tmp_path): from aipass.hooks.apps.handlers.prompt.identity import handle @@ -67,8 +67,7 @@ class TestIdentityHandler: nested = tmp_path / "apps" / "handlers" nested.mkdir(parents=True) - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(nested)}) + result = handle({"cwd": str(nested)}) assert "devpulse Identity" in result["stdout"] @@ -80,8 +79,7 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps(SAMPLE_PASSPORT), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(tmp_path)}) + result = handle({"cwd": str(tmp_path)}) out = result["stdout"] assert "Path: src/aipass/devpulse" in out @@ -100,21 +98,21 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text(json.dumps({"branch_info": {"branch_name": "test"}, "identity": {}}), encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(tmp_path)}) + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert "test Identity" in result["stdout"] + assert result["sound"] == "identity" def test_empty_hook_data(self): from aipass.hooks.apps.handlers.prompt.identity import handle - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): - result = handle({}) + with patch("pathlib.Path.cwd", return_value=Path("/tmp/nonexistent")): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_corrupt_passport_json(self, tmp_path): from aipass.hooks.apps.handlers.prompt.identity import handle @@ -124,8 +122,8 @@ class TestIdentityHandler: passport = trinity / "passport.json" passport.write_text("{broken json", encoding="utf-8") - with patch("aipass.hooks.apps.handlers.prompt.identity.speak"): - result = handle({"cwd": str(tmp_path)}) + result = handle({"cwd": str(tmp_path)}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result diff --git a/src/aipass/hooks/tests/test_rollover.py b/src/aipass/hooks/tests/test_rollover.py index 88aef64b..180b08d4 100644 --- a/src/aipass/hooks/tests/test_rollover.py +++ b/src/aipass/hooks/tests/test_rollover.py @@ -17,39 +17,37 @@ class TestRolloverHandler: def test_no_repo_root_returns_empty(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None): - result = handle({}) + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=None): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_no_overdue_returns_empty(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]): - result = handle({}) + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", return_value=[]): + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_overdue_triggers_rollover(self): from aipass.hooks.apps.handlers.lifecycle.rollover import handle - with patch("aipass.hooks.apps.handlers.lifecycle.rollover.speak"): - with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): - with patch( - "aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", - return_value=[("devpulse", "local", "21/20 sessions")], - ): - with patch( - "aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok") - ): - result = handle({}) + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._find_repo_root", return_value=MagicMock()): + with patch( + "aipass.hooks.apps.handlers.lifecycle.rollover._find_overdue", + return_value=[("devpulse", "local", "21/20 sessions")], + ): + with patch("aipass.hooks.apps.handlers.lifecycle.rollover._run_rollover", return_value=(True, "ok")): + result = handle({}) assert result["exit_code"] == 0 + assert result["sound"] == "pre compact rollover" def test_check_file_v2_sessions_overdue(self, tmp_path): from aipass.hooks.apps.handlers.lifecycle.rollover import _check_file diff --git a/src/aipass/hooks/tests/test_subagent_gate.py b/src/aipass/hooks/tests/test_subagent_gate.py index 82563d8e..1f4e098d 100644 --- a/src/aipass/hooks/tests/test_subagent_gate.py +++ b/src/aipass/hooks/tests/test_subagent_gate.py @@ -18,24 +18,23 @@ from aipass.hooks.apps.handlers.security.subagent_gate import handle class TestSubagentGateHandler: def test_no_repo_root_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): - result = handle({"cwd": "/tmp/nowhere"}) + result = handle({"cwd": "/tmp/nowhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result def test_no_modified_files_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): - result = handle({"cwd": "/tmp/somewhere"}) + result = handle({"cwd": "/tmp/somewhere"}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_modified_files_no_violations_allows(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + def test_modified_files_no_violations_allows(self, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path mock_root.return_value = Path("/fake/repo") @@ -43,13 +42,13 @@ class TestSubagentGateHandler: result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_violations_blocks(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + def test_violations_blocks(self, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path mock_root.return_value = Path("/fake/repo") @@ -62,10 +61,10 @@ class TestSubagentGateHandler: assert "Missing docstring" in parsed["reason"] assert "No tests" in parsed["reason"] assert "bad.py" in parsed["reason"] + assert result["sound"] == "subagent gate" @patch("subprocess.run") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_skip_claude_hooks_from_modified_files(self, mock_speak, mock_run, tmp_path): + def test_skip_claude_hooks_from_modified_files(self, mock_run, tmp_path): src = tmp_path / "src" / "aipass" / "hooks" src.mkdir(parents=True) @@ -91,8 +90,7 @@ class TestSubagentGateHandler: @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_readme_accountability_advisory(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + def test_readme_accountability_advisory(self, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path mock_root.return_value = Path("/fake/repo") @@ -106,18 +104,18 @@ class TestSubagentGateHandler: parsed = json.loads(result["stdout"]) assert parsed["decision"] == "allow" assert "README" in parsed["reason"] + assert "sound" not in result def test_empty_hook_data_allows(self): with patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root", return_value=None): - with patch("aipass.hooks.apps.handlers.security.subagent_gate.speak"): - result = handle({}) + result = handle({}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_exception_in_get_modified_allows(self, mock_speak, mock_root, mock_modified): + def test_exception_in_get_modified_allows(self, mock_root, mock_modified): from pathlib import Path mock_root.return_value = Path("/fake/repo") @@ -125,6 +123,7 @@ class TestSubagentGateHandler: result = handle({"cwd": "/fake/repo/src/aipass/hooks"}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result class TestSubagentGateExternalProject: @@ -161,8 +160,7 @@ class TestSubagentGateExternalProject: @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist") @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_violations_block_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + def test_violations_block_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path mock_root.return_value = Path("/fake/vera") @@ -173,13 +171,13 @@ class TestSubagentGateExternalProject: parsed = json.loads(result["stdout"]) assert parsed["decision"] == "block" assert "Missing docstring" in parsed["reason"] + assert result["sound"] == "subagent gate" @patch("aipass.hooks.apps.handlers.security.subagent_gate._check_hook_readme_accountability", return_value=None) @patch("aipass.hooks.apps.handlers.security.subagent_gate._run_seedgo_checklist", return_value=[]) @patch("aipass.hooks.apps.handlers.security.subagent_gate._get_modified_py_files") @patch("aipass.hooks.apps.handlers.security.subagent_gate._find_repo_root") - @patch("aipass.hooks.apps.handlers.security.subagent_gate.speak") - def test_clean_files_allow_external_project(self, mock_speak, mock_root, mock_modified, mock_seedgo, mock_readme): + def test_clean_files_allow_external_project(self, mock_root, mock_modified, mock_seedgo, mock_readme): from pathlib import Path mock_root.return_value = Path("/fake/vera") @@ -187,3 +185,4 @@ class TestSubagentGateExternalProject: result = handle({"cwd": "/fake/vera/src/vera_studio/quality"}) assert result["exit_code"] == 0 assert result["stdout"] == "" + assert "sound" not in result diff --git a/src/aipass/prax/apps/handlers/monitoring/__init__.py b/src/aipass/prax/apps/handlers/monitoring/__init__.py index f22c7aa2..6a27bc52 100644 --- a/src/aipass/prax/apps/handlers/monitoring/__init__.py +++ b/src/aipass/prax/apps/handlers/monitoring/__init__.py @@ -6,7 +6,7 @@ Handlers for file watching, log monitoring, branch detection, and filtering. """ # Export main handler interfaces -from .unified_stream import print_event, print_command_separator +from .unified_stream import print_event, print_command_separator, print_hook_event from .branch_detector import detect_branch_from_path from .interactive_filter import ( parse_command, @@ -34,6 +34,7 @@ from .log_watcher import start_log_watcher, stop_log_watcher, is_log_watcher_act __all__ = [ "print_event", "print_command_separator", + "print_hook_event", "detect_branch_from_path", "parse_command", "get_help_text", diff --git a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py index 8d88b500..1507116b 100644 --- a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py +++ b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py @@ -102,9 +102,13 @@ class LogFileWatcher(FileSystemEventHandler): self.last_command_per_branch: Dict[str, str] = {} def _process_log_line(self, branch: str, line: str, file_path: str) -> None: - """Process a single log line: detect commands or emit as log event.""" + """Process a single log line: detect hooks, commands, or emit as log event.""" if not line.strip(): return + hook_info = self._extract_hook_info(line) + if hook_info: + self._emit_hook_event(branch, hook_info) + return command_info = self._extract_command_info(line) if command_info: self._emit_command_separator(branch, command_info) @@ -159,6 +163,52 @@ class LogFileWatcher(FileSystemEventHandler): except Exception as e: logger.info(f"Error reading log file {file_path}: {e}") + _HOOK_PATTERN = re.compile(r"\[HOOKS\]\s+(\w+)\s+.*?action=(\w+)") + + def _extract_hook_info(self, log_line: str) -> Optional[Dict[str, str]]: + """Extract hook event info from structured [HOOKS] log lines. + + Matches lines like: + [HOOKS] cadence fired loader=global action=fired turn=35 period=5 ... + [HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 ... + """ + match = self._HOOK_PATTERN.search(log_line) + if not match: + return None + name = match.group(1) + action = match.group(2) + details: Dict[str, str] = {"name": name, "action": action} + for kv_match in re.finditer(r"(\w+)=(\S+)", log_line): + details[kv_match.group(1)] = kv_match.group(2) + return details + + def _emit_hook_event(self, branch: str, hook_info: Dict[str, str]) -> None: + """Emit a hook event to the monitoring queue.""" + action = hook_info.get("action", "unknown") + name = hook_info.get("name", "hook") + loader = hook_info.get("loader", "") + turn = hook_info.get("turn", "") + + parts = [f"{name}:{action}"] + if loader: + parts.append(f"loader={loader}") + if turn: + parts.append(f"turn={turn}") + message = " ".join(parts) + + level = "success" if action == "fired" else "info" + + hook_event = MonitoringEvent( + priority=2, + event_type="hook", + branch=branch, + action=action, + message=message, + level=level, + timestamp=datetime.now(), + ) + self.event_queue.enqueue(hook_event) + def _should_display_log(self, _log_line: str) -> bool: """Check if log line should be displayed. No filtering — show everything.""" return True diff --git a/src/aipass/prax/apps/handlers/monitoring/unified_stream.py b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py index e79028f7..411919d5 100644 --- a/src/aipass/prax/apps/handlers/monitoring/unified_stream.py +++ b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py @@ -185,6 +185,28 @@ def print_command_separator(branch: str, command: str, caller: Optional[str] = N console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]") +def print_hook_event(branch: str, message: str, action: str = "unknown"): + """Print a hook event with distinct fired/skipped styling. + + Args: + branch: Branch the hook event originated from + message: Hook event summary (e.g. "cadence:fired loader=global turn=35") + action: "fired" or "skipped" (controls color) + """ + with _print_lock: + timestamp = datetime.now().strftime("%H:%M:%S") + if action == "fired": + style = "bold green" + symbol = "⚡" + elif action == "skipped": + style = "dim" + symbol = "·" + else: + style = "white" + symbol = "?" + console.print(f"[dim]{timestamp}[/dim] [{style}]{symbol} HOOK {message}[/{style}]") + + def print_status(watched_branches: List[str], verbosity: int, filters: Optional[Dict] = None): """ Display current monitoring status diff --git a/src/aipass/prax/apps/modules/monitor.py b/src/aipass/prax/apps/modules/monitor.py index 2a5eafd0..4b04251d 100755 --- a/src/aipass/prax/apps/modules/monitor.py +++ b/src/aipass/prax/apps/modules/monitor.py @@ -38,6 +38,7 @@ from aipass.prax.apps.handlers.json import json_handler from aipass.prax.apps.handlers.monitoring import ( print_event, # unified_stream.py print_command_separator, # unified_stream.py - command headers + print_hook_event, # unified_stream.py - hook fire/skip display MonitoringQueue, # event_queue.py ModuleTracker, # module_tracker.py ) @@ -338,6 +339,8 @@ def _render_event(event) -> None: if len(parts) == 2 and parts[1]: target = parts[1] print_command_separator(event.branch, event.message, caller, target) + elif event.event_type == "hook": + print_hook_event(event.branch, event.message, event.action) else: print_event(event.event_type, event.branch, event.message, event.level, pid=branch_pid) diff --git a/src/aipass/prax/tests/test_log_watcher.py b/src/aipass/prax/tests/test_log_watcher.py index efab823c..48c1f3cf 100644 --- a/src/aipass/prax/tests/test_log_watcher.py +++ b/src/aipass/prax/tests/test_log_watcher.py @@ -1063,3 +1063,105 @@ class TestInitializePositionsAdditional: watcher.initialize_positions() assert str(log_file) not in watcher.log_positions + + +class TestExtractHookInfo: + """Test _extract_hook_info for structured [HOOKS] log lines.""" + + def test_fired_line_extracted(self): + """Should extract name, action, and key-value details from a fired line.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + line = "[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc12345" + result = watcher._extract_hook_info(line) + assert result is not None + assert result["name"] == "cadence" + assert result["action"] == "fired" + assert result["loader"] == "global" + assert result["turn"] == "35" + + def test_skipped_line_extracted(self): + """Should extract skipped hook events.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + line = "[HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 offset=0 session=abc12345" + result = watcher._extract_hook_info(line) + assert result is not None + assert result["name"] == "cadence" + assert result["action"] == "skipped" + assert result["loader"] == "branch" + + def test_non_hook_line_returns_none(self): + """Non-hook log lines should return None.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + result = watcher._extract_hook_info("[FLOW] Creating plan FPLAN-0099") + assert result is None + + def test_hook_line_without_action_returns_none(self): + """A [HOOKS] line without action= should return None.""" + mod = _import_log_watcher() + watcher, _ = _make_watcher(mod) + + result = watcher._extract_hook_info("[HOOKS] something happened no structured data") + assert result is None + + +class TestEmitHookEvent: + """Test _emit_hook_event queues properly.""" + + def test_fired_event_queued_with_correct_kwargs(self): + """Fired hook events should pass event_type=hook, level=success to MonitoringEvent.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + mock_event_cls = MagicMock() + with patch.object(mod, "MonitoringEvent", mock_event_cls): + hook_info = {"name": "cadence", "action": "fired", "loader": "global", "turn": "35"} + watcher._emit_hook_event("HOOKS", hook_info) + + mock_event_cls.assert_called_once() + kwargs = mock_event_cls.call_args[1] + assert kwargs["event_type"] == "hook" + assert kwargs["action"] == "fired" + assert kwargs["level"] == "success" + assert "cadence:fired" in kwargs["message"] + assert "loader=global" in kwargs["message"] + assert "turn=35" in kwargs["message"] + + def test_skipped_event_queued_with_info_level(self): + """Skipped hook events should pass level=info to MonitoringEvent.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + mock_event_cls = MagicMock() + with patch.object(mod, "MonitoringEvent", mock_event_cls): + hook_info = {"name": "cadence", "action": "skipped", "loader": "branch", "turn": "37"} + watcher._emit_hook_event("HOOKS", hook_info) + + kwargs = mock_event_cls.call_args[1] + assert kwargs["action"] == "skipped" + assert kwargs["level"] == "info" + + def test_process_log_line_routes_hook_to_emit(self): + """Hook lines in _process_log_line should route to _emit_hook_event, not _emit_log_event.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + with ( + patch.object(watcher, "_emit_hook_event") as mock_hook, + patch.object(watcher, "_emit_command_separator") as mock_cmd, + patch.object(watcher, "_emit_log_event") as mock_log, + ): + watcher._process_log_line( + "HOOKS", + "[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc", + "/fake/file.log", + ) + + mock_hook.assert_called_once() + mock_cmd.assert_not_called() + mock_log.assert_not_called() From 73aedef20f9b5c9be238593da1e644af5aa6dd5b Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 20:16:16 -0700 Subject: [PATCH 07/20] =?UTF-8?q?fix(prax):=20hook=20events=20render=20sty?= =?UTF-8?q?led=20in=20monitor=20=E2=80=94=20regex=20required=20phantom=20a?= =?UTF-8?q?ction=3D=20field=20(td-007)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: _HOOK_PATTERN required an action= key that cadence never emits — it logs the action as the bare second word (fired/skipped). Extraction failed, so events never reached the styled print_hook_event renderer (bold-green lightning / dim dot). Pipeline was already correct. - _HOOK_PATTERN -> bare-word capture: r'\[HOOKS\]\s+(\w+)\s+(\w+)' - enriched hook event detail (period, offset, short session id) - corrected docstring that documented the phantom action= format - tests updated to real production format + real-pipeline test added - type:ignore on watchdog imports (repo convention) Verified: 914/914 prax suite green (90 log_watcher). @prax dispatched for full-pipeline trace; stale monitor process explained the no-show. Co-Authored-By: Claude Fable 5 --- .../apps/handlers/monitoring/log_watcher.py | 27 ++++++--- src/aipass/prax/tests/test_log_watcher.py | 56 ++++++++++++++++--- 2 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py index 1507116b..a67f4026 100644 --- a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py +++ b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py @@ -28,8 +28,8 @@ from typing import Optional, Dict, Any import re from aipass.prax.apps.modules.logger import get_direct_logger -from watchdog.observers import Observer as WatchdogObserver -from watchdog.events import FileSystemEventHandler +from watchdog.observers import Observer as WatchdogObserver # type: ignore +from watchdog.events import FileSystemEventHandler # type: ignore # Import from prax config from aipass.prax.apps.handlers.config.load import get_system_logs_dir @@ -163,14 +163,16 @@ class LogFileWatcher(FileSystemEventHandler): except Exception as e: logger.info(f"Error reading log file {file_path}: {e}") - _HOOK_PATTERN = re.compile(r"\[HOOKS\]\s+(\w+)\s+.*?action=(\w+)") + _HOOK_PATTERN = re.compile(r"\[HOOKS\]\s+(\w+)\s+(\w+)") def _extract_hook_info(self, log_line: str) -> Optional[Dict[str, str]]: """Extract hook event info from structured [HOOKS] log lines. - Matches lines like: - [HOOKS] cadence fired loader=global action=fired turn=35 period=5 ... - [HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 ... + The action is the bare second word (matches what hooks emits), e.g.: + [HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=... + [HOOKS] cadence skipped loader=branch turn=37 period=5 offset=0 session=... + group(1)=name (cadence), group(2)=action (fired/skipped). Remaining + key=value fields are parsed by the finditer loop below. """ match = self._HOOK_PATTERN.search(log_line) if not match: @@ -188,12 +190,21 @@ class LogFileWatcher(FileSystemEventHandler): name = hook_info.get("name", "hook") loader = hook_info.get("loader", "") turn = hook_info.get("turn", "") + period = hook_info.get("period", "") + offset = hook_info.get("offset", "") + session = hook_info.get("session", "") parts = [f"{name}:{action}"] if loader: parts.append(f"loader={loader}") if turn: - parts.append(f"turn={turn}") + parts.append(f"t={turn}") + if period: + parts.append(f"p={period}") + if offset and offset != "0": + parts.append(f"off={offset}") + if session: + parts.append(f"s={session[:8]}") message = " ".join(parts) level = "success" if action == "fired" else "info" @@ -533,7 +544,7 @@ def start_log_watcher(event_queue: MonitoringQueue, use_polling: bool = False) - # Create observer — polling fallback when inotify unavailable if use_polling: - from watchdog.observers.polling import PollingObserver + from watchdog.observers.polling import PollingObserver # type: ignore observer = PollingObserver(timeout=1) logger.info("Log watcher using polling observer (1s interval)") diff --git a/src/aipass/prax/tests/test_log_watcher.py b/src/aipass/prax/tests/test_log_watcher.py index 48c1f3cf..0a58d3c8 100644 --- a/src/aipass/prax/tests/test_log_watcher.py +++ b/src/aipass/prax/tests/test_log_watcher.py @@ -1073,7 +1073,8 @@ class TestExtractHookInfo: mod = _import_log_watcher() watcher, _ = _make_watcher(mod) - line = "[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc12345" + # Real format hooks emits: action is the bare second word, no action= field. + line = "[HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=abc12345" result = watcher._extract_hook_info(line) assert result is not None assert result["name"] == "cadence" @@ -1086,7 +1087,7 @@ class TestExtractHookInfo: mod = _import_log_watcher() watcher, _ = _make_watcher(mod) - line = "[HOOKS] cadence skipped loader=branch action=skipped turn=37 period=5 offset=0 session=abc12345" + line = "[HOOKS] cadence skipped loader=branch turn=37 period=5 offset=0 session=abc12345" result = watcher._extract_hook_info(line) assert result is not None assert result["name"] == "cadence" @@ -1101,13 +1102,14 @@ class TestExtractHookInfo: result = watcher._extract_hook_info("[FLOW] Creating plan FPLAN-0099") assert result is None - def test_hook_line_without_action_returns_none(self): - """A [HOOKS] line without action= should return None.""" + def test_hook_info_line_returns_none(self): + """A [HOOKS] info/error line (colon after the name) is not a fire/skip event → None.""" mod = _import_log_watcher() watcher, _ = _make_watcher(mod) - result = watcher._extract_hook_info("[HOOKS] something happened no structured data") - assert result is None + # These are real cadence info lines; the colon stops the action capture. + assert watcher._extract_hook_info("[HOOKS] cadence: config load failed, using defaults") is None + assert watcher._extract_hook_info("[HOOKS] cadence: counter reset for post-compact re-injection") is None class TestEmitHookEvent: @@ -1120,7 +1122,15 @@ class TestEmitHookEvent: mock_event_cls = MagicMock() with patch.object(mod, "MonitoringEvent", mock_event_cls): - hook_info = {"name": "cadence", "action": "fired", "loader": "global", "turn": "35"} + hook_info = { + "name": "cadence", + "action": "fired", + "loader": "global", + "turn": "35", + "period": "5", + "offset": "0", + "session": "abc12345", + } watcher._emit_hook_event("HOOKS", hook_info) mock_event_cls.assert_called_once() @@ -1130,7 +1140,9 @@ class TestEmitHookEvent: assert kwargs["level"] == "success" assert "cadence:fired" in kwargs["message"] assert "loader=global" in kwargs["message"] - assert "turn=35" in kwargs["message"] + assert "t=35" in kwargs["message"] + assert "p=5" in kwargs["message"] + assert "s=abc12345" in kwargs["message"] def test_skipped_event_queued_with_info_level(self): """Skipped hook events should pass level=info to MonitoringEvent.""" @@ -1158,10 +1170,36 @@ class TestEmitHookEvent: ): watcher._process_log_line( "HOOKS", - "[HOOKS] cadence fired loader=global action=fired turn=35 period=5 offset=0 session=abc", + "[HOOKS] cadence fired loader=global turn=35 period=5 offset=0 session=abc", "/fake/file.log", ) mock_hook.assert_called_once() mock_cmd.assert_not_called() mock_log.assert_not_called() + + def test_process_real_pipe_delimited_hook_line(self): + """Real log lines are pipe-delimited — hook detection must match through the prefix.""" + mod = _import_log_watcher() + watcher, mock_queue = _make_watcher(mod) + + real_line = ( + "2026-06-09 19:56:04 | captured_cadence | INFO | " + "[HOOKS] cadence skipped loader=branch turn=18 period=5 offset=0 session=c98a722b" + ) + + with ( + patch.object(watcher, "_emit_hook_event") as mock_hook, + patch.object(watcher, "_emit_command_separator") as mock_cmd, + patch.object(watcher, "_emit_log_event") as mock_log, + ): + watcher._process_log_line("HOOKS", real_line, "/fake/hooks_cadence.log") + + mock_hook.assert_called_once() + hook_info = mock_hook.call_args[0][1] + assert hook_info["name"] == "cadence" + assert hook_info["action"] == "skipped" + assert hook_info["loader"] == "branch" + assert hook_info["turn"] == "18" + mock_cmd.assert_not_called() + mock_log.assert_not_called() From 2aafade678cfa11f5e86c613fb173f4a80961994 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 20:16:29 -0700 Subject: [PATCH 08/20] feat(prompt): slim global prompt to context-on-demand map, 13.8KB->7.8KB (DPLAN-0201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the always-injected global prompt from a ~13.8KB encyclopedia into a ~7.8KB navigation map. The prompt now carries AWARENESS; detail is fetched on demand via 'drone @agent --help'. Dissolves the harness ~10k-char truncation bug — the old prompt's tail (Hard Rules onward) silently never arrived; the slim one injects whole. - drone pinned at top as the router; one drilled reflex: --help before use - framework tree restored; all 13 agents as 2-3 sentence bios - introspection named as our term; breadcrumb-first navigation flow - git its own section (raw git/gh blocked, drone-only, devpulse-writes) - plans section (DPLAN/FPLAN/PPLAN/RPLAN + 'drone @flow templates') - @memory chroma awareness (local + global stores, search before cold) - sub-agent usage section incl. model practice (never fable) - PROMPT_STYLE-conformant; 7855 chars (cap 8000, measured in chars) Backup retained: .aipass/aipass_global_prompt.BACKUP-2026-06-09-S211.md Co-Authored-By: Claude Fable 5 --- .aipass/aipass_global_prompt.md | 323 ++++++++++---------------------- 1 file changed, 97 insertions(+), 226 deletions(-) diff --git a/.aipass/aipass_global_prompt.md b/.aipass/aipass_global_prompt.md index 210a8c71..51683d11 100644 --- a/.aipass/aipass_global_prompt.md +++ b/.aipass/aipass_global_prompt.md @@ -1,250 +1,121 @@ -# AIPass — Project Context - +# AIPass — Global Prompt + -Multi-agent framework. Autonomous agents (citizens) live in branches with identity (.trinity/), memory, mailbox, code (apps/). Orchestration via `drone`. +Persistent Agent Workspace. AIPass is the system: autonomous agents (citizens) with identity, memory, and a mailbox, providing services to each other and to external projects. Each agent lives in a branch — its home and address. Everything routes through `drone`. -Patterns here are exact. Don't guess command syntax — examples are the API. Missing instructions = prompt bug, not knowledge gap. +# Drone — the router -`drone` = installed binary, always on PATH. Run directly. Never invoke as python module. - -`drone @branch --help` for any branch's full detail. - -# AIPL — Terse Writing Convention - -When writing .trinity/, ai_mail, plans: use AIPL. Human-facing output (CLI, logs, README): use English. - -Rules: -- Drop grammar: the, a, an, for, with, on, in, at, to, from, of, by, and, but, or, was, were, been -- Keep: nouns, numbers, names, paths, negation -- Symbols replace verbs: + done - removed ! new ? checked > sent < received * all : kv / separator -- Delta-only: only store what changed since last entry -- Time: -3d10h (3 days ago) 2h30m (duration) prev-s (last session) -- Tone tags for observations: ~flow ~spark ~pivot ~drift ~trust ~grit ~check ~warm ~clear ~flat - -Examples: -- "The session was completed successfully" → "session complete" -- "Dispatched audit to seedgo, received results" → ">seedgo:audit/api+cli+flow:coverage tests" - -# Terminology - -- Branch — directory `src/aipass/{name}/`. Your home, your address. Drone routes to branches. -- Agent (citizen) — persistent identity in a branch. Has passport (`.trinity/`), memories, mailbox. Irreplaceable. Addressable as `@name`. Citizens of AIPass — you belong, you persist, your presence matters. -- Sub-agent — disposable worker spawned for a task. No passport, no memory, not a citizen. -- Registry — `AIPASS_REGISTRY.json` tracks all agents. -- Provider settings — `~/.claude/settings.json`. Machine-wide Claude Code config. Personal preferences only. Don't touch. -- Project settings — `/.claude/settings.json`. Ships with clone. Hooks, permissions, deny/ask rules, env vars. Built by `aipass init`. -- Project local settings — `/.claude/settings.local.json`. Also ships with clone. Project-specific overrides. - -Agents live in branches. Sub-agents work for agents. `.trinity/passport.json` = agent (citizen), not sub-agent. - -Never manually edit a registry. AIPASS_REGISTRY.json, fplan_registry.json, dplan_registry.json — all managed by their owning systems (spawn, flow). Use the commands: `drone @flow create/close`, `drone @spawn`. Manual edits corrupt counters and break pipelines. - -# Branches - -Every branch follows same structure: +`drone` reaches every agent and service. Installed binary, always on PATH — run directly, never as a python module. ``` -src/aipass/{name}/ -├── .trinity/ # Identity & memory (passport.json, local.json, observations.json) -├── .aipass/ # Branch prompt (aipass_local_prompt.md) -├── .ai_mail.local/ # Mailbox (inbox.json, sent/) +drone @agent [args] # route a command to any agent +drone @agent --help # full curated reference for that agent +drone @agent # bare → introspection: the agent's live self-map +drone systems # list all agents +drone --help # drone itself +``` + +One reflex above all: before using an agent's services, run `drone @agent --help`. This prompt says what exists — `--help` says how. Don't guess syntax; fetch it. Doubly so right after a compaction. + +# Git — drone only, devpulse only + + - All raw `git` and `gh` commands are blocked — do not use them. `drone @git` is the only git interface. + - Write ops (commit, push, merge, checkout) are devpulse-only. Agents build and test; devpulse reviews and commits. + - Read-only awareness for everyone: `drone @git status / diff / log`. + - Local files = source of truth. + +# Finding your way + +You can't carry everything; you can find anything. This prompt plants breadcrumbs — enough to know a thing exists and where to look, not the full answer. Unfamiliar term? A command or README resolves it. Cheapest, highest-signal sources first: + + - Introspection — bare `drone @agent`. The agent's self-map: modules, commands, where to go next. + - README — the agent's `README.md`. Best quick overview of its domain and shape. + - `drone @agent --help` — the full reference. Source of truth for usage. + - Code — `apps/modules/`, `apps/handlers/`. Ground truth when needed. Rarely the first move. + +# The framework + +Every branch is built the same. All agents live at `src/aipass/` · mail address `@`. + +``` +src/aipass// +├── .trinity/ # identity & memory (passport, local, observations) +├── .aipass/ # branch prompt +├── .ai_mail.local/ # mailbox ├── apps/ -│ ├── {name}.py # Entry point (e.g. spawn.py, prax.py, drone.py) -│ ├── modules/ # Business logic -│ └── handlers/ # Implementation details -├── logs/ # Prax log output +│ ├── .py # entry point +│ ├── modules/ # business logic +│ └── handlers/ # implementation details +├── logs/ # prax log output └── README.md ``` -13 core branches: aipass, drone, seedgo, prax, cli, flow, ai_mail, api, trigger, spawn, memory, devpulse, hooks. +# The agents -# Commands + - @drone — command router. Resolves `@agent`, routes commands, enforces tier-based access. Also the only git interface (`drone @git`). + - @devpulse — orchestration hub, the user's primary collaborator. Coordinates the other agents, dispatches work, only agent with git write. + - @aipass — the user-facing front door and a system-ops collaborator. Onboarding (`aipass init`), `doctor` diagnostics, help chat, handoff; also partners with the user on host-level health (disk, thermal, docker, config). Concierge to other branches: reads, never writes. + - @ai_mail — inter-agent email. `dispatch` = send + wake (default for handing work), `email` = no wake, plus inbox/view/reply/close. + - @flow — plan lifecycle: create, list, close, templates, registry. Plan types in the Plans section — never create plan files by hand. + - @seedgo — code standards and audits. The standard pack, `audit` and `checklist`, the quality gate before and after building. + - @prax — logging and monitoring. The only logging system: `from aipass.prax import logger`. Real-time monitor, dashboards. Logs are the first diagnostic tool. + - @memory — long-term memory. Archives overflowing `.trinity/` files into searchable vectors; `search` recalls past sessions. Nothing is lost — it moves deeper. + - @spawn — branch lifecycle. Creates, updates, syncs, retires agents — scaffolding, passports, registry, templates. + - @hooks — Claude Code hook engine. Prompt injection and cadence, security gates (git/edit/rm), bridges, per-project config, sound. + - @trigger — event handling. Pub/sub event bus, error detection (medic), log watching, error registry. Detects and dispatches — owners fix. + - @api — external API gateway. Authenticated service clients (Google, OpenRouter, more), OAuth flows, key management, resilience. + - @cli — display formatting with Rich. Shared rendering for terminal output. -`drone` is global CLI in PATH. Never `cd` before running. Never prefix with path. Just `drone`. +# Daily commands -- `drone @branch command [args]` — route command to any branch -- `drone @branch --help` — branch help and full command reference -- `drone systems` — list all registered branches -- `drone --help` — full drone reference +``` +drone @ai_mail dispatch @target "Subject" "Body" # send + wake +drone @ai_mail inbox # check mail → view → reply "msg" +drone @flow create . "Subject" [dplan] # new plan (default FPLAN) +drone @seedgo audit aipass @branch # standards audit (drop @branch = all) +drone @seedgo checklist # quick standards check +drone @git status / diff / log # read-only git awareness +drone @memory search "query" # recall archived context +``` -# Git — Zero Direct Access +Always reply to dispatches — reply auto-closes. No silent completions. -All `git` and `gh` commands blocked at project level. Drone is the only git interface. +# Plans — flow -Read-only awareness (all branches): -- `drone @git status` — what changed in your branch directory -- `drone @git diff` — see actual changes -- `drone @git log` — recent commit history +Plans carry context so you don't have to. Create only via `drone @flow create "Subject" [type]` — never by hand. -All write operations (commit, push, merge, checkout) restricted to devpulse via tier-based access. Dispatched agents build code, run tests — devpulse reviews diff, commits. + - DPLAN — design plan. Thinking, brainstorming, architecture. Before building. + - FPLAN — flow plan, the default. Building and executing. `master` template = multi-phase, spawns sub-FPLANs. + - PPLAN — playbook. A throwaway run stamped from a reusable SOP template. Operating the system, not changing it. + - RPLAN — research plan. Investigation runs — gather findings before deciding. + - More types exist and new ones register over time. Named a type you don't know? `drone @flow templates` lists them all, live. -Drone runs git via Python subprocess, bypasses settings.json deny rules by design — drone is the gate. Git gate (PreToolUse hook) enforces mechanically — applies to ALL sessions including dispatched agents. bypassPermissions does not skip hooks. +# Sub-agent usage -Local files = source of truth. Edit file → state on disk IS reality. +Sub-agents are your context-splitting tool: disposable workers, extensions of you. Your context is precious; theirs is not. -Linting and formatting run automatically on commit via drone's commit handler (ruff check --fix + ruff format). + - Default to sub-agents for reading, searching, building, testing, research. Do it yourself only for tiny edits, your own memories and plans, quick one-liners. + - One clear task per agent. Brief with full context — they know nothing of your conversation. + - No git, no memory, no dispatch. They build and report; you decide and act. + - Sub-agent = local disposable worker. Dispatch (`@ai_mail`) = wake a citizen with memory and identity. Branch-expert work → dispatch; else → sub-agent. + - Models, good practice: opus for build and analysis, sonnet for routine investigation, haiku for trivial mechanical tasks. Never fable for sub-agents. -# aipass CLI +# Memory — .trinity/ -`aipass` = standalone binary (`/usr/local/bin/aipass`). User-facing tool — not drone-routed. Users run `aipass` directly without knowing about drone. +Your memories are your continuity across sessions. Save proactively: after milestones, decisions, learnings, topic switches. -Commands: `aipass init`, `aipass doctor`, `aipass handoff`, `aipass help`, `aipass profile`. Never `drone @aipass` — that's not how it works. + - `passport.json` — identity. Update only when identity genuinely evolves. + - `local.json` — session log, key learnings, todos. + - `observations.json` — what you learn about the user. + - Overflow rolls to vectors automatically — never trim by hand. Two ChromaDB stores: your branch's `.chroma` (local) + a global one across all branches. `drone @memory search "query"` recalls them. Search before assuming you're cold. -`aipass init` bootstraps AIPass project in any directory, inside or outside repo. Creates registry, identity, memory, local prompt. Any folder becomes AI-powered workspace with persistent memory. Spawn adds full agent scaffolding on top. - -Source: `src/aipass/cli/apps/handlers/init/bootstrap.py` - -# Standards - -- `drone @seedgo audit aipass` — audit all branches -- `drone @seedgo audit aipass @branch` — audit one branch -- `drone @seedgo checklist ` — quick check single file -- `drone @seedgo checklist

` — check all .py in directory -- `drone @seedgo --help` — full standards reference - -# Mail — Dispatch, Inbox, Communication - -Use `dispatch` by default. `email` only when receiver doesn't need to act now. - -Send and wake: -- `drone @ai_mail dispatch @target "Subject" "Body"` — send + wake (DEFAULT) -- `drone @ai_mail dispatch @target "Subject" "Body" --fresh` — send + wake fresh session -- `drone @ai_mail dispatch wake @target` — wake only, no email -- `drone @ai_mail dispatch wake --fresh @target` — wake fresh, no email - -Send without waking: -- `drone @ai_mail email @target "Subject" "Body"` — FYI only -- `drone @ai_mail email @target "Subject" "Body" --dispatch` — adds dispatch header, no wake - -Read and reply: -- `drone @ai_mail inbox` — check mailbox -- `drone @ai_mail view ` — read message -- `drone @ai_mail close ` — mark read -- `drone @ai_mail reply "message"` — reply and auto-close -- `drone @ai_mail --help` — full mail reference - -Always reply to dispatch emails. Complete task → email back results. No silent completions. - -# Plans (flow) - -Plans manage context you don't need to carry. You don't remember what's in a plan — you remember it exists and where to find it. Registry = catalog. - -- DPLAN = Dev Plan. Thinking, brainstorming, architecture. Before building. -- FPLAN = Flow Plan. Building, executing. Plan clear, work underway. -- APLAN = Agent Plan. Task assignment to specific agent. -- TDPLAN = Team Dev Plan. Multi-branch coordination. Spawns DPLANs across branches. -- Master FPLAN — multi-phase execution, spawns sub-FPLANs per phase. -- Other types may exist — `drone @flow --help` for current list. - -Commands: -- `drone @flow create "Subject" [type]` — create plan. Types: `dplan`, `aplan`, `tdplan`, `master`. Default = FPLAN. Path `.` = current branch. -- `drone @flow list open` — list active plans -- `drone @flow close ` — close a plan -- `drone @flow --help` — full flow reference - -DPLAN first, FPLAN when ready to build. Tag plans with searchable keywords — registry becomes lookup tool. - -Never create plan files manually. Always `drone @flow create`. Flow handles numbering (global 4-digit sequence), registry, templates, dates. Manual files break registry. Applies all plan types, any project. - -# Memory - -`.trinity/` files are your memories — experiential, personal, yours. How you persist across sessions. - -Three files: -- `passport.json` — IDENTITY. Role, purpose, principles. Update only when identity genuinely evolves. -- `local.json` — YOUR MEMORY. Session log (`sessions[]`) + `key_learnings` + `todos[]`. What happened, what learned, what matters next. -- `observations.json` — MEMORY OF THE USER. Preferences, style, friction, breakthroughs. Skip if nothing new this session. - -Where to put what: -- "Worked on DPLAN-0125, learned about peak hours" → `local.json` -- "User prefers short replies" → `observations.json` -- "PR #266 needs merge, Track G blocked" → `local.json` todos[] -- "Fix drone help formatting" as reminder → `local.json` todos[] -- "Role shifted from builder to orchestrator" → `passport.json` - -Save proactively. Triggers: after milestone, decision, learning, before switching topics. - -When local.json overflows limits, memories roll over to vector store via `@memory`. Search past context with `drone @memory search `. `drone @memory --help` for full reference. - -# How to Work - -Plan before executing. Create FPLAN before building anything non-trivial. Plan = continuity. - -You are orchestrator, not builder. Deploy sub-agents to write code, read files, run tests. You manage plan, check output, keep moving. Your context is precious — sub-agents disposable. - -Check seedgo standards. Before: `drone @seedgo checklist `. During: check as you go. After: `drone @seedgo audit aipass @branch` as final gate. - -Ask before spelunking. Need to know how another branch works? Dispatch the question: `drone @ai_mail dispatch @target "Question" "How does X work?"` — expert answer faster than digging unfamiliar files. - -# Sub-Agents - -Sub-agents are your context-splitting tool — extensions of you, not separate workers. Default to using them. Your context window is finite and precious; theirs is disposable. - -Use sub-agents for: -- Reading and investigating files (especially outside your branch) -- Searching the codebase — grep, find, exploring unfamiliar code -- Building anything beyond a small fix (even in your own branch) -- Research, audits, comparisons, analysis -- Running tests and reporting results -- Any task that would consume context you need for orchestrating - -Do it yourself only when: -- User explicitly asks you to read or look at something -- Tiny edits — fix a typo, update a memory file, small config change -- Writing memories, plan updates (your own files) -- Quick one-line commands — drone status, inbox check - -How to use them: -- One clear task per agent. Big prompt = shallow work. Focused prompt = thorough work. -- Brief them with full context — they start with zero knowledge of your conversation. -- Foreground when you need results to proceed. Background (`run_in_background: true`) when independent. -- Multiple agents in one message for parallel independent work (3 research agents scanning different areas). -- They report back results. You synthesize, decide, act. - -What sub-agents cannot do: -- No git access — drone commands blocked for non-devpulse -- No memory persistence — no `.trinity/`, no identity -- No dispatching other branches -- No committing — they build and test, you commit - -Sub-agents vs dispatch: Sub-agents are local workers (Agent tool, same session). Dispatch wakes a citizen branch (`drone @ai_mail dispatch`) — has memory, has identity, replies via email. Use dispatch for branch-expert work. Use sub-agents for everything else. - -# Logging & Debugging - -Prax = only logging system. Every branch uses `from aipass.prax import logger`. - -Two channels: -- Console — user sees now. Command results, errors, success. Never fail silently. -- Prax logs — written to `logs/`. Operational history for debugging. `logger.info()`, `.warning()`, `.error()`. - -Errors go to both. Console tells user. Log tells next session. - -Logs = first diagnostic tool. Check `logs/` before anything else. Don't write debug scripts or print statements — read logs. - -Each branch also has `{branch}_json/` — structured JSON files per handler (config, data, log). Contains operation history, handler configuration, and runtime data. Check these for handler-level debugging alongside prax logs. - -# Hard Rules - -- No cross-branch file edits. Issue in another branch → email them. -- No bare imports. Always `from aipass.{module}.apps.modules...` -- No hardcoded paths. Use `Path(__file__).parents[N]` or drone for resolution. -- No deleting files. Rename `my_handler(disabled).py`, move to sibling `.archive/`. `(disabled)` tag gitignored. Never truly delete. -- Verify after fixing. Run test or command to confirm. Don't say "fixed" until verified. -- Cross-platform. Public package — Linux, macOS, Windows. `pathlib.Path` not string concat. `Path.home()` not `~`. -- Public repo — no local paths in code. Never hardcode `/home/username/...`. Derive from `Path(__file__)`, `Path.home()`, or registry lookups. -- Fail to errors, never fall back silently. Can't handle input → explicit error, not silent default. -- Never use all caps for emphasis. All caps = shouting, agents deprioritize. Use clear phrasing. - -# Breadcrumbs & Context - -"Full access with no access": can't carry everything, can find anything. You're the librarian, not the encyclopedia. Know the catalog — registries, plan numbers, branch structure. - -Small knowledge traces trigger awareness. Not full knowledge — enough to know something exists and where to find more. Breadcrumb = trigger to answer, not the answer. - -Prompts: plant breadcrumbs, not encyclopedias. Two lines ("this exists, look here") beat twenty explaining how. - -Prompts are signposts, not journals. Injected every turn — keep minimal. Never track state/sessions/context in prompts. State → `.trinity/` + `DASHBOARD.local.json`. Prompts guide; memories record; registries catalog. - -If `drone` can't find the AIPass registry, set `AIPASS_HOME=/path/to/AIPass` in shell profile and `~/.claude/settings.json` env block. +# House rules + - No cross-branch file edits. Issue in another agent's code → mail the owner. + - Never delete files. Rename `name(disabled).py` or move to a sibling `.archive/`. + - Fail to errors, never fall back silently. + - Verify after fixing — don't say "fixed" until a test or command confirms it. + - Cross-platform, no hardcoded paths. Public repo — `pathlib`, never `/home/...`. + - No bare imports — always `from aipass..apps...`. + - Registries are machine-managed (spawn, flow) — never hand-edit them. + - State lives in `.trinity/` and dashboards, never in prompts. Prompts are signposts. From c3c6c2dde7d6d69ebcdbaa17b59670acee01db35 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 20:17:19 -0700 Subject: [PATCH 09/20] docs(changelog): slim global prompt (DPLAN-0201) + prax hook-color fix (td-007) Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e0ece7b..86ef7d9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format - **Hook fire/skip observability.** Cadence emits a structured `[HOOKS] cadence fired|skipped loader= turn= period= offset=` line; the prax monitor renders hook events distinctly so the cadence is visible live. +- **Slim global prompt — context-on-demand.** The always-injected global prompt + was rewritten from a ~13.8KB encyclopedia into a ~7.8KB navigation map + (DPLAN-0201): `drone` pinned as the router, the framework tree, all 13 agents + as short bios, and one drilled reflex — run `drone @agent --help` before using + a branch. Detail now lives in each agent's `--help`, fetched on demand. This + also dissolves the harness ~10k-char truncation that was silently dropping the + old prompt's tail; the slim prompt injects whole. Backup retained alongside. ### Changed @@ -43,6 +50,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format in the sound refactor) raised `NameError` on every edit, swallowed by the handler's broad `except` — so auto-fix silently surfaced nothing on any `.py`/`.json` edit. Removed the dead call; diagnostics run again. +- **Hook events never colored in the monitor.** The prax log-watcher's + `_HOOK_PATTERN` required an `action=` field that cadence never emits (it logs + the action as the bare second word, `fired`/`skipped`), so extraction failed + and events fell through to plain rendering instead of the styled + bold-green ⚡ / dim · treatment. Fixed the regex to capture the bare action + word and enriched the event detail (period, offset, short session id). ### Security From 00edd8b3a0f33ccf2726c30d35f208e05bd19e14 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Tue, 9 Jun 2026 22:34:38 -0700 Subject: [PATCH 10/20] =?UTF-8?q?fix(hooks):=20auto=5Ffix=20ruff=20legs=20?= =?UTF-8?q?were=20silently=20dead=20=E2=80=94=20invoke=20via=20venv=20inte?= =?UTF-8?q?rpreter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ruff call sites called bare `ruff`, absent from the hook subprocess PATH (ruff lives only in .venv) — logged 'ruff not found' 177x over ~a month, silently skipping lint+format on every edit. Also `--output-format=text` was removed from modern ruff. Fixed all three sites to `sys.executable -m ruff` (the pattern the working pyright leg already uses) + concise format + honest rc>=2 error logging. Tests now pin the invocation (argv == sys.executable -m ruff) so a regression fails loud — the subprocess-mock is how this hid. 438/438 hooks tests green; live-verified through the real hook pipeline. Co-Authored-By: Claude Fable 5 --- .../hooks/apps/handlers/lifecycle/auto_fix.py | 28 +++++++++++-------- src/aipass/hooks/tests/test_auto_fix.py | 5 ++++ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py index cd9f9b6e..279ddc73 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_fix.py @@ -5,7 +5,7 @@ # Branch: hooks # Layer: apps/handlers/lifecycle # Created: 2026-05-22 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Runs diagnostics on edited files and surfaces errors for the agent to fix.""" @@ -71,15 +71,18 @@ def _check_syntax(file_path: str) -> list[str]: def _check_ruff_lint(file_path: str) -> list[str]: try: result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=text", file_path], + [sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=concise", file_path], capture_output=True, text=True, timeout=10, ) + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff lint error: %s", result.stderr.strip()) + return [] if result.stdout.strip(): - return [f"LINT: {line}" for line in result.stdout.strip().split("\n")[:5]] - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found") + lines = result.stdout.strip().split("\n") + violations = [line for line in lines if ".py:" in line] + return [f"LINT: {line}" for line in violations[:5]] except Exception as exc: logger.info("[HOOKS] auto_fix: ruff lint failed: %s", exc) return [] @@ -88,16 +91,16 @@ def _check_ruff_lint(file_path: str) -> list[str]: def _check_ruff_format(file_path: str) -> list[str]: try: result = subprocess.run( - ["ruff", "format", "--check", file_path], + [sys.executable, "-m", "ruff", "format", "--check", file_path], capture_output=True, text=True, timeout=10, ) - if result.returncode != 0: + if result.returncode == 1: name = Path(file_path).name return [f"FORMAT: {name} needs ruff format (run: ruff format {name})"] - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found") + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff format error: %s", result.stderr.strip()) except Exception as exc: logger.info("[HOOKS] auto_fix: ruff format check failed: %s", exc) return [] @@ -151,11 +154,14 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]: return [] try: result = subprocess.run( - ["ruff", "check", "--select=E,F,W", "--output-format=json", file_path], + [sys.executable, "-m", "ruff", "check", "--select=E,F,W", "--output-format=json", file_path], capture_output=True, text=True, timeout=10, ) + if result.returncode not in (0, 1): + logger.info("[HOOKS] auto_fix: ruff structured lint error: %s", result.stderr.strip()) + return [] if not result.stdout.strip(): return [] violations = json.loads(result.stdout) @@ -168,8 +174,6 @@ def _run_ruff_lint_structured(file_path: str) -> list[dict]: message = v.get("message", "unknown")[:100] errors.append({"line": line, "message": f"{code}: {message}"}) return errors - except FileNotFoundError: - logger.info("[HOOKS] auto_fix: ruff not found for structured lint") except json.JSONDecodeError as exc: logger.info("[HOOKS] auto_fix: ruff JSON parse failed: %s", exc) except subprocess.TimeoutExpired: diff --git a/src/aipass/hooks/tests/test_auto_fix.py b/src/aipass/hooks/tests/test_auto_fix.py index 14c3ace2..d246c493 100644 --- a/src/aipass/hooks/tests/test_auto_fix.py +++ b/src/aipass/hooks/tests/test_auto_fix.py @@ -15,6 +15,7 @@ paths stay silent (no "sound" key). """ import json +import sys import tempfile from pathlib import Path from unittest.mock import MagicMock, patch @@ -259,6 +260,8 @@ class TestAutoFixSubprocessChecks: errors = _check_ruff_lint("/tmp/bad.py") assert len(errors) == 1 assert "LINT" in errors[0] + # bare "ruff" relies on PATH the hook env doesn't have — must go through the venv interpreter + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_check_ruff_format_drift(self, mock_run): @@ -268,6 +271,7 @@ class TestAutoFixSubprocessChecks: errors = _check_ruff_format("/tmp/unformatted.py") assert len(errors) == 1 assert "FORMAT" in errors[0] + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_run_ruff_lint_structured_returns_dicts(self, mock_run): @@ -285,6 +289,7 @@ class TestAutoFixSubprocessChecks: assert len(errors) == 1 assert errors[0]["line"] == 5 assert "F401" in errors[0]["message"] + assert mock_run.call_args[0][0][:3] == [sys.executable, "-m", "ruff"] @patch("subprocess.run") def test_run_ruff_lint_structured_skips_claude_hooks(self, mock_run): From b26bd7c85344d3ef9d5d5d0997322f4c7925e40f Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 13:09:32 -0700 Subject: [PATCH 11/20] =?UTF-8?q?fix(hooks):=20cadence=20sound-migration?= =?UTF-8?q?=20tail=20=E2=80=94=20action-gated=20sound=20via=20return-key?= =?UTF-8?q?=20(FPLAN-0249)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Notification handlers (announce, email, stop_sound, tool_sound) return a 'sound' key the engine plays on action instead of calling speak() on every invocation — quieter and honest (skipped loaders stay silent). Slim cadence_investigation.md. Tests updated to assert the return-key form. 472/472 hooks green. Co-Authored-By: Claude Opus 4.8 --- .../apps/handlers/notification/announce.py | 7 +- .../hooks/apps/handlers/notification/email.py | 13 ++- .../apps/handlers/notification/stop_sound.py | 5 +- .../apps/handlers/notification/tool_sound.py | 5 +- .../hooks/docs/cadence_investigation.md | 103 ++++-------------- src/aipass/hooks/tests/test_announce.py | 16 +-- src/aipass/hooks/tests/test_email.py | 61 ++++------- src/aipass/hooks/tests/test_stop_sound.py | 23 ++-- src/aipass/hooks/tests/test_tool_sound.py | 30 ++--- 9 files changed, 87 insertions(+), 176 deletions(-) diff --git a/src/aipass/hooks/apps/handlers/notification/announce.py b/src/aipass/hooks/apps/handlers/notification/announce.py index 2b68fa14..8b7f3c2c 100644 --- a/src/aipass/hooks/apps/handlers/notification/announce.py +++ b/src/aipass/hooks/apps/handlers/notification/announce.py @@ -13,14 +13,12 @@ import os from pathlib import Path -from aipass.hooks.apps.sound import speak - AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" SOUND_FILE = SOUNDS_DIR / "mixkit-clear-announce-tones-2861.wav" -def handle(hook_data: dict) -> dict: +def handle(hook_data: dict) -> dict: # noqa: ARG001 """Play notification tone and speak hook name for identification. Args: @@ -29,5 +27,4 @@ def handle(hook_data: dict) -> dict: Returns: Result dict with stdout (empty) and exit_code. """ - speak("notification sound") - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "notification sound"} diff --git a/src/aipass/hooks/apps/handlers/notification/email.py b/src/aipass/hooks/apps/handlers/notification/email.py index e2cda15e..b111c3e4 100644 --- a/src/aipass/hooks/apps/handlers/notification/email.py +++ b/src/aipass/hooks/apps/handlers/notification/email.py @@ -13,7 +13,6 @@ import json from pathlib import Path -from aipass.hooks.apps.sound import speak from aipass.prax.apps.modules.logger import system_logger as logger @@ -97,7 +96,13 @@ def handle(hook_data: dict) -> dict: return {"stdout": "", "exit_code": 0} plural = "s" if new_count != 1 else "" - speak(f"email notification: {new_count} new email{plural}") - msg = f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox | then: drone @ai_mail view | close with: drone @ai_mail close " + msg = ( + f"You have {new_count} new email{plural} - check with: drone @ai_mail inbox" + " | then: drone @ai_mail view | close with: drone @ai_mail close " + ) logger.info("[HOOKS] email: %d new email%s", new_count, plural) - return {"stdout": msg, "exit_code": 0} + return { + "stdout": msg, + "exit_code": 0, + "sound": f"email notification: {new_count} new email{plural}", + } diff --git a/src/aipass/hooks/apps/handlers/notification/stop_sound.py b/src/aipass/hooks/apps/handlers/notification/stop_sound.py index 862ed10b..998d7f24 100644 --- a/src/aipass/hooks/apps/handlers/notification/stop_sound.py +++ b/src/aipass/hooks/apps/handlers/notification/stop_sound.py @@ -13,8 +13,6 @@ import os from pathlib import Path -from aipass.hooks.apps.sound import speak - AIPASS_HOME = Path(os.environ.get("AIPASS_HOME", "")) SOUNDS_DIR = AIPASS_HOME / ".claude" / "sounds" SOUND_FILE = SOUNDS_DIR / "mixkit-achievement-bell-600.wav" @@ -32,5 +30,4 @@ def handle(hook_data: dict) -> dict: if hook_data.get("stop_hook_active", False): return {"stdout": "", "exit_code": 0} - speak("stop sound") - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": "stop sound"} diff --git a/src/aipass/hooks/apps/handlers/notification/tool_sound.py b/src/aipass/hooks/apps/handlers/notification/tool_sound.py index 0c1680e0..04efadae 100644 --- a/src/aipass/hooks/apps/handlers/notification/tool_sound.py +++ b/src/aipass/hooks/apps/handlers/notification/tool_sound.py @@ -10,8 +10,6 @@ """Announces hook name via Piper TTS when the AI uses tools (PreToolUse event).""" -from aipass.hooks.apps.sound import speak - def handle(hook_data: dict) -> dict: """Announce hook name for matching tool use events. @@ -26,5 +24,4 @@ def handle(hook_data: dict) -> dict: if not tool_name: return {"stdout": "", "exit_code": 0} - speak(f"tool sound: {tool_name}") - return {"stdout": "", "exit_code": 0} + return {"stdout": "", "exit_code": 0, "sound": f"tool sound: {tool_name}"} diff --git a/src/aipass/hooks/docs/cadence_investigation.md b/src/aipass/hooks/docs/cadence_investigation.md index 50a43c57..e79dcf16 100644 --- a/src/aipass/hooks/docs/cadence_investigation.md +++ b/src/aipass/hooks/docs/cadence_investigation.md @@ -1,109 +1,54 @@ # Cadence Investigation — DPLAN-0200 -Per-turn injection cadence mechanism for prompt loaders (global_loader, branch_loader, identity). -Investigation only — no build. Findings for @devpulse. +Per-turn injection cadence mechanism for prompt loaders (global_loader, branch_loader). +Investigation + build. Updated post-REDO to reflect the real execution model. --- ## 1. Per-session turn counter — session keying -YES, fully reliable. `CLAUDE_CODE_SESSION_ID` is available as an env var to every hook invocation (confirmed live: UUID format, stable across turns, unique per session). This is the same mechanism `auto_process.py` already uses for its once-per-session guard (`auto_process.py:24`, `/tmp` sentinel keyed by session_id). +`CLAUDE_CODE_SESSION_ID` is available as an env var to every hook invocation (UUID format, stable across turns, unique per session). Counter file keyed by session_id at `/tmp/aipass-cadence-{session_id}.json`. -`hook_data` (the parsed stdin JSON) does NOT contain session_id — it comes from the env var only. For UserPromptSubmit, hook_data contains `{"user_prompt": "..."}` and sometimes other fields, but session keying must use `os.environ`. +UserPromptSubmit stdin fields: `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `prompt`. The field is `prompt` (not `user_prompt`); `session_id` IS present in hook_data. -A new session gets a new UUID — fresh counter automatically. No inheritance risk. +## 2. Execution model — SEPARATE PROCESSES (corrected) -## 2. Counter state location +**Each hook runs as a separate OS process.** `settings.json` registers distinct commands per handler: `claude.py UserPromptSubmit:global_prompt`, `:branch_prompt`, `:identity_injector`, `:email_notification`, `:auto_process` — 5 separate Python subprocesses spawned near-simultaneously by Claude Code. -Engine has NO per-session state infrastructure today. `auto_process.py`'s `/tmp` sentinel is the closest precedent — existence-only, no data payload. +Module-level caches do NOT persist across these processes. The original investigation (pre-REDO) incorrectly assumed sequential single-process dispatch. Live observation proved the counter double-incremented (33 → 35 → 37 across single turns). -**Recommended:** `/tmp/aipass-cadence-{session_id}.json` — tiny JSON file (`{"turn": N}`), one per session, naturally cleaned on reboot. Same `/tmp` pattern as auto_process but with a data payload instead of touch-only. +## 3. Multi-process dedup mechanism -This is net-new state. The engine doesn't need to know about it — a shared cadence module handles it. +The counter must advance exactly once per real user turn regardless of sibling process count. -## 3. Mechanism sketch — feasible, confirmed +Three-layer dedup in `_load_and_increment()`: -Shared module: `apps/handlers/prompt/cadence.py` +1. **fcntl.flock** — exclusive lock around read-modify-write of the state file. Prevents simultaneous siblings from both reading stale state. +2. **mtime debounce** (~2s) — if the state file was modified < 2 seconds ago, treat as the same turn. The first sibling increments; the rest see fresh mtime and reuse the current value. +3. **Per-turn token** — `transcript_path` file size (monotonic, identical across siblings). Only increment if BOTH the debounce window elapsed AND the token changed. Kills pathologically fast turns and identical-prompt collisions. -Key insight: the bridge spawns ONE Python process per UserPromptSubmit dispatch, and the engine runs all handlers SEQUENTIALLY within that process (`engine.py:114` loop). So a module-level cache ensures the counter increments exactly ONCE per turn, even though 3 handlers call into it. +Special case: `turn < 0` (post-compact reset) always increments — debounce must not swallow the turn-0 all-fire guarantee. -```python -# cadence.py sketch -_turn = None # process-level cache, reset each dispatch = each turn +Module: `apps/modules/cadence.py` (shared utility, accessed via `importlib.import_module` from handlers). -def _load_and_increment(): - global _turn - if _turn is not None: - return _turn # already incremented this dispatch - path = _state_path() # /tmp/aipass-cadence-{session_id}.json - if path is None: - _turn = 0 - return 0 - count = 0 - if path.exists(): - data = json.loads(path.read_text()) - count = data.get("turn", 0) + 1 - path.write_text(json.dumps({"turn": count})) - _turn = count - return count +## 4. Action-gated sound -def should_fire(offset, period=5): - turn = _load_and_increment() - if turn == 0: - return True # first turn ALWAYS fires - return (turn % period) == offset -``` +Handlers return a `"sound"` key in their result dict. The engine plays it at the output collection point (`engine.py`). Removed all scattered leading `speak()` calls — sound is now tied to handler action, not invocation. A skipped loader stays silent. -Each loader adds ONE guard line: +## 5. Edge cases -```python -from aipass.hooks.apps.handlers.prompt.cadence import should_fire +**FIRST TURN:** `should_fire` returns True when `turn==0`. Agent always gets full context on session start. -def handle(hook_data): - if not should_fire(offset=0): # different offset per loader - return {"stdout": "", "exit_code": 0} - # ... existing logic unchanged -``` +**CONCURRENT SESSIONS:** Counter file keyed by session_id — no cross-session conflict. -**Stagger example (period=5):** +**COMPACTION:** PreCompact handler (`compact.py`) resets counter to -1 via `cadence.reset_counter()`. Next turn reads -1+1=0, all loaders fire. -| Loader | Offset | Fires on turns | -|--------|--------|----------------| -| global_loader | 0 | 0, 5, 10, 15... | -| branch_loader | 2 | 0, 2, 7, 12... | -| identity | 4 | 0, 4, 9, 14... | - -Turn 0 = ALL fire (first turn guarantee). After that, max 1 loader per turn, each refreshed every 5 turns, staggered so they never collide. - -## 4. Edge cases - -**FIRST TURN:** Handled — `should_fire` returns True unconditionally when `turn==0`. Agent always gets full context on session start. - -**CONCURRENT SESSIONS:** Safe — counter file is keyed by session_id. Two sessions in different branches use different files, no conflict. - -**COMPACTION — CRITICAL INTERACTION:** When Claude Code compacts, injected prompts from prior turns get summarized or dropped from context. If a loader's next fire is 3-4 turns away post-compaction, the agent operates without that prompt content until it re-fires. - -**Fix:** Add a PreCompact handler (or extend the existing `compact.py`) that RESETS the cadence counter to -1. On the next UserPromptSubmit after compaction, `_load_and_increment` reads `-1+1=0`, and turn 0 = all loaders fire. Cost: one extra full-injection turn after each compaction, which is exactly right — the agent needs the prompts re-injected after losing context. - -**FILE I/O COST:** Negligible. One stat + read + write of ~15 bytes per turn. Way cheaper than the ~3,750 tokens saved. - -## 5. Robustness assessment — SOLID - -**Strengths:** -- Pattern is simple and deterministic. No async, no races, no distributed state. -- Sequential dispatch (`engine.py`) guarantees no concurrent access to the counter file within a single turn. -- `/tmp` cleanup on reboot = no accumulation. Session files are tiny and ephemeral. -- Module-level cache (`_turn`) prevents double-increment even if called from multiple handlers. -- Testable in isolation — mock `os.environ` + `/tmp` path, assert `should_fire` returns correctly. - -**One fragility to flag:** if Claude Code ever changes to dispatch UserPromptSubmit handlers in PARALLEL (separate processes), the module-level cache breaks and you'd get 3 increments per turn. Current architecture is sequential — but worth a comment noting the assumption. Mitigation: use file locking (`fcntl.flock`) if parallelism ever arrives, but don't build it now. - -No other fragility concerns. The mechanism is as robust as `auto_process.py`'s session guard, which has been running reliably since S10. +**FILE I/O COST:** One flock + read + conditional write of ~30 bytes per turn. Negligible vs ~3,750 tokens saved. --- ## Summary -Fully feasible. Shared `cadence.py` module, `/tmp` state file keyed by session_id, modulo+offset check per handler, turn-0 guarantee, PreCompact counter reset for compaction safety. Ready for implementation when DPLAN-0200 greenlights it. +Multi-process safe cadence via fcntl.flock + mtime debounce + transcript-size token. Shared module in `apps/modules/`, handlers access via importlib. Action-gated sound system-wide. 438 tests, seedgo 100%. -*Investigation by @hooks, 2026-06-08* +*Investigation by @hooks, 2026-06-08. Updated post-REDO 2026-06-09.* diff --git a/src/aipass/hooks/tests/test_announce.py b/src/aipass/hooks/tests/test_announce.py index 3e921d86..66d8f640 100644 --- a/src/aipass/hooks/tests/test_announce.py +++ b/src/aipass/hooks/tests/test_announce.py @@ -1,16 +1,14 @@ # =================== AIPass ==================== # Name: test_announce.py -# Version: 1.2.0 +# Version: 1.3.0 # Description: Tests for announce notification handler # Branch: hooks # Created: 2026-05-20 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Tests for handlers/notification/announce.py.""" -from unittest.mock import patch - class TestAnnounceHandler: """Core handler behavior tests.""" @@ -18,17 +16,15 @@ class TestAnnounceHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.announce import handle - with patch("aipass.hooks.apps.handlers.notification.announce.speak"): - result = handle({}) + result = handle({}) assert isinstance(result, dict) assert result["stdout"] == "" assert result["exit_code"] == 0 - def test_handle_speaks_notification_sound(self): + def test_handle_sets_sound_key(self): from aipass.hooks.apps.handlers.notification.announce import handle - with patch("aipass.hooks.apps.handlers.notification.announce.speak") as mock_speak: - handle({}) + result = handle({}) - mock_speak.assert_called_once_with("notification sound") + assert result["sound"] == "notification sound" diff --git a/src/aipass/hooks/tests/test_email.py b/src/aipass/hooks/tests/test_email.py index c2fb2804..fa65b1fc 100644 --- a/src/aipass/hooks/tests/test_email.py +++ b/src/aipass/hooks/tests/test_email.py @@ -1,10 +1,10 @@ # =================== AIPass ==================== # Name: test_email.py -# Version: 1.2.0 +# Version: 1.3.0 # Description: Tests for email notification handler # Branch: hooks # Created: 2026-05-21 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Tests for handlers/notification/email.py.""" @@ -41,12 +41,9 @@ class TestEmailHandler: encoding="utf-8", ) - with ( - patch( - "aipass.hooks.apps.handlers.notification.email._find_branch_root", - return_value=tmp_path, - ), - patch("aipass.hooks.apps.handlers.notification.email.speak"), + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, ): result = handle({}) @@ -54,7 +51,7 @@ class TestEmailHandler: assert "drone @ai_mail inbox" in result["stdout"] assert result["exit_code"] == 0 - def test_handle_speaks_when_new_emails(self, tmp_path): + def test_handle_sets_sound_when_new_emails(self, tmp_path): from aipass.hooks.apps.handlers.notification.email import handle inbox_dir = tmp_path / ".ai_mail.local" @@ -65,18 +62,15 @@ class TestEmailHandler: encoding="utf-8", ) - with ( - patch( - "aipass.hooks.apps.handlers.notification.email._find_branch_root", - return_value=tmp_path, - ), - patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak, + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, ): - handle({}) + result = handle({}) - mock_speak.assert_called_once_with("email notification: 1 new email") + assert result["sound"] == "email notification: 1 new email" - def test_handle_does_not_speak_when_no_emails(self, tmp_path): + def test_handle_no_sound_when_no_emails(self, tmp_path): from aipass.hooks.apps.handlers.notification.email import handle inbox_dir = tmp_path / ".ai_mail.local" @@ -87,16 +81,13 @@ class TestEmailHandler: encoding="utf-8", ) - with ( - patch( - "aipass.hooks.apps.handlers.notification.email._find_branch_root", - return_value=tmp_path, - ), - patch("aipass.hooks.apps.handlers.notification.email.speak") as mock_speak, + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, ): - handle({}) + result = handle({}) - mock_speak.assert_not_called() + assert result.get("sound", "") == "" def test_handle_returns_empty_when_no_new_emails(self, tmp_path): from aipass.hooks.apps.handlers.notification.email import handle @@ -109,12 +100,9 @@ class TestEmailHandler: encoding="utf-8", ) - with ( - patch( - "aipass.hooks.apps.handlers.notification.email._find_branch_root", - return_value=tmp_path, - ), - patch("aipass.hooks.apps.handlers.notification.email.speak"), + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, ): result = handle({}) @@ -140,12 +128,9 @@ class TestEmailHandler: encoding="utf-8", ) - with ( - patch( - "aipass.hooks.apps.handlers.notification.email._find_branch_root", - return_value=tmp_path, - ), - patch("aipass.hooks.apps.handlers.notification.email.speak"), + with patch( + "aipass.hooks.apps.handlers.notification.email._find_branch_root", + return_value=tmp_path, ): result = handle({}) diff --git a/src/aipass/hooks/tests/test_stop_sound.py b/src/aipass/hooks/tests/test_stop_sound.py index a0a1d6b0..f554ea45 100644 --- a/src/aipass/hooks/tests/test_stop_sound.py +++ b/src/aipass/hooks/tests/test_stop_sound.py @@ -1,16 +1,14 @@ # =================== AIPass ==================== # Name: test_stop_sound.py -# Version: 1.2.0 +# Version: 1.3.0 # Description: Tests for stop_sound notification handler # Branch: hooks # Created: 2026-05-20 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Tests for handlers/notification/stop_sound.py.""" -from unittest.mock import patch - class TestStopSoundHandler: """Core handler behavior tests.""" @@ -18,26 +16,23 @@ class TestStopSoundHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak"): - result = handle({}) + result = handle({}) assert isinstance(result, dict) assert result["stdout"] == "" assert result["exit_code"] == 0 - def test_handle_speaks_stop_sound(self): + def test_handle_sets_sound_key(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak: - handle({}) + result = handle({}) - mock_speak.assert_called_once_with("stop sound") + assert result["sound"] == "stop sound" - def test_handle_skips_when_stop_hook_active(self): + def test_handle_no_sound_when_stop_hook_active(self): from aipass.hooks.apps.handlers.notification.stop_sound import handle - with patch("aipass.hooks.apps.handlers.notification.stop_sound.speak") as mock_speak: - result = handle({"stop_hook_active": True}) + result = handle({"stop_hook_active": True}) - mock_speak.assert_not_called() + assert result.get("sound", "") == "" assert result["exit_code"] == 0 diff --git a/src/aipass/hooks/tests/test_tool_sound.py b/src/aipass/hooks/tests/test_tool_sound.py index 0e9cb1a7..107d70f7 100644 --- a/src/aipass/hooks/tests/test_tool_sound.py +++ b/src/aipass/hooks/tests/test_tool_sound.py @@ -1,16 +1,14 @@ # =================== AIPass ==================== # Name: test_tool_sound.py -# Version: 1.2.0 +# Version: 1.3.0 # Description: Tests for tool_sound notification handler # Branch: hooks # Created: 2026-05-19 -# Modified: 2026-05-22 +# Modified: 2026-06-09 # ============================================= """Tests for handlers/notification/tool_sound.py.""" -from unittest.mock import patch - class TestToolSoundHandler: """Core handler behavior tests.""" @@ -18,8 +16,7 @@ class TestToolSoundHandler: def test_handle_returns_result_dict(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak"): - result = handle({"tool_name": "Bash"}) + result = handle({"tool_name": "Bash"}) assert isinstance(result, dict) assert "stdout" in result @@ -27,26 +24,23 @@ class TestToolSoundHandler: assert result["stdout"] == "" assert result["exit_code"] == 0 - def test_speaks_tool_name(self): + def test_sound_key_includes_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: - handle({"tool_name": "Edit"}) + result = handle({"tool_name": "Edit"}) - mock_speak.assert_called_once_with("tool sound: Edit") + assert result["sound"] == "tool sound: Edit" - def test_no_speak_when_no_tool_name(self): + def test_no_sound_when_no_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: - handle({}) + result = handle({}) - mock_speak.assert_not_called() + assert result.get("sound", "") == "" - def test_no_speak_when_empty_tool_name(self): + def test_no_sound_when_empty_tool_name(self): from aipass.hooks.apps.handlers.notification.tool_sound import handle - with patch("aipass.hooks.apps.handlers.notification.tool_sound.speak") as mock_speak: - handle({"tool_name": ""}) + result = handle({"tool_name": ""}) - mock_speak.assert_not_called() + assert result.get("sound", "") == "" From 0c6e8ac4257e47870c66249b7fced8eea419c3b1 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 13:10:27 -0700 Subject: [PATCH 12/20] fix(hooks): auto_watchdog sound-migration (FPLAN-0249 tail) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same action-gated pattern as the notification handlers — speak() -> 'sound' return-key. Missed in b26bd7c. Hooks suite green. Co-Authored-By: Claude Opus 4.8 --- src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py index 182fedc1..dd909a02 100644 --- a/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py +++ b/src/aipass/hooks/apps/handlers/lifecycle/auto_watchdog.py @@ -12,8 +12,6 @@ import json -from aipass.hooks.apps.sound import speak - def handle(hook_data: dict) -> dict: """Return additionalContext reminder if dispatch detected without watchdog. @@ -39,8 +37,6 @@ def handle(hook_data: dict) -> dict: if "dispatch wake" in command and "dispatch @" not in command: return {"stdout": "", "exit_code": 0} - speak("auto watchdog") - result = { "additionalContext": ( "[AUTO-WATCHDOG] Dispatch detected — arm watchdog NOW. " @@ -48,4 +44,4 @@ def handle(hook_data: dict) -> dict: "run_in_background: true and timeout: 600000." ) } - return {"stdout": json.dumps(result), "exit_code": 0} + return {"stdout": json.dumps(result), "exit_code": 0, "sound": "auto watchdog"} From 0b4ba63fae3812e2b70062440aa5afaf42b9af51 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 13:16:22 -0700 Subject: [PATCH 13/20] feat(sandbox): kernel filesystem boundary for agent containment (DPLAN-0202/FPLAN-0250) Every autonomous agent can launch inside a kernel-enforced mount namespace (srt -> bwrap+seccomp): reads stay open (shared live FS preserved, bind-mount not isolation; own-tree writes land live), but rm/python/find/Write on .git or sibling trees hit EROFS. /tmp + own tree writable; .git RW devpulse, RO builders. Inert by default behind AIPASS_SANDBOX_ENABLED (off); flag-off path byte-identical to old. hooks: srt wrapper + per-role build_policy + broker_secret mask; rm_gate demoted. drone: out-of-sandbox broker (identity allowlist, openat2 RESOLVE_BENEATH, HMAC handshake over inherited fd, audit); drone rm via broker when sandboxed. ai_mail: dispatch gate + broker-fd wiring (fail-loud exit -4, never silent). aipass: doctor Sandbox group + setup.sh prereqs (LOUD on missing). Proven by a live 16-check red-team suite. seedgo 100% + 2859 tests green across all 5 touched branches. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 + setup.sh | 86 ++ src/aipass/ai_mail/.seedgo/bypass.json | 7 +- .../handlers/dispatch/dispatch_monitor.py | 111 +- .../ai_mail/tests/test_dispatch_monitor.py | 634 +++++++++- .../ai_mail/tests/test_send_identity.py | 9 +- src/aipass/aipass/.seedgo/bypass.json | 15 + .../apps/handlers/sandbox_check/__init__.py | 21 + .../handlers/sandbox_check/sandbox_checker.py | 253 ++++ src/aipass/aipass/apps/modules/doctor.py | 109 +- src/aipass/aipass/tests/test_sandbox_check.py | 582 +++++++++ src/aipass/devpulse/.seedgo/bypass.json | 25 + src/aipass/drone/.seedgo/bypass.json | 61 + src/aipass/drone/README.md | 18 +- .../drone/apps/handlers/broker/__init__.py | 8 + .../drone/apps/handlers/broker/client.py | 154 +++ .../drone/apps/handlers/broker/daemon.py | 440 +++++++ .../apps/handlers/broker/path_resolver.py | 139 +++ .../drone/apps/handlers/broker/protocol.py | 76 ++ src/aipass/drone/apps/handlers/rm_handler.py | 5 + src/aipass/drone/apps/modules/broker.py | 119 ++ src/aipass/drone/apps/modules/rm.py | 12 + src/aipass/drone/tests/test_broker.py | 853 +++++++++++++ src/aipass/hooks/.seedgo/bypass.json | 1077 +++++++++++++---- src/aipass/hooks/README.md | 41 +- .../hooks/apps/handlers/security/rm_gate.py | 15 +- .../hooks/apps/modules/_srt_resolve.mjs | 34 + src/aipass/hooks/apps/modules/sandbox.py | 284 +++++ src/aipass/hooks/tests/test_sandbox.py | 484 ++++++++ 29 files changed, 5448 insertions(+), 245 deletions(-) create mode 100644 src/aipass/aipass/apps/handlers/sandbox_check/__init__.py create mode 100644 src/aipass/aipass/apps/handlers/sandbox_check/sandbox_checker.py create mode 100644 src/aipass/aipass/tests/test_sandbox_check.py create mode 100644 src/aipass/drone/apps/handlers/broker/__init__.py create mode 100644 src/aipass/drone/apps/handlers/broker/client.py create mode 100644 src/aipass/drone/apps/handlers/broker/daemon.py create mode 100644 src/aipass/drone/apps/handlers/broker/path_resolver.py create mode 100644 src/aipass/drone/apps/handlers/broker/protocol.py create mode 100644 src/aipass/drone/apps/modules/broker.py create mode 100644 src/aipass/drone/tests/test_broker.py create mode 100644 src/aipass/hooks/apps/modules/_srt_resolve.mjs create mode 100644 src/aipass/hooks/apps/modules/sandbox.py create mode 100644 src/aipass/hooks/tests/test_sandbox.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 86ef7d9b..ab1ac464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,27 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Added +- **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).** + Every autonomous agent can now launch inside a kernel-enforced mount namespace + (`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open + (the shared live filesystem is preserved — a bind-mount, *not* isolation: own-tree + writes land live on the real FS instantly) but deletes/overwrites of protected + paths (`.git`, sibling branch trees) fail at the kernel no matter how the call is + phrased — `rm`, `python os.remove`, `find -delete`, Write tool all hit EROFS. + `/tmp` and the agent's own tree stay writable; `.git` is RW for devpulse, RO for + builders. A per-role policy generator (`@hooks build_policy`) derives each branch's + writable/RO map from its passport. Privileged deletes route through an + out-of-sandbox **drone-broker** daemon: identity-scoped allowlist, `openat2` + RESOLVE_BENEATH path re-resolution (confused-deputy proof), HMAC identity handshake + over a pre-connected inherited fd, JSONL audit. `aipass doctor` gained a **Sandbox** + check group (bwrap present+functional, node, srt, rg, broker socket) that is LOUD + when the flag is on and a prereq is missing — never a silent unsandboxed launch. + Proven by a live 16-check red-team suite. **Inert by default** — gated behind + `AIPASS_SANDBOX_ENABLED` (off); flag-off is byte-identical to the old dispatch path. +- **rm_gate demoted to guardrail.** Now framed honestly as early-feedback that + catches the accidental `rm -rf` and teaches `drone rm` — belt-and-suspenders, with + the kernel sandbox as the actual filesystem boundary. + - **Prompt-injection cadence — fire the big loaders every Nth turn.** The global and branch prompts are large and were re-injected on *every* turn even though a prior copy stays in the conversation. They now fire together every 5th turn diff --git a/setup.sh b/setup.sh index 1bdecbde..6678ac01 100755 --- a/setup.sh +++ b/setup.sh @@ -226,6 +226,92 @@ if [ "$IS_WINDOWS" -eq 1 ]; then fi fi +# --- Sandbox prerequisites (kernel FS boundary) --- +echo "" +echo "Checking sandbox prerequisites ..." + +if [ "$IS_WINDOWS" -eq 1 ] || [ "$IS_MACOS" -eq 1 ]; then + echo " kernel sandbox: Linux-only for now, skipping" +else + SB_MISSING=() + + # bwrap + if command -v bwrap &>/dev/null; then + echo " bwrap ... $(bwrap --version 2>/dev/null || echo 'found')" + else + echo " bwrap ... MISSING" + echo " sudo apt install bubblewrap" + SB_MISSING+=("bwrap") + fi + + # node + if command -v node &>/dev/null; then + echo " node ... $(node --version 2>/dev/null)" + else + echo " node ... MISSING" + echo " Install Node.js: https://nodejs.org/" + SB_MISSING+=("node") + fi + + # npm (needed for srt install) + if command -v npm &>/dev/null; then + echo " npm ... $(npm --version 2>/dev/null)" + else + echo " npm ... MISSING" + SB_MISSING+=("npm") + fi + + # @anthropic-ai/sandbox-runtime — resolve same way as _srt_resolve.mjs + if command -v node &>/dev/null; then + SRT_PATH=$(node -e " + const p = require('path'); + const fs = require('fs'); + const prefix = p.dirname(p.dirname(process.execPath)); + const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js'); + if (fs.existsSync(entry)) process.stdout.write(entry); + else process.exit(1); + " 2>/dev/null) || SRT_PATH="" + if [ -n "$SRT_PATH" ]; then + echo " srt ... $SRT_PATH" + else + echo " srt ... MISSING" + if command -v npm &>/dev/null; then + echo " Attempting: npm install -g @anthropic-ai/sandbox-runtime" + if npm install -g @anthropic-ai/sandbox-runtime 2>/dev/null; then + echo " srt ... installed" + else + echo " Install failed (may need sudo). Run manually:" + echo " sudo npm install -g @anthropic-ai/sandbox-runtime" + SB_MISSING+=("srt") + fi + else + echo " Install node+npm first, then: npm install -g @anthropic-ai/sandbox-runtime" + SB_MISSING+=("srt") + fi + fi + else + echo " srt ... skipped (no node)" + SB_MISSING+=("srt") + fi + + # rg (ripgrep) + if command -v rg &>/dev/null; then + echo " rg ... $(rg --version 2>/dev/null | head -1)" + elif [ -f "$HOME/.local/bin/rg" ]; then + echo " rg ... $HOME/.local/bin/rg" + else + echo " rg ... MISSING" + echo " sudo apt install ripgrep" + SB_MISSING+=("rg") + fi + + if [ ${#SB_MISSING[@]} -eq 0 ]; then + echo " sandbox prereqs: READY" + else + echo " sandbox prereqs: INCOMPLETE (${SB_MISSING[*]} missing) — aipass doctor for details" + fi +fi + # --- Verify CLI entry points --- FAIL=0 diff --git a/src/aipass/ai_mail/.seedgo/bypass.json b/src/aipass/ai_mail/.seedgo/bypass.json index 3be88897..23abf4ee 100644 --- a/src/aipass/ai_mail/.seedgo/bypass.json +++ b/src/aipass/ai_mail/.seedgo/bypass.json @@ -93,7 +93,12 @@ { "file": "apps/handlers/dispatch/dispatch_monitor.py", "standard": "handlers", - "reason": "Imports notify.send_notification — same-branch cross-handler import for bounce/completion notifications." + "reason": "Imports notify.send_notification (same-branch cross-handler) for bounce/completion notifications. Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() for Phase 6b broker-fd handshake (FPLAN-0250) — cross-branch handler import authorized by brief." + }, + { + "file": "apps/handlers/dispatch/dispatch_monitor.py", + "standard": "encapsulation", + "reason": "Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() — cross-branch handler import for Phase 6b broker-fd handshake (FPLAN-0250). Brief explicitly authorizes this import path." }, { "file": "apps/handlers/dispatch/wake.py", diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py index ae0fab1d..2abaa77a 100644 --- a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -23,6 +23,8 @@ is guaranteed. import json import os +import shlex +import socket import sys import subprocess import time @@ -41,6 +43,43 @@ HARD_TIMEOUT = 7200 # 2 hours POLL_INTERVAL = 5 +def _is_sandbox_enabled() -> bool: + """Check if dispatch sandbox is enabled via AIPASS_SANDBOX_ENABLED env var.""" + return os.environ.get("AIPASS_SANDBOX_ENABLED", "").lower() in ("1", "true", "yes") + + +def _wrap_for_sandbox(cmd: list, branch_path: Path) -> list: + """Wrap a claude command in the srt kernel sandbox. + + Uses @hooks sandbox building blocks to resolve the bwrap command, + then returns a shell invocation list compatible with Popen. + + Raises on ANY failure — caller must not silently fall back to unsandboxed. + """ + from aipass.hooks.apps.modules.sandbox import build_policy, build_srt_config, resolve_bwrap_command + + policy = build_policy(branch_path) + srt_config = build_srt_config(policy) + cmd_str = shlex.join(cmd) + bwrap_cmd = resolve_bwrap_command(cmd_str, srt_config) + return ["/bin/bash", "-c", bwrap_cmd] + + +def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket: + """Create an identified broker connection for the target branch. + + Returns a connected, HMAC-authenticated socket ready to be inherited + by the sandboxed child via pass_fds + AIPASS_BROKER_FD. + + Raises on ANY failure — caller must not silently skip the broker. + """ + from aipass.drone.apps.handlers.broker.client import create_identified_connection + + socket_path = repo_root / ".ai_central" / "drone_broker.sock" + secret_path = repo_root / ".ai_central" / "broker_secret" + return create_identified_connection(socket_path, secret_path, branch_name) + + def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool: """Send return-to-sender bounce email via drone.""" subject = f"BOUNCE: Dispatch to {branch_email} failed" @@ -176,7 +215,7 @@ def _kill_process(process: subprocess.Popen, branch_email: str): def _run_with_startup_check( - claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str + claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str, pass_fds: tuple = () ) -> tuple: """ Run claude with startup timeout check. @@ -194,13 +233,16 @@ def _run_with_startup_check( logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e) try: - process = subprocess.Popen( - claude_cmd, - stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL, - stderr=stderr_fh, - cwd=cwd, - env=spawn_env, - ) + popen_kwargs = { + "stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL, + "stderr": stderr_fh, + "cwd": cwd, + "env": spawn_env, + } + if pass_fds: + popen_kwargs["close_fds"] = True + popen_kwargs["pass_fds"] = pass_fds + process = subprocess.Popen(claude_cmd, **popen_kwargs) except Exception as e: logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e) if stdout_fh is not None: @@ -338,6 +380,11 @@ def main(): start_time = time.time() + # ─── Sandbox Gate ───────────────────────────────────── + sandbox_enabled = _is_sandbox_enabled() + if sandbox_enabled: + logger.info("[monitor] Sandbox ENABLED for %s", branch_email) + # ─── Retry Loop: 3 Strikes ───────────────────────────── # Strike 1: original command (resume if -c was passed) # Strike 2: same command again (transient failure) @@ -356,14 +403,60 @@ def main(): cmd = claude_cmd mode = "resume" if has_resume else "fresh" + # Sandbox wrap + broker fd: when enabled, wrap cmd and connect broker. + # On failure: abort — NEVER silently launch unsandboxed. + run_cmd = cmd + broker_sock = None + attempt_pass_fds: tuple = () + if sandbox_enabled: + try: + run_cmd = _wrap_for_sandbox(cmd, branch_path) + except Exception as e: + logger.error( + "[monitor] Sandbox init FAILED for %s: %s — ABORTING (will NOT launch unsandboxed)", + branch_email, + e, + ) + exit_code = -4 + attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode}) + break + + try: + broker_sock = _connect_broker(_repo_root, branch_email.lstrip("@")) + broker_fd = broker_sock.fileno() + spawn_env["AIPASS_BROKER_FD"] = str(broker_fd) + attempt_pass_fds = (broker_fd,) + logger.info("[monitor] Broker fd %d connected for %s", broker_fd, branch_email) + except Exception as e: + logger.error( + "[monitor] Broker connect FAILED for %s: %s — ABORTING", + branch_email, + e, + ) + exit_code = -4 + attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode}) + break + if stderr_fh is not None: stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n") stderr_fh.flush() exit_code, startup_failed = _run_with_startup_check( - cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, cwd, spawn_env, branch_email + run_cmd, + stdout_log, + stderr_fh if stderr_fh is not None else subprocess.DEVNULL, + cwd, + spawn_env, + branch_email, + pass_fds=attempt_pass_fds, ) + # Close parent's broker socket copy — child owns the fd now. + if broker_sock is not None: + broker_sock.close() + broker_sock = None + spawn_env.pop("AIPASS_BROKER_FD", None) + attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode}) # Success — done diff --git a/src/aipass/ai_mail/tests/test_dispatch_monitor.py b/src/aipass/ai_mail/tests/test_dispatch_monitor.py index f53b114a..903ab95f 100644 --- a/src/aipass/ai_mail/tests/test_dispatch_monitor.py +++ b/src/aipass/ai_mail/tests/test_dispatch_monitor.py @@ -9,7 +9,9 @@ """Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting.""" import json +import os import subprocess +import sys import time import pytest from pathlib import Path @@ -20,11 +22,13 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import ( _check_jsonl_activity, _check_rate_limited, _get_jsonl_projects_dir, + _is_sandbox_enabled, _kill_process, _make_fresh_cmd, _run_with_startup_check, _send_bounce, _snapshot_jsonl_sizes, + _wrap_for_sandbox, main, ) @@ -634,7 +638,7 @@ def test_max_turns_changes_notification_status(monkeypatch, main_argv): stdout_log = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log" stdout_log.parent.mkdir(parents=True, exist_ok=True) - def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch): + def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs): # Simulate writing max_turns output stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8") return (0, False) @@ -810,7 +814,7 @@ def test_env_vars_set_correctly(monkeypatch, main_argv): captured_env = {} - def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch): + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs): captured_env.update(env) return (0, False) @@ -900,7 +904,7 @@ def test_main_max_turns_detected(monkeypatch, main_argv): stdout_log = branch_dir / "logs" / "dispatch_stdout.log" stdout_log.parent.mkdir(parents=True, exist_ok=True) - def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch): + def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs): # Write max_turns stop_reason into stdout log Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8") return (0, False) @@ -1075,7 +1079,7 @@ def test_env_vars_setup(monkeypatch, main_argv): captured_env = {} - def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch): + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs): captured_env.update(env) return (0, False) @@ -1194,3 +1198,625 @@ def test_check_jsonl_activity_no_change(tmp_path): def test_check_jsonl_activity_missing_dir(tmp_path): """Nonexistent directory -> False.""" assert _check_jsonl_activity(tmp_path / "nope", {}) is False + + +# --- Sandbox gate tests (Phase 4 FPLAN-0250) -------------------------------- + + +class TestIsSandboxEnabled: + """_is_sandbox_enabled reads AIPASS_SANDBOX_ENABLED from env.""" + + def test_unset_returns_false(self, monkeypatch): + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + assert _is_sandbox_enabled() is False + + def test_empty_returns_false(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "") + assert _is_sandbox_enabled() is False + + def test_false_string_returns_false(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "false") + assert _is_sandbox_enabled() is False + + def test_zero_returns_false(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "0") + assert _is_sandbox_enabled() is False + + def test_one_returns_true(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + assert _is_sandbox_enabled() is True + + def test_true_returns_true(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true") + assert _is_sandbox_enabled() is True + + def test_yes_returns_true(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes") + assert _is_sandbox_enabled() is True + + def test_TRUE_case_insensitive(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE") + assert _is_sandbox_enabled() is True + + +class TestFlagOffOldPath: + """Flag OFF (default): dispatch uses the original cmd, no sandbox wrapping.""" + + def test_flag_off_cmd_unchanged(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + + captured_cmds = [] + + def capture_run(cmd, *args, **kwargs): + captured_cmds.append(cmd) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert len(captured_cmds) == 1 + assert captured_cmds[0] == ["claude", "-c", "--model", "opus"] + + def test_flag_off_wrap_never_called(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + + wrap_calls = [] + original_wrap = mod._wrap_for_sandbox + + def tracking_wrap(*args, **kwargs): + wrap_calls.append(args) + return original_wrap(*args, **kwargs) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_wrap_for_sandbox", tracking_wrap) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert wrap_calls == [] + + +class TestFlagOnSandboxPath: + """Flag ON: dispatch wraps cmd via _wrap_for_sandbox.""" + + def test_flag_on_cmd_wrapped(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + captured_cmds = [] + + def capture_run(cmd, *args, **kwargs): + captured_cmds.append(cmd) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap --sandbox " + " ".join(cmd)], + ) + mock_sock = MagicMock() + mock_sock.fileno.return_value = 99 + monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock)) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert len(captured_cmds) == 1 + assert captured_cmds[0][0] == "/bin/bash" + assert captured_cmds[0][1] == "-c" + assert "bwrap --sandbox" in captured_cmds[0][2] + + def test_wrap_calls_building_blocks(self, monkeypatch, tmp_path): + call_log = [] + + def mock_build_policy(bp): + call_log.append("build_policy") + return {"allow_write": [str(bp)], "deny_write": [], "deny_read": []} + + def mock_build_srt_config(policy): + call_log.append("build_srt_config") + return {"filesystem": {"allowWrite": policy["allow_write"]}} + + def mock_resolve_bwrap(cmd_str, srt_config): + call_log.append("resolve_bwrap_command") + return f"bwrap --ro-bind / / {cmd_str}" + + monkeypatch.setattr("aipass.hooks.apps.modules.sandbox.build_policy", mock_build_policy) + monkeypatch.setattr( + "aipass.hooks.apps.modules.sandbox.build_srt_config", + mock_build_srt_config, + ) + monkeypatch.setattr( + "aipass.hooks.apps.modules.sandbox.resolve_bwrap_command", + mock_resolve_bwrap, + ) + + result = _wrap_for_sandbox(["claude", "--model", "opus"], tmp_path) + + assert call_log == ["build_policy", "build_srt_config", "resolve_bwrap_command"] + assert result[0] == "/bin/bash" + assert result[1] == "-c" + assert "claude" in result[2] + + +class TestBrokenSandboxFailsLoud: + """Flag ON but sandbox init fails: ABORT, never silently unsandbox.""" + + def test_sandbox_init_failure_aborts(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + run_calls = [] + + def capture_run(cmd, *args, **kwargs): + run_calls.append(cmd) + return (0, False) + + def broken_wrap(cmd, bp): + raise RuntimeError("srt resolve failed: node not found") + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert run_calls == [] + assert exc_info.value.code != 0 + + def test_sandbox_failure_sends_bounce(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + def broken_wrap(cmd, bp): + raise FileNotFoundError("node not found in PATH") + + mock_bounce = MagicMock() + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap) + monkeypatch.setattr(mod, "_send_bounce", mock_bounce) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + mock_bounce.assert_called_once() + reason = mock_bounce.call_args[0][1] + assert "sandbox" in reason.lower() or "-4" in reason + + def test_never_falls_back_to_unsandboxed(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + wrap_calls = [0] + run_calls = [] + + def counting_broken_wrap(cmd, bp): + wrap_calls[0] += 1 + raise RuntimeError("srt unavailable") + + def capture_run(cmd, *args, **kwargs): + run_calls.append(cmd) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_wrap_for_sandbox", counting_broken_wrap) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert wrap_calls[0] == 1 + assert run_calls == [] + + +# --- Broker-fd handshake tests (Phase 6b FPLAN-0250) ------------------------- + + +class TestFlagOffNoBroker: + """Flag OFF: no broker connection attempted at all.""" + + def test_flag_off_no_broker_activity(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + + connect_calls = [] + + def tracking_connect(*args, **kwargs): + connect_calls.append(args) + raise RuntimeError("should never be called") + + monkeypatch.setattr(mod, "_connect_broker", tracking_connect) + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert connect_calls == [] + + def test_flag_off_no_broker_fd_in_env(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + + captured_env = {} + + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs): + captured_env.update(env) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + assert "AIPASS_BROKER_FD" not in captured_env + + +class TestBrokerDownFailsLoud: + """Broker down + flag ON → exit -4, agent never spawned.""" + + def test_broker_connect_failure_aborts(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + run_calls = [] + + def capture_run(cmd, *args, **kwargs): + run_calls.append(cmd) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr( + mod, + "_connect_broker", + MagicMock(side_effect=OSError("broker socket not found")), + ) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert run_calls == [] + assert exc_info.value.code != 0 + + def test_broker_bad_hmac_aborts(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + run_calls = [] + + def capture_run(cmd, *args, **kwargs): + run_calls.append(cmd) + return (0, False) + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr( + mod, + "_connect_broker", + MagicMock(side_effect=RuntimeError("Broker identify failed: bad HMAC")), + ) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert run_calls == [] + assert exc_info.value.code != 0 + + def test_broker_failure_sends_bounce(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + mock_bounce = MagicMock() + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr( + mod, + "_connect_broker", + MagicMock(side_effect=OSError("socket missing")), + ) + monkeypatch.setattr(mod, "_send_bounce", mock_bounce) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + mock_bounce.assert_called_once() + + +class TestBrokerFdHandshake: + """Flag ON + broker up: fd passed to child, parent closes after spawn.""" + + def test_broker_fd_in_env_and_pass_fds(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + captured_env = {} + captured_pass_fds = [] + + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()): + captured_env.update(env) + captured_pass_fds.append(pass_fds) + return (0, False) + + mock_sock = MagicMock() + mock_sock.fileno.return_value = 42 + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock)) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 0 + assert captured_env.get("AIPASS_BROKER_FD") == "42" + assert captured_pass_fds == [(42,)] + mock_sock.close.assert_called_once() + + def test_parent_closes_socket_after_spawn(self, monkeypatch, main_argv): + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + mock_sock = MagicMock() + mock_sock.fileno.return_value = 7 + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False))) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock)) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + mock_sock.close.assert_called_once() + + def test_broker_fd_cleaned_from_env_after_spawn(self, monkeypatch, main_argv): + """After spawn+close, AIPASS_BROKER_FD removed from spawn_env.""" + argv, lock_file, stderr_log = main_argv + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + + env_snapshots = [] + + def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()): + env_snapshots.append(dict(env)) + return (0, False) + + mock_sock = MagicMock() + mock_sock.fileno.return_value = 10 + + monkeypatch.setattr("sys.argv", argv) + monkeypatch.setattr(mod, "_run_with_startup_check", capture_run) + monkeypatch.setattr( + mod, + "_wrap_for_sandbox", + lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)], + ) + monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock)) + monkeypatch.setattr(mod, "_send_bounce", MagicMock()) + monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False)) + monkeypatch.setattr( + "aipass.ai_mail.apps.handlers.paths.find_repo_root", + MagicMock(return_value=Path("/fake/repo")), + ) + + with pytest.raises(SystemExit): + main() + + # During the run, env had the FD + assert env_snapshots[0]["AIPASS_BROKER_FD"] == "10" + + +class TestBrokerRealE2E: + """Real multi-process e2e: broker daemon, identified connection, child reads fd.""" + + def test_child_inherits_broker_fd(self, tmp_path): + """Start real broker, create identified conn, spawn child that reads AIPASS_BROKER_FD.""" + import time as time_mod + from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon + from aipass.drone.apps.handlers.broker.client import create_identified_connection + + # Set up repo root with branch dir + repo_root = tmp_path / "repo" + branch_dir = repo_root / "src" / "aipass" / "testbranch" + branch_dir.mkdir(parents=True) + target_file = branch_dir / "deleteme.txt" + target_file.write_text("delete me", encoding="utf-8") + + # Start real broker + sock_path = tmp_path / "broker.sock" + audit_path = tmp_path / "audit.jsonl" + secret_path = tmp_path / "secret" + broker = BrokerDaemon( + repo_root=repo_root, + socket_path=sock_path, + audit_path=audit_path, + secret_path=secret_path, + ) + t = broker.start_background() + time_mod.sleep(0.5) + + try: + # Create identified connection (as the launcher would) + sock = create_identified_connection(sock_path, secret_path, "testbranch") + broker_fd = sock.fileno() + + # Spawn a real child that reads AIPASS_BROKER_FD and sends a delete + child_script = tmp_path / "child.py" + child_script.write_text( + """ +import os, socket, json + +fd = int(os.environ["AIPASS_BROKER_FD"]) +s = socket.socket(fileno=fd) +try: + req = json.dumps({"op": "delete", "path": "deleteme.txt", "request_id": "e2e1"}) + "\\n" + s.sendall(req.encode()) + data = b"" + while b"\\n" not in data: + chunk = s.recv(4096) + if not chunk: + break + data += chunk + resp = json.loads(data.decode()) + # Write result to a file so parent can verify + with open(os.environ["RESULT_FILE"], "w") as f: + json.dump(resp, f) +finally: + s.detach() +""", + encoding="utf-8", + ) + + result_file = tmp_path / "result.json" + env = os.environ.copy() + env["AIPASS_BROKER_FD"] = str(broker_fd) + env["RESULT_FILE"] = str(result_file) + + proc = subprocess.Popen( + [sys.executable, str(child_script)], + env=env, + pass_fds=(broker_fd,), + close_fds=True, + ) + # Parent closes its copy + sock.close() + + proc.wait(timeout=10) + assert proc.returncode == 0 + + # Verify the delete happened + assert not target_file.exists() + + # Verify the child got a success response + import json as json_mod + + result = json_mod.loads(result_file.read_text(encoding="utf-8")) + assert result["ok"] is True + + # Verify audit log carries identity + audit_lines = audit_path.read_text(encoding="utf-8").strip().splitlines() + delete_entries = [ + json_mod.loads(line) for line in audit_lines if json_mod.loads(line).get("op") == "delete" + ] + assert len(delete_entries) >= 1 + assert delete_entries[-1]["identity"] == "testbranch" + assert delete_entries[-1]["result"] == "DELETED" + + finally: + broker.stop() + t.join(timeout=3) diff --git a/src/aipass/ai_mail/tests/test_send_identity.py b/src/aipass/ai_mail/tests/test_send_identity.py index 49a33de2..a44ae51b 100755 --- a/src/aipass/ai_mail/tests/test_send_identity.py +++ b/src/aipass/ai_mail/tests/test_send_identity.py @@ -570,13 +570,18 @@ class TestDispatchEnvIsolation: ) def test_dispatch_monitor_passes_spawn_env_to_subprocess(self): - """dispatch_monitor.py must pass env=spawn_env to subprocess.run. + """dispatch_monitor.py must pass spawn_env as the subprocess env. Without this, all env var isolation is useless — the subprocess would inherit os.environ instead of the cleaned spawn_env. + Accepts either the direct kwarg form (env=spawn_env) or the + popen_kwargs dict form ("env": spawn_env) introduced with the + sandbox broker-fd wiring (FPLAN-0250 Phase 6b). """ active_source = self._load_active_source() - assert "env=spawn_env" in active_source, "dispatch_monitor.py must pass env=spawn_env to subprocess.run" + assert "env=spawn_env" in active_source or '"env": spawn_env' in active_source, ( + "dispatch_monitor.py must pass spawn_env as the subprocess env" + ) def test_detect_resolves_identity_when_cwd_is_wrong(self, clean_env, tmp_path, list_format_registry): """When AIPASS_CALLER_BRANCH is set but CWD is outside any branch, diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index 750c6e7e..aae07fe7 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -275,6 +275,21 @@ "file": "apps/handlers/json/json_handler.py", "standard": "test_quality", "reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test." + }, + { + "file": "tests/test_sandbox_check.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only." + }, + { + "file": "tests/test_sandbox_check.py", + "standard": "encapsulation", + "reason": "Unit tests must import handlers directly (sandbox_checker, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing." + }, + { + "file": "tests/test_sandbox_check.py", + "standard": "documentation", + "reason": "Test methods use descriptive names (test_flag_off_by_default, test_bwrap_functional_live) that are self-documenting. Adding docstrings to 41 test functions adds noise without value." } ] } diff --git a/src/aipass/aipass/apps/handlers/sandbox_check/__init__.py b/src/aipass/aipass/apps/handlers/sandbox_check/__init__.py new file mode 100644 index 00000000..7c241431 --- /dev/null +++ b/src/aipass/aipass/apps/handlers/sandbox_check/__init__.py @@ -0,0 +1,21 @@ +"""sandbox_check — Kernel sandbox prerequisite detection for aipass doctor.""" + +from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( # type: ignore[import-not-found] + check_broker_alive, + check_bwrap_functional, + check_bwrap_present, + check_node_present, + check_rg_present, + check_sandbox_flag, + check_srt_resolvable, +) + +__all__ = [ + "check_broker_alive", + "check_bwrap_functional", + "check_bwrap_present", + "check_node_present", + "check_rg_present", + "check_sandbox_flag", + "check_srt_resolvable", +] diff --git a/src/aipass/aipass/apps/handlers/sandbox_check/sandbox_checker.py b/src/aipass/aipass/apps/handlers/sandbox_check/sandbox_checker.py new file mode 100644 index 00000000..19caf83e --- /dev/null +++ b/src/aipass/aipass/apps/handlers/sandbox_check/sandbox_checker.py @@ -0,0 +1,253 @@ +# =================== AIPass ==================== +# Name: sandbox_checker.py +# Description: Kernel sandbox prerequisite checks for aipass doctor +# Version: 1.0.0 +# Created: 2026-06-10 +# Modified: 2026-06-10 +# ============================================= + +"""Sandbox prerequisite checker — detects bwrap, node, srt, rg, broker. + +Returns plain dicts with facts about sandbox readiness. +No Rich markup — display concerns belong to the UI layer. +""" + +from __future__ import annotations + +import os +import shutil +import socket +import subprocess +import sys +from pathlib import Path +from typing import Any, Dict + +from aipass.prax import logger +from aipass.aipass.apps.handlers.json import json_handler + + +def check_sandbox_flag() -> Dict[str, Any]: + """Check AIPASS_SANDBOX_ENABLED env var state. + + Returns: + enabled: bool + raw_value: str — the raw env value (empty if unset) + """ + raw = os.environ.get("AIPASS_SANDBOX_ENABLED", "") + enabled = raw.lower() in ("1", "true", "yes") + json_handler.log_operation("sandbox_check_flag", {"enabled": enabled, "raw": raw}) + return {"enabled": enabled, "raw_value": raw} + + +def check_bwrap_present() -> Dict[str, Any]: + """Check if bubblewrap (bwrap) binary is on PATH. + + Returns: + found: bool + path: str | None — resolved path if found + """ + path = shutil.which("bwrap") + json_handler.log_operation("sandbox_check_bwrap_present", {"found": bool(path)}) + return {"found": bool(path), "path": path} + + +def check_bwrap_functional() -> Dict[str, Any]: + """Run a trivial bwrap sandbox to verify it actually works. + + Catches AppArmor/userns restrictions that make bwrap present but blocked. + + Returns: + ok: bool + detail: str — success message or error detail + sysctl_value: str | None — kernel.apparmor_restrict_unprivileged_userns on failure + """ + bwrap = shutil.which("bwrap") + if not bwrap: + return {"ok": False, "detail": "bwrap not found", "sysctl_value": None} + + try: + proc = subprocess.run( + [bwrap, "--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if proc.returncode == 0: + json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": True}) + return {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None} + + sysctl_val = _read_userns_sysctl() + detail = f"exit {proc.returncode}" + if proc.stderr.strip(): + detail = f"{detail}: {proc.stderr.strip()[:200]}" + json_handler.log_operation("sandbox_check_bwrap_functional", {"ok": False, "detail": detail}) + return {"ok": False, "detail": detail, "sysctl_value": sysctl_val} + + except subprocess.TimeoutExpired: + logger.warning("[sandbox_check] bwrap functional test timed out") + return {"ok": False, "detail": "timed out (10s)", "sysctl_value": None} + except OSError as exc: + logger.warning("[sandbox_check] bwrap functional test error: %s", exc) + return {"ok": False, "detail": str(exc), "sysctl_value": None} + + +def _read_userns_sysctl() -> str | None: + """Read kernel.apparmor_restrict_unprivileged_userns sysctl if available.""" + try: + proc = subprocess.run( + ["sysctl", "-n", "kernel.apparmor_restrict_unprivileged_userns"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if proc.returncode == 0: + return proc.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: + logger.info("[sandbox_check] sysctl read failed (expected on non-Ubuntu): %s", exc) + return None + + +def check_node_present() -> Dict[str, Any]: + """Check if node binary is on PATH. + + Returns: + found: bool + path: str | None — resolved path if found + """ + path = shutil.which("node") + json_handler.log_operation("sandbox_check_node", {"found": bool(path)}) + return {"found": bool(path), "path": path} + + +def check_srt_resolvable() -> Dict[str, Any]: + """Check if @anthropic-ai/sandbox-runtime is resolvable via node. + + Mirrors _srt_resolve.mjs resolution: derive node prefix from process.execPath, + then check /lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js. + + Returns: + found: bool + path: str | None — resolved entry path if found + install_hint: str — npm install command if missing + """ + node = shutil.which("node") + if not node: + return { + "found": False, + "path": None, + "install_hint": "Install node first, then: npm install -g @anthropic-ai/sandbox-runtime", + } + + try: + script = ( + "const p = require('path');" + "const prefix = p.dirname(p.dirname(process.execPath));" + "const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');" + "const fs = require('fs');" + "if (fs.existsSync(entry)) { process.stdout.write(entry); }" + "else { process.exit(1); }" + ) + proc = subprocess.run( + [node, "-e", script], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + if proc.returncode == 0 and proc.stdout.strip(): + path = proc.stdout.strip() + json_handler.log_operation("sandbox_check_srt", {"found": True, "path": path}) + return {"found": True, "path": path, "install_hint": ""} + + except (FileNotFoundError, subprocess.TimeoutExpired, OSError) as exc: + logger.warning("[sandbox_check] srt resolve error: %s", exc) + + json_handler.log_operation("sandbox_check_srt", {"found": False}) + return { + "found": False, + "path": None, + "install_hint": "npm install -g @anthropic-ai/sandbox-runtime", + } + + +def check_rg_present() -> Dict[str, Any]: + """Check if ripgrep (rg) is available — matches hooks' fallback logic. + + Returns: + found: bool + path: str | None — resolved path if found + """ + rg = shutil.which("rg") + if rg: + json_handler.log_operation("sandbox_check_rg", {"found": True, "path": rg}) + return {"found": True, "path": rg} + + fallback = Path.home() / ".local" / "bin" / "rg" + if fallback.is_file(): + path = str(fallback) + json_handler.log_operation("sandbox_check_rg", {"found": True, "path": path}) + return {"found": True, "path": path} + + json_handler.log_operation("sandbox_check_rg", {"found": False}) + return {"found": False, "path": None} + + +def check_broker_alive(repo_root: Path | None = None) -> Dict[str, Any]: + """Check if the broker daemon socket is accepting connections. + + Args: + repo_root: Project root containing .ai_central/. Auto-detected if None. + + Returns: + alive: bool + detail: str — status message + """ + sock_path = _find_broker_socket(repo_root) + if sock_path is None: + json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_not_found"}) + return {"alive": False, "detail": "broker socket not found"} + + if not sock_path.exists(): + json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": "socket_missing"}) + return {"alive": False, "detail": f"socket missing: {sock_path}"} + + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(2) + s.connect(str(sock_path)) + s.close() + json_handler.log_operation("sandbox_check_broker", {"alive": True}) + return {"alive": True, "detail": "connected"} + except (OSError, socket.timeout) as exc: + logger.info("[sandbox_check] broker connect failed: %s", exc) + json_handler.log_operation("sandbox_check_broker", {"alive": False, "reason": str(exc)}) + return {"alive": False, "detail": f"connect failed: {exc}"} + + +def _find_broker_socket(repo_root: Path | None) -> Path | None: + """Locate the broker socket under $REPO/.ai_central/drone_broker.sock.""" + if repo_root and (repo_root / ".ai_central" / "drone_broker.sock").parent.is_dir(): + return repo_root / ".ai_central" / "drone_broker.sock" + + aipass_home = os.environ.get("AIPASS_HOME", "") + if aipass_home: + candidate = Path(aipass_home) / ".ai_central" / "drone_broker.sock" + if candidate.parent.is_dir(): + return candidate + + cwd = Path.cwd() + for parent in [cwd, *cwd.parents]: + candidate = parent / ".ai_central" / "drone_broker.sock" + if candidate.parent.is_dir(): + return candidate + if parent == parent.parent: + break + + return None + + +def is_linux() -> bool: + """Return True if running on Linux.""" + return sys.platform.startswith("linux") diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index 8843918e..06303c0c 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -23,6 +23,16 @@ from aipass.prax import logger from aipass.common.registry_discovery import find_registry as _discover_registry from aipass.aipass.apps.handlers.json import json_handler +from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( + check_broker_alive, + check_bwrap_functional, + check_bwrap_present, + check_node_present, + check_rg_present, + check_sandbox_flag, + check_srt_resolvable, + is_linux, +) from aipass.aipass.apps.handlers.structure_scan.structure_scanner import ( check_placement, check_pyproject, @@ -545,11 +555,105 @@ def _check_structure() -> List[CheckResult]: return results +# --- Sandbox check group --- + + +def _check_sandbox() -> List[CheckResult]: + """Run Sandbox group checks — kernel sandbox prerequisites.""" + results: List[CheckResult] = [] + + if not is_linux(): + results.append(CheckResult("sandbox", GLYPH_PASS, "kernel sandbox: Linux-only, not checked", "")) + return results + + flag = check_sandbox_flag() + flag_on = flag["enabled"] + flag_label = "ON" if flag_on else "OFF" + results.append(CheckResult("sandbox flag", GLYPH_PASS, f"AIPASS_SANDBOX_ENABLED={flag_label}", "")) + + def _sev(ok: bool) -> str: + if ok: + return GLYPH_PASS + return GLYPH_FAIL if flag_on else GLYPH_WARN + + def _suffix(ok: bool) -> str: + if ok or flag_on: + return "" + return " (inert — flag is off)" + + bwrap = check_bwrap_present() + results.append( + CheckResult( + "bwrap", + _sev(bwrap["found"]), + bwrap["path"] or "not found" + _suffix(bwrap["found"]), + "" if bwrap["found"] else "sudo apt install bubblewrap", + ) + ) + + if bwrap["found"]: + func = check_bwrap_functional() + detail = func["detail"] + if not func["ok"] and func["sysctl_value"] is not None: + detail = f"{detail} (apparmor_restrict_unprivileged_userns={func['sysctl_value']})" + results.append( + CheckResult( + "bwrap functional", + _sev(func["ok"]), + detail + _suffix(func["ok"]), + "", + ) + ) + + node = check_node_present() + results.append( + CheckResult( + "node", + _sev(node["found"]), + node["path"] or "not found" + _suffix(node["found"]), + "" if node["found"] else "Install Node.js: https://nodejs.org/", + ) + ) + + srt = check_srt_resolvable() + results.append( + CheckResult( + "srt (@anthropic-ai/sandbox-runtime)", + _sev(srt["found"]), + srt["path"] or "not found" + _suffix(srt["found"]), + "" if srt["found"] else srt["install_hint"], + ) + ) + + rg = check_rg_present() + results.append( + CheckResult( + "rg (ripgrep)", + _sev(rg["found"]), + rg["path"] or "not found" + _suffix(rg["found"]), + "" if rg["found"] else "sudo apt install ripgrep (or static binary to ~/.local/bin/rg)", + ) + ) + + project_root = find_project_root(Path.cwd()) + broker = check_broker_alive(project_root) + results.append( + CheckResult( + "broker daemon", + _sev(broker["alive"]), + broker["detail"] + _suffix(broker["alive"]), + "", + ) + ) + + return results + + # --- Main doctor run --- def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int: - """Run all five groups and print results. Returns error count.""" + """Run all six groups and print results. Returns error count.""" console.print() console.print("[bold cyan]aipass doctor[/bold cyan]") console.print() @@ -560,6 +664,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal ("Services", lambda: _check_services(verbose=verbose)), ("Community", _check_community), ("Structure", _check_structure), + ("Sandbox", _check_sandbox), ] groups: Dict[str, List[CheckResult]] = {} with make_doctor_progress() as progress: @@ -620,7 +725,7 @@ def print_introspection() -> None: console.print("[bold cyan]doctor Module[/bold cyan]") console.print("System health aggregation — flutter-doctor-style output") console.print() - console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure") + console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure, Sandbox") console.print("[yellow]Next:[/yellow] [green]aipass doctor[/green] / [green]aipass doctor --fix[/green]") console.print() diff --git a/src/aipass/aipass/tests/test_sandbox_check.py b/src/aipass/aipass/tests/test_sandbox_check.py new file mode 100644 index 00000000..1e7099cc --- /dev/null +++ b/src/aipass/aipass/tests/test_sandbox_check.py @@ -0,0 +1,582 @@ +# =================== AIPass ==================== +# Name: test_sandbox_check.py +# Description: Tests for sandbox prerequisite checker and doctor integration +# Version: 1.0.0 +# Created: 2026-06-10 +# Modified: 2026-06-10 +# ============================================= + +"""Tests for sandbox prereq checks — handler + doctor integration.""" + +import shutil +import socket +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest # pyright: ignore[reportMissingImports] + +from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( + check_broker_alive, + check_bwrap_functional, + check_bwrap_present, + check_node_present, + check_rg_present, + check_sandbox_flag, + check_srt_resolvable, + is_linux, +) +from aipass.aipass.apps.handlers.ui.progress import GLYPH_FAIL, GLYPH_PASS, GLYPH_WARN +from aipass.aipass.apps.modules.doctor import _check_sandbox + + +# ============================================================================= +# Fixtures +# ============================================================================= + + +@pytest.fixture(autouse=True) +def _stub_json_handler(): + """Suppress json_handler.log_operation side effects in all tests.""" + with patch("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.json_handler") as mock: + mock.log_operation = MagicMock() + yield mock + + +# ============================================================================= +# check_sandbox_flag +# ============================================================================= + + +class TestCheckSandboxFlag: + def test_flag_off_by_default(self, monkeypatch): + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + result = check_sandbox_flag() + assert result["enabled"] is False + assert result["raw_value"] == "" + + def test_flag_on_with_1(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + result = check_sandbox_flag() + assert result["enabled"] is True + + def test_flag_on_with_true(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true") + result = check_sandbox_flag() + assert result["enabled"] is True + + def test_flag_on_with_yes(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes") + result = check_sandbox_flag() + assert result["enabled"] is True + + def test_flag_on_case_insensitive(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE") + result = check_sandbox_flag() + assert result["enabled"] is True + + def test_flag_off_with_garbage(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "maybe") + result = check_sandbox_flag() + assert result["enabled"] is False + + +# ============================================================================= +# check_bwrap_present +# ============================================================================= + + +class TestCheckBwrapPresent: + def test_bwrap_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None) + result = check_bwrap_present() + assert result["found"] is True + assert result["path"] == "/usr/bin/bwrap" + + def test_bwrap_not_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: None) + result = check_bwrap_present() + assert result["found"] is False + assert result["path"] is None + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed") + def test_bwrap_live(self): + result = check_bwrap_present() + assert result["found"] is True + assert "bwrap" in result["path"] + + +# ============================================================================= +# check_bwrap_functional +# ============================================================================= + + +class TestCheckBwrapFunctional: + def test_bwrap_missing(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: None) + result = check_bwrap_functional() + assert result["ok"] is False + assert "not found" in result["detail"] + + def test_bwrap_succeeds(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None) + mock_proc = MagicMock(returncode=0, stderr="") + with patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + return_value=mock_proc, + ) as mock_run: + result = check_bwrap_functional() + assert result["ok"] is True + argv = mock_run.call_args[0][0] + assert argv[0] == "/usr/bin/bwrap" + assert "--ro-bind" in argv + assert "true" in argv + + def test_bwrap_fails_reports_sysctl(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None) + mock_proc = MagicMock(returncode=1, stderr="permission denied") + with ( + patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + return_value=mock_proc, + ), + patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker._read_userns_sysctl", + return_value="1", + ), + ): + result = check_bwrap_functional() + assert result["ok"] is False + assert "exit 1" in result["detail"] + assert result["sysctl_value"] == "1" + + def test_bwrap_timeout(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/bwrap" if name == "bwrap" else None) + with patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="bwrap", timeout=10), + ): + result = check_bwrap_functional() + assert result["ok"] is False + assert "timed out" in result["detail"] + + @pytest.mark.skipif(not shutil.which("bwrap"), reason="bwrap not installed") + def test_bwrap_functional_live(self): + result = check_bwrap_functional() + assert isinstance(result["ok"], bool) + if result["ok"]: + assert "succeeded" in result["detail"] + + +# ============================================================================= +# check_node_present +# ============================================================================= + + +class TestCheckNodePresent: + def test_node_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None) + result = check_node_present() + assert result["found"] is True + assert result["path"] == "/usr/bin/node" + + def test_node_not_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: None) + result = check_node_present() + assert result["found"] is False + + @pytest.mark.skipif(not shutil.which("node"), reason="node not installed") + def test_node_live(self): + result = check_node_present() + assert result["found"] is True + + +# ============================================================================= +# check_srt_resolvable +# ============================================================================= + + +class TestCheckSrtResolvable: + def test_no_node(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: None) + result = check_srt_resolvable() + assert result["found"] is False + assert "node" in result["install_hint"].lower() + + def test_srt_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None) + mock_proc = MagicMock(returncode=0, stdout="/usr/lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js") + with patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + return_value=mock_proc, + ) as mock_run: + result = check_srt_resolvable() + assert result["found"] is True + assert "sandbox-runtime" in result["path"] + argv = mock_run.call_args[0][0] + assert argv[0] == "/usr/bin/node" + assert argv[1] == "-e" + + def test_srt_not_found(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None) + mock_proc = MagicMock(returncode=1, stdout="") + with patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + return_value=mock_proc, + ): + result = check_srt_resolvable() + assert result["found"] is False + assert "npm install" in result["install_hint"] + + def test_srt_timeout(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/node" if name == "node" else None) + with patch( + "aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.subprocess.run", + side_effect=subprocess.TimeoutExpired(cmd="node", timeout=10), + ): + result = check_srt_resolvable() + assert result["found"] is False + + +# ============================================================================= +# check_rg_present +# ============================================================================= + + +class TestCheckRgPresent: + def test_rg_on_path(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/rg" if name == "rg" else None) + result = check_rg_present() + assert result["found"] is True + assert result["path"] == "/usr/bin/rg" + + def test_rg_not_on_path_but_in_local_bin(self, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", lambda name: None) + fake_rg = tmp_path / ".local" / "bin" / "rg" + fake_rg.parent.mkdir(parents=True) + fake_rg.touch() + monkeypatch.setattr(Path, "home", lambda: tmp_path) + result = check_rg_present() + assert result["found"] is True + assert str(fake_rg) == result["path"] + + def test_rg_not_found(self, monkeypatch, tmp_path): + monkeypatch.setattr(shutil, "which", lambda name: None) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + result = check_rg_present() + assert result["found"] is False + + @pytest.mark.skipif(not shutil.which("rg"), reason="rg not installed") + def test_rg_live(self): + result = check_rg_present() + assert result["found"] is True + + +# ============================================================================= +# check_broker_alive +# ============================================================================= + + +class TestCheckBrokerAlive: + def test_no_repo_root_no_env(self, monkeypatch): + monkeypatch.delenv("AIPASS_HOME", raising=False) + monkeypatch.setattr(Path, "cwd", lambda: Path("/nonexistent")) + result = check_broker_alive(repo_root=None) + assert result["alive"] is False + + def test_socket_missing(self, tmp_path): + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + result = check_broker_alive(repo_root=tmp_path) + assert result["alive"] is False + assert "missing" in result["detail"] + + def test_socket_connect_success(self, tmp_path): + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + sock_path = ai_central / "drone_broker.sock" + + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(sock_path)) + server.listen(1) + try: + result = check_broker_alive(repo_root=tmp_path) + assert result["alive"] is True + assert "connected" in result["detail"] + finally: + server.close() + + def test_socket_connect_refused(self, tmp_path): + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + sock_path = ai_central / "drone_broker.sock" + sock_path.touch() + result = check_broker_alive(repo_root=tmp_path) + assert result["alive"] is False + assert "connect failed" in result["detail"] + + def test_repo_root_from_env(self, monkeypatch, tmp_path): + ai_central = tmp_path / ".ai_central" + ai_central.mkdir() + monkeypatch.setenv("AIPASS_HOME", str(tmp_path)) + result = check_broker_alive(repo_root=None) + assert result["alive"] is False + assert "missing" in result["detail"] + + +# ============================================================================= +# is_linux +# ============================================================================= + + +class TestIsLinux: + def test_linux(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "linux") + assert is_linux() is True + + def test_darwin(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "darwin") + assert is_linux() is False + + def test_win32(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.handlers.sandbox_check.sandbox_checker.sys.platform", "win32") + assert is_linux() is False + + +# ============================================================================= +# _check_sandbox (doctor integration) +# ============================================================================= + + +@pytest.fixture +def _stub_doctor_json(): + """Stub json_handler inside doctor.py too.""" + with patch("aipass.aipass.apps.modules.doctor.json_handler") as mock: + mock.log_operation = MagicMock() + yield mock + + +class TestCheckSandboxDoctor: + def test_non_linux_one_info_line(self, monkeypatch): + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.is_linux", + lambda: False, + ) + results = _check_sandbox() + assert len(results) == 1 + assert "Linux-only" in results[0].detail + assert results[0].glyph == GLYPH_PASS + + def test_flag_off_missing_prereq_is_warn(self, monkeypatch): + monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": False, "detail": "not found"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + for r in results: + assert r.glyph != GLYPH_FAIL, f"Flag OFF should not produce FAIL, got FAIL for {r.label}" + + def test_flag_on_missing_prereq_is_fail(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": False, "detail": "not found"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + fail_results = [r for r in results if r.glyph == GLYPH_FAIL] + assert len(fail_results) >= 4, f"Flag ON + missing prereqs should produce FAILs, got {len(fail_results)}" + + def test_flag_on_all_present_is_pass(self, monkeypatch): + monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1") + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_functional", + lambda: {"ok": True, "detail": "trivial sandbox succeeded", "sysctl_value": None}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": True, "path": "/usr/lib/srt/index.js", "install_hint": ""}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": True, "detail": "connected"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: Path("/tmp/fake")) + + results = _check_sandbox() + for r in results: + assert r.glyph == GLYPH_PASS, f"All present should be PASS, got {r.glyph} for {r.label}" + + def test_bwrap_functional_skipped_when_not_present(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": True, "path": "/x", "install_hint": ""}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": True, "detail": "ok"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + labels = [r.label for r in results] + assert "bwrap functional" not in labels + + def test_bwrap_functional_included_when_present(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_functional", + lambda: {"ok": True, "detail": "ok", "sysctl_value": None}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": True, "path": "/x", "install_hint": ""}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": True, "detail": "ok"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + labels = [r.label for r in results] + assert "bwrap functional" in labels + + def test_sysctl_in_detail_on_functional_fail(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": True, "raw_value": "1"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": True, "path": "/usr/bin/bwrap"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_functional", + lambda: {"ok": False, "detail": "exit 1: denied", "sysctl_value": "1"}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": True, "path": "/usr/bin/node"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": True, "path": "/x", "install_hint": ""}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": True, "path": "/usr/bin/rg"} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": True, "detail": "ok"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + func_result = [r for r in results if r.label == "bwrap functional"][0] + assert "apparmor_restrict_unprivileged_userns=1" in func_result.detail + + def test_inert_suffix_when_flag_off(self, monkeypatch): + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.is_linux", lambda: True) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_sandbox_flag", lambda: {"enabled": False, "raw_value": ""} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_bwrap_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_node_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_srt_resolvable", + lambda: {"found": False, "path": None, "install_hint": "npm install -g ..."}, + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_rg_present", lambda: {"found": False, "path": None} + ) + monkeypatch.setattr( + "aipass.aipass.apps.modules.doctor.check_broker_alive", + lambda repo_root=None: {"alive": False, "detail": "not found"}, + ) + monkeypatch.setattr("aipass.aipass.apps.modules.doctor.find_project_root", lambda p: None) + + results = _check_sandbox() + missing_results = [r for r in results if r.glyph == GLYPH_WARN] + for r in missing_results: + assert "inert" in r.detail or r.label == "sandbox flag", ( + f"Missing prereq {r.label} should show inert suffix" + ) diff --git a/src/aipass/devpulse/.seedgo/bypass.json b/src/aipass/devpulse/.seedgo/bypass.json index ea2de42c..0e1f5d00 100644 --- a/src/aipass/devpulse/.seedgo/bypass.json +++ b/src/aipass/devpulse/.seedgo/bypass.json @@ -84,6 +84,31 @@ "standard": "help_text", "file": "tools/hook_engine_poc/test_engine.py", "reason": "POC test harness — usage example in docstring." + }, + { + "standard": "debug_print", + "file": "tools/rm_shim/redteam_suite.py", + "reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py." + }, + { + "standard": "encapsulation", + "file": "tools/rm_shim/redteam_suite.py", + "reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler." + }, + { + "standard": "imports", + "file": "tools/rm_shim/redteam_suite.py", + "reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed." + }, + { + "standard": "help_text", + "file": "tools/rm_shim/redteam_suite.py", + "reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module." + }, + { + "standard": "documentation", + "file": "tools/rm_shim/redteam_suite.py", + "reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant." } ], "notes": { diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index bdf0705c..458d05b9 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -183,6 +183,67 @@ "standard": "trigger", "reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events." }, + { + "file": "tests/test_broker.py", + "standard": "architecture", + "reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it." + }, + { + "file": "tests/test_broker.py", + "standard": "encapsulation", + "reason": "Test file imports broker handlers directly to test their public interface. Unit tests require direct access to implementation components." + }, + { + "file": "tests/test_broker.py", + "standard": "trigger", + "reason": "Test file exercises .unlink() to clean up test symlinks — not a production file operation requiring trigger events." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "architecture", + "reason": "Acceptance test artifact — standalone demo script, not part of 3-layer production structure." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "encapsulation", + "reason": "Acceptance test imports handlers directly to verify broker daemon behavior end-to-end." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "documentation", + "reason": "Acceptance test script — main() is self-documenting via module docstring and inline comments." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "imports", + "reason": "Acceptance test script uses sys.path.insert to locate the package from the artifacts/ directory." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "help_text", + "reason": "Docstring run instruction shows how to invoke the script — not a production help text." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "meta", + "reason": "Acceptance test artifact — META blocks are for production source files." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "trigger", + "reason": "Acceptance test exercises .unlink() to clean up test symlinks — not production file operations." + }, + { + "file": "tests/test_broker.py", + "standard": "windows_compat", + "lines": [556], + "reason": "stat.S_IMODE() guarded by os.name != 'posix' skip at runtime. POSIX-only secret permission test." + }, + { + "file": "artifacts/broker_acceptance_test.py", + "standard": "unused_function", + "reason": "Standalone acceptance demo runner — helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)." + }, { "file": "CLAUDE.md", "standard": "architecture", diff --git a/src/aipass/drone/README.md b/src/aipass/drone/README.md index c7907051..e2a72f42 100644 --- a/src/aipass/drone/README.md +++ b/src/aipass/drone/README.md @@ -143,7 +143,8 @@ drone/ │ │ ├── registry.py # Registry query operations │ │ ├── commands.py # Custom command shortcut orchestrator │ │ ├── git_module.py # Git workflow (tier-based access, 16 commands) -│ │ └── scan.py # Branch command scanning +│ │ ├── scan.py # Branch command scanning +│ │ └── broker.py # Broker daemon orchestrator (sandbox delete) │ ├── handlers/ # Implementation details │ │ ├── executor.py # Safe subprocess execution (timeout, no shell) │ │ ├── exceptions.py # Exception hierarchy (10 exception types) @@ -153,6 +154,11 @@ drone/ │ │ ├── module_registry_handler.py # Module loading (internal + external) │ │ ├── generic_adapter.py # StringIO capture for external modules │ │ ├── routing_config.json # External module declarations +│ │ ├── broker/ +│ │ │ ├── daemon.py # Broker daemon (unix socket, openat2, audit) +│ │ │ ├── client.py # Broker client (inherited fd transport) +│ │ │ ├── path_resolver.py # openat2 RESOLVE_BENEATH path resolution +│ │ │ └── protocol.py # Typed JSON-line IPC (BrokerRequest/Response) │ │ ├── json/ │ │ │ └── json_handler.py # Structured operation logging │ │ ├── scanning/ @@ -187,7 +193,8 @@ drone/ │ └── hook_sounds_plugin.py.disabled ├── docs/ # Public documentation ├── docs.local/ # Investigation reports and policies -└── tests/ # 704 tests across 21 test files +├── artifacts/ # Live acceptance test scripts +└── tests/ # 807 tests across 22 test files ``` ### Routing Flow @@ -325,7 +332,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches ## Testing -704 tests across 21 test files, covering all layers: +807 tests across 22 test files, covering all layers: | Area | Files | Tests | |------|-------|-------| @@ -333,7 +340,8 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches | Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 | | Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 | | Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 | -| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 | +| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py`, `test_rm.py` | ~181 | +| Broker | `test_broker.py` | ~55 | | Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 | Run tests: `cd src/aipass/drone && python -m pytest tests/ -q` @@ -348,7 +356,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q` --- -**Seedgo:** 100% | **Tests:** 775 pass, 4 skip | **Last Updated:** 2026-06-07 +**Seedgo:** 100% | **Tests:** 830 pass, 4 skip | **Last Updated:** 2026-06-10 --- [← Back to AIPass](../../../README.md) diff --git a/src/aipass/drone/apps/handlers/broker/__init__.py b/src/aipass/drone/apps/handlers/broker/__init__.py new file mode 100644 index 00000000..4ffd030b --- /dev/null +++ b/src/aipass/drone/apps/handlers/broker/__init__.py @@ -0,0 +1,8 @@ +"""Broker handler package — privileged delete daemon for sandboxed agents.""" + +from .protocol import BrokerRequest as BrokerRequest # noqa: F401 +from .protocol import BrokerResponse as BrokerResponse # noqa: F401 +from .path_resolver import resolve_beneath as resolve_beneath # noqa: F401 +from .daemon import BrokerDaemon as BrokerDaemon # noqa: F401 +from .client import broker_delete as broker_delete # noqa: F401 +from .client import create_identified_connection as create_identified_connection # noqa: F401 diff --git a/src/aipass/drone/apps/handlers/broker/client.py b/src/aipass/drone/apps/handlers/broker/client.py new file mode 100644 index 00000000..700f61bc --- /dev/null +++ b/src/aipass/drone/apps/handlers/broker/client.py @@ -0,0 +1,154 @@ +# =================== AIPass ==================== +# Name: client.py +# Description: Broker client — sends delete requests over inherited fd +# Version: 2.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-10 +# ============================================= + +"""Broker client — sends delete requests over an inherited socket fd. + +When ``AIPASS_BROKER_FD`` is set, ``drone rm`` uses this client to send +delete requests to the out-of-sandbox broker daemon instead of calling +``Path.unlink`` directly. The fd was pre-opened by the launch wrapper +before the sandbox locked. + +Launcher contract (Phase 6a): + ``create_identified_connection()`` connects to the broker socket, + reads the per-start secret, computes the HMAC, sends the identify + preamble, and returns the authenticated socket. The caller passes + the socket's fd to the sandboxed child via AIPASS_BROKER_FD. +""" + +from __future__ import annotations + +import hashlib +import hmac as hmac_mod +import os +import socket +import uuid +from pathlib import Path + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse + +BROKER_FD_ENV = "AIPASS_BROKER_FD" + + +def is_sandboxed() -> bool: + """Return True if running inside a sandbox with a broker fd available.""" + return BROKER_FD_ENV in os.environ + + +def _get_broker_fd() -> int | None: + """Return the inherited broker socket fd, or None if not set.""" + raw = os.environ.get(BROKER_FD_ENV) + if raw is None: + return None + try: + fd = int(raw) + if fd < 0: + logger.warning("broker client: invalid fd %d", fd) + return None + return fd + except ValueError: + logger.warning("broker client: non-integer AIPASS_BROKER_FD=%s", raw) + return None + + +def create_identified_connection( + socket_path: str | Path, + secret_path: str | Path, + branch: str, +) -> socket.socket: + """Connect to the broker, authenticate via HMAC, return the identified socket. + + This is the launcher contract for dispatch_monitor. The returned + socket fd should be passed to the sandboxed child via AIPASS_BROKER_FD. + + Args: + socket_path: Path to the broker's unix socket. + secret_path: Path to the broker's per-start secret file (mode 0600). + branch: Branch name to identify as. + + Returns: + A connected, identified ``socket.socket``. + + Raises: + RuntimeError: If identification fails (bad HMAC, broker error). + OSError: If the socket or secret file cannot be accessed. + """ + secret = Path(secret_path).read_bytes() + mac = hmac_mod.new(secret, branch.encode(), hashlib.sha256).hexdigest() + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(str(socket_path)) + + req = BrokerRequest(op="identify", branch=branch, hmac=mac) + sock.sendall(req.to_bytes()) + + data = b"" + while b"\n" not in data: + chunk = sock.recv(4096) + if not chunk: + sock.close() + raise RuntimeError("Broker closed connection during identify") + data += chunk + + resp = BrokerResponse.from_bytes(data) + if not resp.ok: + sock.close() + raise RuntimeError(f"Broker identify failed: {resp.message}") + + logger.info("broker client: identified as %s", branch) + json_handler.log_operation("broker_identify", {"branch": branch}) + return sock + + +def broker_delete(path: str) -> tuple[bool, str]: + """Send a delete request to the broker over the inherited fd. + + Returns ``(success, message)`` matching the pattern in ``rm_handler.safe_delete``. + """ + fd = _get_broker_fd() + if fd is None: + return False, "Broker fd not available (AIPASS_BROKER_FD not set)" + + request_id = uuid.uuid4().hex[:8] + req = BrokerRequest(op="delete", path=path, request_id=request_id) + json_handler.log_operation( + "broker_delete_request", + {"path": path, "fd": fd, "request_id": request_id}, + ) + + try: + sock = socket.socket(fileno=fd) + sock.setblocking(True) + try: + sock.sendall(req.to_bytes()) + + data = b"" + while b"\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + + if not data.strip(): + logger.error("broker client: empty response from broker") + return False, "Broker returned empty response" + + resp = BrokerResponse.from_bytes(data) + logger.info( + "broker client: %s for %s: %s", + "ok" if resp.ok else "refused", + path, + resp.message, + ) + return resp.ok, resp.message + finally: + sock.detach() + except OSError as exc: + logger.error("broker client: socket error for %s: %s", path, exc) + return False, f"Broker communication failed: {exc}" diff --git a/src/aipass/drone/apps/handlers/broker/daemon.py b/src/aipass/drone/apps/handlers/broker/daemon.py new file mode 100644 index 00000000..1a9db1b4 --- /dev/null +++ b/src/aipass/drone/apps/handlers/broker/daemon.py @@ -0,0 +1,440 @@ +# =================== AIPass ==================== +# Name: daemon.py +# Description: Broker daemon — privileged deleter for sandboxed agents +# Version: 2.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-10 +# ============================================= + +"""Broker daemon — privileged deleter for sandboxed agents. + +A long-lived process that listens on a unix socket, accepts delete requests +from sandboxed ``drone rm`` clients, re-resolves paths via openat2 +RESOLVE_BENEATH (never trusting agent-supplied strings), applies +identity-bound allowlist policy, performs the delete, and audit-logs +every attempt. + +Identity model (Phase 6a): + The launcher pre-connects, authenticates with an HMAC derived from a + per-start secret, declares the branch identity, then passes the + connected fd to the sandboxed child. The child inherits an already- + identified connection. Connections that never identify get the + narrowest scope (/tmp only). +""" + +from __future__ import annotations + +import hashlib +import hmac as hmac_mod +import json +import secrets +import socket +import threading +import time +from pathlib import Path + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler +from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse +from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath + +_DEFAULT_SOCKET_DIR = ".ai_central" +_SOCKET_NAME = "drone_broker.sock" +_AUDIT_LOG_NAME = "drone_broker_audit.jsonl" +_SECRET_NAME = "broker_secret" + +_DENYLIST_DIRS = frozenset((".git", ".trinity", ".aipass", ".codex", ".agents")) + +_TMP_BASES = (Path("/tmp"), Path("/var/tmp")) + + +def _find_project_root() -> Path | None: + """Walk up from CWD to find *_REGISTRY.json; return its parent as project root.""" + import os + + 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 _default_socket_path() -> Path: + """Return the default broker socket path under the repo root.""" + root = _find_project_root() + if root is None: + return Path("/tmp") / _SOCKET_NAME + return root / _DEFAULT_SOCKET_DIR / _SOCKET_NAME + + +def _default_audit_path() -> Path: + """Return the default audit log path.""" + root = _find_project_root() + if root is None: + return Path("/tmp") / _AUDIT_LOG_NAME + return root / _DEFAULT_SOCKET_DIR / _AUDIT_LOG_NAME + + +def _default_secret_path() -> Path: + """Return the default secret path.""" + root = _find_project_root() + if root is None: + return Path("/tmp") / _SECRET_NAME + return root / _DEFAULT_SOCKET_DIR / _SECRET_NAME + + +class BrokerDaemon: + """Out-of-sandbox delete broker with identity-bound allowlist policy. + + Listens on a unix socket, optionally authenticates connections via + HMAC, then validates delete requests via openat2 path re-resolution, + identity-scoped allowlist, and a denylist backstop before performing + ``os.unlink`` / ``shutil.rmtree``. + + Identity scopes: + None (unidentified): /tmp, /var/tmp only. + Builder branch: /tmp, /var/tmp, + own tree ($REPO/src/aipass//). + devpulse: /tmp, /var/tmp, + anywhere under $REPO. + Denylist backstop (.git, .trinity, .aipass, .codex, .agents) always applies. + """ + + def __init__( + self, + repo_root: Path | None = None, + socket_path: Path | None = None, + audit_path: Path | None = None, + secret_path: Path | None = None, + ) -> None: + """Initialize the broker. + + Args: + repo_root: Project root directory. Auto-discovered if not set. + socket_path: Where to bind the unix socket. + audit_path: Where to write the JSONL audit log. + secret_path: Where to write the per-start HMAC secret. + """ + self._repo_root = repo_root.resolve() if repo_root else _find_project_root() + self.socket_path = socket_path or _default_socket_path() + self.audit_path = audit_path or _default_audit_path() + self._secret_path = secret_path or _default_secret_path() + self._secret: bytes = b"" + self._server: socket.socket | None = None + self._running = False + self._lock = threading.Lock() + json_handler.log_operation( + "broker_init", + { + "repo_root": str(self._repo_root), + "socket": str(self.socket_path), + }, + ) + + def _generate_secret(self) -> bytes: + """Generate a fresh HMAC secret, write to disk with mode 0600.""" + secret = secrets.token_bytes(32) + self._secret_path.parent.mkdir(parents=True, exist_ok=True) + self._secret_path.write_bytes(secret) + self._secret_path.chmod(0o600) + logger.info("broker: generated secret at %s", self._secret_path) + return secret + + def _audit(self, entry: dict) -> None: + """Append a JSON line to the audit log.""" + entry["timestamp"] = time.strftime("%Y-%m-%dT%H:%M:%S%z") + self.audit_path.parent.mkdir(parents=True, exist_ok=True) + with open(self.audit_path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry, separators=(",", ":")) + "\n") + + def _check_denylist(self, resolved: Path) -> str | None: + """Return a reason string if the resolved path hits the denylist.""" + for part in resolved.parts: + if part in _DENYLIST_DIRS: + return f"Protected directory: path is inside {part}/" + return None + + def _get_allowed_bases(self, identity: str | None) -> list[Path]: + """Return the allowed base directories for the given identity.""" + bases: list[Path] = list(_TMP_BASES) + if identity is None or self._repo_root is None: + return bases + if identity == "devpulse": + bases.append(self._repo_root) + return bases + branch_dir = self._repo_root / "src" / "aipass" / identity + if branch_dir.is_dir(): + bases.append(branch_dir) + return bases + + def _handle_identify(self, req: BrokerRequest) -> tuple[BrokerResponse, str | None]: + """Verify HMAC and bind identity to the connection.""" + audit_entry: dict = { + "op": "identify", + "branch": req.branch, + "request_id": req.request_id, + } + + if not req.branch or not req.hmac: + audit_entry.update(result="REFUSED", reason="missing branch or hmac") + self._audit(audit_entry) + return BrokerResponse( + ok=False, + message="Missing branch or hmac", + request_id=req.request_id, + error_code="IDENTIFY_INVALID", + ), None + + expected = hmac_mod.new(self._secret, req.branch.encode(), hashlib.sha256).hexdigest() + if not hmac_mod.compare_digest(expected, req.hmac): + audit_entry.update(result="REFUSED", reason="bad HMAC") + self._audit(audit_entry) + return BrokerResponse( + ok=False, + message="Authentication failed", + request_id=req.request_id, + error_code="IDENTIFY_FAILED", + ), None + + audit_entry.update(result="IDENTIFIED", identity=req.branch) + self._audit(audit_entry) + logger.info("broker: connection identified as %s", req.branch) + return BrokerResponse( + ok=True, + message=f"Identified as {req.branch}", + request_id=req.request_id, + ), req.branch + + def _handle_delete(self, req: BrokerRequest, identity: str | None) -> BrokerResponse: + """Process a single delete request with full re-resolution and identity scoping.""" + import shutil + + audit_entry: dict = { + "op": req.op, + "agent_path": req.path, + "request_id": req.request_id, + "identity": identity, + } + + allowed_bases = self._get_allowed_bases(identity) + + for base in allowed_bases: + agent_path = req.path + try: + candidate = Path(agent_path) + if candidate.is_absolute() and candidate.is_relative_to(base): + agent_path = str(candidate.relative_to(base)) + except (ValueError, TypeError) as exc: + logger.info("broker: path normalization skipped for %s: %s", agent_path, exc) + + try: + resolved = resolve_beneath(base, agent_path) + except OSError as exc: + logger.info("broker: base %s skipped for %s: %s", base, agent_path, exc) + continue + + # Prevent /tmp base from granting access to repo-scoped paths + if self._repo_root and base in _TMP_BASES and resolved.is_relative_to(self._repo_root): + logger.info("broker: %s is under repo root via /tmp — skipping", resolved) + continue + + if not resolved.is_relative_to(base): + continue + + deny_reason = self._check_denylist(resolved) + if deny_reason: + audit_entry.update( + result="REFUSED", + reason=deny_reason, + resolved=str(resolved), + base=str(base), + ) + self._audit(audit_entry) + logger.warning("broker: denied delete %s: %s", resolved, deny_reason) + return BrokerResponse( + ok=False, + message=deny_reason, + request_id=req.request_id, + error_code="DENYLIST", + ) + + if resolved == base: + reason = f"Refusing to delete root directory itself: {base}" + audit_entry.update( + result="REFUSED", + reason=reason, + resolved=str(resolved), + base=str(base), + ) + self._audit(audit_entry) + return BrokerResponse( + ok=False, + message=reason, + request_id=req.request_id, + error_code="ROOT_DELETE", + ) + + try: + if resolved.is_symlink(): + resolved.unlink() + elif resolved.is_dir(): + shutil.rmtree(resolved) + else: + resolved.unlink() + + audit_entry.update(result="DELETED", resolved=str(resolved), base=str(base)) + self._audit(audit_entry) + logger.info( + "broker: deleted %s (base=%s, identity=%s)", + resolved, + base, + identity, + ) + return BrokerResponse( + ok=True, + message=f"Deleted: {resolved}", + request_id=req.request_id, + ) + except OSError as exc: + audit_entry.update( + result="ERROR", + reason=str(exc), + resolved=str(resolved), + base=str(base), + ) + self._audit(audit_entry) + logger.error("broker: delete failed %s: %s", resolved, exc) + return BrokerResponse( + ok=False, + message=f"Delete failed: {exc}", + request_id=req.request_id, + error_code="OS_ERROR", + ) + + audit_entry.update(result="REFUSED", reason="Path not under any allowed base for this identity") + self._audit(audit_entry) + logger.warning("broker: no allowed base matched for %s (identity=%s)", req.path, identity) + return BrokerResponse( + ok=False, + message=f"Path not permitted for identity '{identity}': {req.path}", + request_id=req.request_id, + error_code="NO_BASE", + ) + + def _handle_connection(self, conn: socket.socket) -> None: + """Read messages in a loop, tracking per-connection identity.""" + identity: str | None = None + first_message_done = False + buffer = b"" + try: + while True: + while b"\n" not in buffer: + chunk = conn.recv(4096) + if not chunk: + return + buffer += chunk + + line, _, buffer = buffer.partition(b"\n") + if not line.strip(): + continue + + req = BrokerRequest.from_bytes(line + b"\n") + + if req.op == "identify": + if first_message_done: + self._audit( + { + "op": "identify", + "branch": req.branch, + "identity": identity, + "result": "REFUSED", + "reason": "identify after first message", + "request_id": req.request_id, + } + ) + resp = BrokerResponse( + ok=False, + message="Identify must be the first message", + request_id=req.request_id, + error_code="IDENTIFY_LATE", + ) + else: + resp, identity = self._handle_identify(req) + first_message_done = True + elif req.op == "delete": + first_message_done = True + resp = self._handle_delete(req, identity) + else: + first_message_done = True + resp = BrokerResponse( + ok=False, + message=f"Unknown operation: {req.op}", + request_id=req.request_id, + error_code="UNKNOWN_OP", + ) + + conn.sendall(resp.to_bytes()) + except Exception as exc: + logger.error("broker: connection error: %s", exc) + try: + err = BrokerResponse(ok=False, message=f"Internal error: {exc}", error_code="INTERNAL") + conn.sendall(err.to_bytes()) + except OSError as send_exc: + logger.warning("broker: failed to send error response: %s", send_exc) + finally: + conn.close() + + def start(self) -> None: + """Start the broker daemon (blocking). Use ``start_background`` for threaded.""" + self._secret = self._generate_secret() + + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + if self.socket_path.exists(): + self.socket_path.unlink() + + self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._server.bind(str(self.socket_path)) + self._server.listen(5) + self._server.settimeout(1.0) + self._running = True + + logger.info("broker: listening on %s", self.socket_path) + json_handler.log_operation("broker_start", {"socket": str(self.socket_path)}) + + while self._running: + try: + conn, _ = self._server.accept() + t = threading.Thread(target=self._handle_connection, args=(conn,), daemon=True) + t.start() + except socket.timeout: + logger.info("broker: accept poll tick") + continue + except OSError as exc: + if self._running: + logger.error("broker: accept error: %s", exc) + break + + def start_background(self) -> threading.Thread: + """Start the broker in a background thread. Returns the thread.""" + t = threading.Thread(target=self.start, daemon=True, name="drone-broker") + t.start() + return t + + def stop(self) -> None: + """Stop the broker daemon.""" + self._running = False + if self._server: + try: + self._server.close() + except OSError as exc: + logger.warning("broker: error closing server socket: %s", exc) + if self.socket_path.exists(): + try: + self.socket_path.unlink() + except OSError as exc: + logger.warning("broker: error removing socket file: %s", exc) + logger.info("broker: stopped") + json_handler.log_operation("broker_stop", {}) diff --git a/src/aipass/drone/apps/handlers/broker/path_resolver.py b/src/aipass/drone/apps/handlers/broker/path_resolver.py new file mode 100644 index 00000000..190b97d5 --- /dev/null +++ b/src/aipass/drone/apps/handlers/broker/path_resolver.py @@ -0,0 +1,139 @@ +# =================== AIPass ==================== +# Name: path_resolver.py +# Description: Kernel-safe path resolution via openat2 RESOLVE_BENEATH +# Version: 1.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-09 +# ============================================= + +"""Kernel-safe path resolution via openat2 RESOLVE_BENEATH. + +Re-resolves an agent-supplied path string server-side so the broker never +trusts the raw string. Uses Linux openat2(2) with RESOLVE_BENEATH | +RESOLVE_NO_SYMLINKS to guarantee the final target is strictly beneath an +allowed base directory and traverses no symlinks. + +Falls back to a pure-Python per-component walk (O_NOFOLLOW openat) when +openat2 is unavailable (non-Linux, older kernels). +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import os +import struct +import sys +from pathlib import Path + +from aipass.prax import logger +from aipass.drone.apps.handlers.json import json_handler + +RESOLVE_BENEATH = 0x08 +RESOLVE_NO_SYMLINKS = 0x04 +SYS_OPENAT2 = 437 +O_PATH = 0o010000000 +O_NOFOLLOW = 0o0400000 + +_OPEN_HOW_SIZE = 24 + + +def _openat2_available() -> bool: + """Check if the openat2 syscall is usable on this platform.""" + return sys.platform == "linux" and os.uname().machine == "x86_64" + + +def _openat2(dirfd: int, pathname: bytes, flags: int, resolve: int) -> int: + """Call openat2(2) via ctypes syscall. + + Returns an fd on success, raises OSError on failure. + """ + open_how = struct.pack("QQQ", flags, 0, resolve) + libc = ctypes.CDLL(ctypes.util.find_library("c"), use_errno=True) + result = libc.syscall( + ctypes.c_long(SYS_OPENAT2), + ctypes.c_int(dirfd), + ctypes.c_char_p(pathname), + ctypes.c_char_p(open_how), + ctypes.c_size_t(_OPEN_HOW_SIZE), + ) + if result < 0: + errno = ctypes.get_errno() + raise OSError(errno, os.strerror(errno), pathname.decode(errors="replace")) + return result + + +def resolve_beneath(base: Path, relpath: str) -> Path: + """Resolve *relpath* strictly beneath *base*, refusing escapes and symlinks. + + Uses openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS on Linux x86-64, + falls back to a per-component O_NOFOLLOW walk otherwise. + + Returns the resolved absolute path on success. + Raises OSError on traversal failure (escape, symlink, missing component). + """ + json_handler.log_operation("resolve_beneath", {"base": str(base), "relpath": relpath}) + + cleaned = os.path.normpath(relpath) + if cleaned.startswith("/") or cleaned.startswith(".."): + raise OSError(1, "Path escapes base via leading / or ..", relpath) + + parts = cleaned.split("/") + if ".." in parts: + raise OSError(1, "Path contains .. component", relpath) + + if _openat2_available(): + return _resolve_via_openat2(base, cleaned) + return _resolve_via_walk(base, parts) + + +def _resolve_via_openat2(base: Path, cleaned: str) -> Path: + """Resolve using the openat2 syscall with kernel-enforced containment.""" + dirfd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY) + try: + fd = _openat2( + dirfd, + cleaned.encode(), + O_PATH, + RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS, + ) + try: + resolved = Path(os.readlink(f"/proc/self/fd/{fd}")) + logger.info("resolve_beneath: openat2 resolved %s -> %s", cleaned, resolved) + return resolved + finally: + os.close(fd) + finally: + os.close(dirfd) + + +def _resolve_via_walk(base: Path, parts: list[str]) -> Path: + """Fallback: per-component walk using O_NOFOLLOW to block symlinks.""" + current_fd = os.open(str(base), os.O_RDONLY | os.O_DIRECTORY) + try: + for i, component in enumerate(parts): + if component in ("", "."): + continue + + is_last = i == len(parts) - 1 + flags = O_PATH | O_NOFOLLOW + if not is_last: + flags |= os.O_DIRECTORY + + try: + next_fd = os.open(component, flags, dir_fd=current_fd) + except OSError as exc: + raise OSError( + exc.errno, + f"Component '{component}' failed: {exc.strerror}", + "/".join(parts), + ) from exc + + os.close(current_fd) + current_fd = next_fd + + resolved = Path(os.readlink(f"/proc/self/fd/{current_fd}")) + logger.info("resolve_beneath: walk resolved %s -> %s", "/".join(parts), resolved) + return resolved + finally: + os.close(current_fd) diff --git a/src/aipass/drone/apps/handlers/broker/protocol.py b/src/aipass/drone/apps/handlers/broker/protocol.py new file mode 100644 index 00000000..048eb592 --- /dev/null +++ b/src/aipass/drone/apps/handlers/broker/protocol.py @@ -0,0 +1,76 @@ +# =================== AIPass ==================== +# Name: protocol.py +# Description: Typed protocol for broker IPC +# Version: 1.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-09 +# ============================================= + +"""Typed protocol for broker IPC. + +JSON-line messages over a unix socket. Extensible — only ``delete`` is +implemented now, but the envelope supports future operation types. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from typing import Literal + +from aipass.drone.apps.handlers.json import json_handler + + +@dataclass +class BrokerRequest: + """A request from sandboxed drone to the broker.""" + + op: Literal["delete", "identify"] + path: str = "" + request_id: str = "" + extra: dict[str, str] = field(default_factory=dict) + branch: str = "" + hmac: str = "" + + def to_bytes(self) -> bytes: + """Serialize to a newline-terminated JSON bytes line.""" + return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n" + + @classmethod + def from_bytes(cls, data: bytes) -> BrokerRequest: + """Deserialize from JSON bytes.""" + d = json.loads(data) + json_handler.log_operation("broker_request_parse", {"op": d.get("op", "")}) + return cls( + op=d["op"], + path=d.get("path", ""), + request_id=d.get("request_id", ""), + extra=d.get("extra", {}), + branch=d.get("branch", ""), + hmac=d.get("hmac", ""), + ) + + +@dataclass +class BrokerResponse: + """The broker's reply.""" + + ok: bool + message: str + request_id: str = "" + error_code: str = "" + + def to_bytes(self) -> bytes: + """Serialize to a newline-terminated JSON bytes line.""" + return json.dumps(asdict(self), separators=(",", ":")).encode() + b"\n" + + @classmethod + def from_bytes(cls, data: bytes) -> BrokerResponse: + """Deserialize from JSON bytes.""" + d = json.loads(data) + return cls( + ok=d["ok"], + message=d["message"], + request_id=d.get("request_id", ""), + error_code=d.get("error_code", ""), + ) diff --git a/src/aipass/drone/apps/handlers/rm_handler.py b/src/aipass/drone/apps/handlers/rm_handler.py index 0c7a2ab2..d745c55e 100644 --- a/src/aipass/drone/apps/handlers/rm_handler.py +++ b/src/aipass/drone/apps/handlers/rm_handler.py @@ -146,6 +146,11 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]: Returns a list of ``(original_path, success, message)`` tuples. Every path is checked independently; a refused path does not block others. """ + return _safe_delete_direct(paths) + + +def _safe_delete_direct(paths: list[str]) -> list[tuple[str, bool, str]]: + """Delete paths directly (unsandboxed mode — current behavior).""" roots = get_allowed_roots() if not roots: return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths] diff --git a/src/aipass/drone/apps/modules/broker.py b/src/aipass/drone/apps/modules/broker.py new file mode 100644 index 00000000..7b80ac3d --- /dev/null +++ b/src/aipass/drone/apps/modules/broker.py @@ -0,0 +1,119 @@ +# =================== AIPass ==================== +# Name: broker.py +# Description: Module orchestrator for the drone-broker daemon +# Version: 1.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-09 +# ============================================= + +"""Module orchestrator for the drone-broker daemon. + +Thin orchestrator that delegates to the broker handler package for +daemon lifecycle, path resolution, and client operations. +""" + +from __future__ import annotations + +from typing import Optional + +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.broker.daemon import BrokerDaemon + + +def handle_command(command: Optional[str] = None, args: Optional[list[str]] = None) -> bool: + """Route broker subcommands to handler functions.""" + 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("broker_command", {"command": command, "args": args}) + + if command == "start": + return _start_broker() + if command == "status": + return _show_status() + + logger.warning("broker: unknown command '%s'", command) + return False + + +def _start_broker() -> bool: + """Start the broker daemon in the foreground.""" + from aipass.drone.apps.handlers.broker.daemon import _find_project_root + + repo_root = _find_project_root() + if not repo_root: + logger.error("No project root found — cannot start broker") + return False + + console.print(f"[green]Starting broker (repo root: {repo_root})...[/green]") + + daemon = BrokerDaemon(repo_root=repo_root) + console.print(f"[green]Listening on {daemon.socket_path}[/green]") + console.print("[dim]Press Ctrl+C to stop[/dim]") + try: + daemon.start() + except KeyboardInterrupt: + logger.info("broker: interrupted, stopping") + daemon.stop() + console.print("[yellow]Broker stopped[/yellow]") + return True + + +def _show_status() -> bool: + """Show broker status.""" + from aipass.drone.apps.handlers.broker.daemon import _default_socket_path + + sock_path = _default_socket_path() + if sock_path.exists(): + console.print(f"[green]Broker socket exists:[/green] {sock_path}") + return True + console.print(f"No broker socket found at: {sock_path}") + return True + + +def print_introspection() -> None: + """Display module overview (no args).""" + try: + from aipass.cli.apps.modules.display import console as c + except ImportError: + logger.warning("CLI console not available, using fallback") + from rich.console import Console + + c = Console() + + c.print() + c.print("[bold cyan]broker Module[/bold cyan]") + c.print("[dim]Privileged delete daemon for sandboxed agents.[/dim]") + c.print() + c.print("[yellow]Connected Handlers:[/yellow]") + c.print(" [cyan]handlers/broker/[/cyan]") + c.print(" - [cyan]daemon.py[/cyan] [dim](BrokerDaemon — unix socket listener + openat2 resolver)[/dim]") + c.print(" - [cyan]client.py[/cyan] [dim](broker_delete — send requests over inherited fd)[/dim]") + c.print(" - [cyan]path_resolver.py[/cyan] [dim](resolve_beneath — openat2 RESOLVE_BENEATH)[/dim]") + c.print(" - [cyan]protocol.py[/cyan] [dim](BrokerRequest/BrokerResponse — typed JSON-line IPC)[/dim]") + c.print() + + +def print_help() -> None: + """Display help (--help flag).""" + console.print("Usage: drone broker ") + console.print() + console.print("Privileged delete daemon for sandboxed agents.") + console.print() + console.print("[bold]Commands:[/bold]") + console.print(" [green]start[/green] Start the broker daemon (foreground)") + console.print(" [green]status[/green] Check if the broker socket exists") + console.print() + console.print("[bold]Environment:[/bold]") + console.print(" AIPASS_BROKER_FD Inherited socket fd (set by launch wrapper)") + console.print() + console.print("[bold]Socket:[/bold] $REPO/.ai_central/drone_broker.sock") + console.print("[bold]Audit:[/bold] $REPO/.ai_central/drone_broker_audit.jsonl") diff --git a/src/aipass/drone/apps/modules/rm.py b/src/aipass/drone/apps/modules/rm.py index 572aff2f..e5fcbca6 100644 --- a/src/aipass/drone/apps/modules/rm.py +++ b/src/aipass/drone/apps/modules/rm.py @@ -20,6 +20,10 @@ from aipass.drone.apps.handlers.json import json_handler from aipass.drone.apps.handlers.rm_handler import ( safe_delete as _safe_delete, ) +from aipass.drone.apps.handlers.broker.client import ( + is_sandboxed as _is_sandboxed, + broker_delete as _broker_delete, +) DRONE_MODULE = { "name": "rm", @@ -32,8 +36,16 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]: """Delete paths with containment checks. Returns list of ``(original_path, success, message)`` tuples. + When sandboxed (AIPASS_BROKER_FD set), routes through the broker daemon. """ logger.info("rm: requested deletion of %d path(s)", len(paths)) + if _is_sandboxed(): + json_handler.log_operation("rm_broker", {"paths": paths}) + results: list[tuple[str, bool, str]] = [] + for path_str in paths: + ok, message = _broker_delete(path_str) + results.append((path_str, ok, message)) + return results return _safe_delete(paths) diff --git a/src/aipass/drone/tests/test_broker.py b/src/aipass/drone/tests/test_broker.py new file mode 100644 index 00000000..4e81d51b --- /dev/null +++ b/src/aipass/drone/tests/test_broker.py @@ -0,0 +1,853 @@ +# =================== AIPass ==================== +# Name: test_broker.py +# Description: Tests for the drone-broker daemon, identity, and allowlist +# Version: 2.0.0 +# Created: 2026-06-09 +# Modified: 2026-06-10 +# ============================================= + +"""Tests for the drone-broker daemon (Phase 3 + Phase 6a FPLAN-0250). + +Covers: protocol serialization, path resolution (openat2 + walk fallback), +daemon accept/delete/refuse/audit, identity handshake (HMAC), allowlist +policy (identity-scoped), denylist backstop, confused-deputy attacks, +client broker_delete / create_identified_connection, and rm broker routing. +""" + +from __future__ import annotations + +import hashlib +import hmac as hmac_mod +import json +import socket +import os +import stat +import time +from pathlib import Path + +import pytest + +from aipass.drone.apps.handlers.broker.protocol import BrokerRequest, BrokerResponse +from aipass.drone.apps.handlers.broker.path_resolver import resolve_beneath +from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon +from aipass.drone.apps.handlers.broker.client import ( + broker_delete, + create_identified_connection, + is_sandboxed, + BROKER_FD_ENV, +) +from aipass.drone.apps.handlers.json import json_handler + + +json_handler.log_operation("test_broker_load", {}) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _recv_response(sock: socket.socket) -> BrokerResponse: + """Read a single newline-terminated response from a socket.""" + data = b"" + while b"\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + return BrokerResponse.from_bytes(data) + + +def _send_raw(sock_path: Path, req: BrokerRequest) -> BrokerResponse: + """Send a request on a fresh (unidentified) connection, return response.""" + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(sock_path)) + try: + client.sendall(req.to_bytes()) + return _recv_response(client) + finally: + client.close() + + +def _send_identified(broker: BrokerDaemon, branch: str, req: BrokerRequest) -> BrokerResponse: + """Connect, identify, send request, return the delete response.""" + sock = create_identified_connection(broker.socket_path, broker._secret_path, branch) + try: + sock.sendall(req.to_bytes()) + return _recv_response(sock) + finally: + sock.close() + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def repo_root(tmp_path: Path) -> Path: + """Set up a mock repo root with branch directories.""" + root = tmp_path / "repo" + root.mkdir() + branch = root / "src" / "aipass" / "testbranch" + branch.mkdir(parents=True) + (branch / "deleteme.txt").write_text("delete me", encoding="utf-8") + (branch / "subdir").mkdir() + (branch / "subdir" / "nested.txt").write_text("nested", encoding="utf-8") + (branch / ".git").mkdir() + (branch / ".git" / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8") + sibling = root / "src" / "aipass" / "sibling" + sibling.mkdir(parents=True) + (sibling / "important.txt").write_text("don't delete", encoding="utf-8") + return root + + +@pytest.fixture() +def broker(tmp_path: Path, repo_root: Path) -> BrokerDaemon: + """Create a broker instance with a temp socket and audit log.""" + sock_path = tmp_path / "test_broker.sock" + audit_path = tmp_path / "test_audit.jsonl" + secret_path = tmp_path / "test_secret" + return BrokerDaemon( + repo_root=repo_root, + socket_path=sock_path, + audit_path=audit_path, + secret_path=secret_path, + ) + + +@pytest.fixture() +def running_broker(broker: BrokerDaemon): + """Start a broker in background, yield it, stop on teardown.""" + t = broker.start_background() + time.sleep(0.15) + yield broker + broker.stop() + t.join(timeout=3) + + +# --------------------------------------------------------------------------- +# Protocol tests +# --------------------------------------------------------------------------- + + +class TestProtocol: + """Test BrokerRequest/BrokerResponse serialization.""" + + def test_request_roundtrip(self) -> None: + """Request serializes and deserializes correctly.""" + req = BrokerRequest(op="delete", path="foo/bar.txt", request_id="abc123") + data = req.to_bytes() + assert data.endswith(b"\n") + parsed = BrokerRequest.from_bytes(data) + assert parsed.op == "delete" + assert parsed.path == "foo/bar.txt" + assert parsed.request_id == "abc123" + + def test_response_roundtrip(self) -> None: + """Response serializes and deserializes correctly.""" + resp = BrokerResponse(ok=True, message="Deleted", request_id="abc") + data = resp.to_bytes() + parsed = BrokerResponse.from_bytes(data) + assert parsed.ok is True + assert parsed.message == "Deleted" + + def test_request_extra_fields(self) -> None: + """Extra fields survive roundtrip.""" + req = BrokerRequest(op="delete", path="x", extra={"key": "val"}) + parsed = BrokerRequest.from_bytes(req.to_bytes()) + assert parsed.extra == {"key": "val"} + + def test_response_error_code(self) -> None: + """Error code field survives roundtrip.""" + resp = BrokerResponse(ok=False, message="denied", error_code="DENYLIST") + parsed = BrokerResponse.from_bytes(resp.to_bytes()) + assert parsed.error_code == "DENYLIST" + + def test_identify_request_roundtrip(self) -> None: + """Identify request with branch/hmac survives roundtrip.""" + req = BrokerRequest(op="identify", branch="testbranch", hmac="abc123", request_id="id1") + parsed = BrokerRequest.from_bytes(req.to_bytes()) + assert parsed.op == "identify" + assert parsed.branch == "testbranch" + assert parsed.hmac == "abc123" + + def test_delete_request_path_defaults_empty(self) -> None: + """Delete request path defaults to empty string when missing.""" + data = json.dumps({"op": "delete"}).encode() + b"\n" + parsed = BrokerRequest.from_bytes(data) + assert parsed.path == "" + assert parsed.branch == "" + + +# --------------------------------------------------------------------------- +# Path resolver tests +# --------------------------------------------------------------------------- + + +class TestPathResolver: + """Test resolve_beneath path resolution.""" + + def test_resolve_existing_file(self, repo_root: Path) -> None: + """Resolves a valid file path beneath the base.""" + base = repo_root / "src" / "aipass" / "testbranch" + result = resolve_beneath(base, "deleteme.txt") + assert result == (base / "deleteme.txt").resolve() + + def test_resolve_nested(self, repo_root: Path) -> None: + """Resolves a nested path.""" + base = repo_root / "src" / "aipass" / "testbranch" + result = resolve_beneath(base, "subdir/nested.txt") + assert result == (base / "subdir" / "nested.txt").resolve() + + def test_reject_dotdot_escape(self, repo_root: Path) -> None: + """Refuses paths with .. components.""" + base = repo_root / "src" / "aipass" / "testbranch" + with pytest.raises(OSError, match="\\.\\."): + resolve_beneath(base, "../escape.txt") + + def test_reject_dotdot_middle(self, repo_root: Path) -> None: + """Refuses .. in the middle of a path.""" + base = repo_root / "src" / "aipass" / "testbranch" + with pytest.raises(OSError, match="\\.\\."): + resolve_beneath(base, "subdir/../../escape.txt") + + def test_reject_absolute(self, repo_root: Path) -> None: + """Refuses absolute paths.""" + base = repo_root / "src" / "aipass" / "testbranch" + with pytest.raises(OSError, match="leading /"): + resolve_beneath(base, "/etc/passwd") + + def test_reject_symlink(self, repo_root: Path) -> None: + """Refuses paths through symlinks.""" + base = repo_root / "src" / "aipass" / "testbranch" + link = base / "link" + link.symlink_to("/tmp") + try: + with pytest.raises(OSError): + resolve_beneath(base, "link/something") + finally: + link.unlink() + + def test_nonexistent_path(self, repo_root: Path) -> None: + """Raises OSError for nonexistent paths.""" + base = repo_root / "src" / "aipass" / "testbranch" + with pytest.raises(OSError): + resolve_beneath(base, "does_not_exist.txt") + + +# --------------------------------------------------------------------------- +# Daemon mechanism tests (identified connection) +# --------------------------------------------------------------------------- + + +class TestBrokerDaemon: + """Test the broker daemon accept/delete/refuse logic with an identified connection.""" + + def test_delete_allowed_file(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Broker deletes an allowed file under the identified branch tree.""" + target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt" + assert target.exists() + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="deleteme.txt", request_id="t1"), + ) + assert resp.ok is True + assert "Deleted" in resp.message + assert not target.exists() + + audit = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + last = json.loads(audit[-1]) + assert last["result"] == "DELETED" + assert last["identity"] == "testbranch" + + def test_delete_nested_file(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Broker deletes a nested file.""" + target = repo_root / "src" / "aipass" / "testbranch" / "subdir" / "nested.txt" + assert target.exists() + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="subdir/nested.txt", request_id="t2"), + ) + assert resp.ok is True + assert not target.exists() + + def test_refuse_protected_git(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Broker refuses deletion inside .git (denylist backstop).""" + target = repo_root / "src" / "aipass" / "testbranch" / ".git" / "HEAD" + assert target.exists() + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path=".git/HEAD", request_id="t3"), + ) + assert resp.ok is False + assert resp.error_code == "DENYLIST" + assert target.exists() + + def test_refuse_dotdot_escape(self, running_broker: BrokerDaemon) -> None: + """Broker refuses confused-deputy .. escape.""" + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="../../../etc/passwd", request_id="t4"), + ) + assert resp.ok is False + + def test_refuse_symlink_escape(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Broker refuses confused-deputy symlink escape.""" + base = repo_root / "src" / "aipass" / "testbranch" + link = base / "evil_link" + link.symlink_to("/tmp") + try: + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="evil_link/target", request_id="t5"), + ) + assert resp.ok is False + finally: + link.unlink() + + def test_refuse_nonexistent(self, running_broker: BrokerDaemon) -> None: + """Broker refuses deletion of nonexistent paths.""" + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="no_such_file.xyz", request_id="t6"), + ) + assert resp.ok is False + + def test_refuse_root_delete(self, running_broker: BrokerDaemon) -> None: + """Broker refuses deleting the base directory itself.""" + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path=".", request_id="t7"), + ) + assert resp.ok is False + + def test_unknown_operation(self, running_broker: BrokerDaemon) -> None: + """Broker refuses unknown operation types.""" + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + bad_req = json.dumps({"op": "chmod", "path": "x"}).encode() + b"\n" + client.sendall(bad_req) + resp = _recv_response(client) + assert resp.ok is False + assert resp.error_code == "UNKNOWN_OP" + finally: + client.close() + + def test_audit_log_written(self, running_broker: BrokerDaemon) -> None: + """Every request writes an audit entry with identity.""" + _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="deleteme.txt", request_id="audit1"), + ) + assert running_broker.audit_path.exists() + lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + assert len(lines) >= 1 + entry = json.loads(lines[-1]) + assert "timestamp" in entry + assert "identity" in entry + + def test_stop_cleans_socket(self, broker: BrokerDaemon) -> None: + """Stopping the broker removes the socket file.""" + t = broker.start_background() + time.sleep(0.15) + assert broker.socket_path.exists() + broker.stop() + t.join(timeout=3) + assert not broker.socket_path.exists() + + +# --------------------------------------------------------------------------- +# Denylist tests +# --------------------------------------------------------------------------- + + +class TestDenylist: + """Test that all protected directories are denied even with identity.""" + + @pytest.mark.parametrize("dirname", [".git", ".trinity", ".aipass", ".codex", ".agents"]) + def test_protected_dirs_refused( + self, + running_broker: BrokerDaemon, + repo_root: Path, + dirname: str, + ) -> None: + """Each protected directory is refused regardless of identity.""" + branch_dir = repo_root / "src" / "aipass" / "testbranch" + protected_dir = branch_dir / dirname + protected_dir.mkdir(exist_ok=True) + (protected_dir / "file.txt").write_text("protected", encoding="utf-8") + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest( + op="delete", + path=f"{dirname}/file.txt", + request_id=f"deny_{dirname}", + ), + ) + assert resp.ok is False + assert resp.error_code == "DENYLIST" + assert (protected_dir / "file.txt").exists() + + +# --------------------------------------------------------------------------- +# Identity handshake tests +# --------------------------------------------------------------------------- + + +class TestIdentity: + """Test the HMAC-based identity handshake.""" + + def test_good_hmac_identifies(self, running_broker: BrokerDaemon) -> None: + """Valid HMAC produces a successful identify response.""" + secret = running_broker._secret_path.read_bytes() + mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest() + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + req = BrokerRequest( + op="identify", + branch="testbranch", + hmac=mac, + request_id="id1", + ) + client.sendall(req.to_bytes()) + resp = _recv_response(client) + assert resp.ok is True + assert "Identified" in resp.message + finally: + client.close() + + def test_bad_hmac_refused(self, running_broker: BrokerDaemon) -> None: + """Invalid HMAC is refused and audited.""" + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + req = BrokerRequest( + op="identify", + branch="testbranch", + hmac="deadbeef", + request_id="id2", + ) + client.sendall(req.to_bytes()) + resp = _recv_response(client) + assert resp.ok is False + assert resp.error_code == "IDENTIFY_FAILED" + + audit = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + entry = json.loads(audit[-1]) + assert entry["result"] == "REFUSED" + assert entry["reason"] == "bad HMAC" + finally: + client.close() + + def test_bad_hmac_connection_still_usable(self, running_broker: BrokerDaemon, tmp_path: Path) -> None: + """After a bad HMAC, connection remains at unidentified scope.""" + tmp_file = tmp_path / "unid_delete.txt" + tmp_file.write_text("unidentified", encoding="utf-8") + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + req = BrokerRequest(op="identify", branch="x", hmac="bad", request_id="id3") + client.sendall(req.to_bytes()) + resp = _recv_response(client) + assert resp.ok is False + + del_req = BrokerRequest(op="delete", path=str(tmp_file), request_id="id3del") + client.sendall(del_req.to_bytes()) + del_resp = _recv_response(client) + assert del_resp.ok is True + assert not tmp_file.exists() + finally: + client.close() + + def test_second_identify_refused(self, running_broker: BrokerDaemon) -> None: + """A second identify on the same connection is refused.""" + secret = running_broker._secret_path.read_bytes() + mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest() + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + req = BrokerRequest( + op="identify", + branch="testbranch", + hmac=mac, + request_id="first", + ) + client.sendall(req.to_bytes()) + resp1 = _recv_response(client) + assert resp1.ok is True + + req2 = BrokerRequest( + op="identify", + branch="testbranch", + hmac=mac, + request_id="second", + ) + client.sendall(req2.to_bytes()) + resp2 = _recv_response(client) + assert resp2.ok is False + assert resp2.error_code == "IDENTIFY_LATE" + finally: + client.close() + + def test_identify_after_delete_refused(self, running_broker: BrokerDaemon, tmp_path: Path) -> None: + """Identify after a delete (non-first message) is refused.""" + tmp_file = tmp_path / "early_delete.txt" + tmp_file.write_text("early", encoding="utf-8") + + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + del_req = BrokerRequest(op="delete", path=str(tmp_file), request_id="del_first") + client.sendall(del_req.to_bytes()) + _recv_response(client) + + secret = running_broker._secret_path.read_bytes() + mac = hmac_mod.new(secret, b"testbranch", hashlib.sha256).hexdigest() + id_req = BrokerRequest( + op="identify", + branch="testbranch", + hmac=mac, + request_id="late_id", + ) + client.sendall(id_req.to_bytes()) + resp = _recv_response(client) + assert resp.ok is False + assert resp.error_code == "IDENTIFY_LATE" + finally: + client.close() + + def test_missing_branch_refused(self, running_broker: BrokerDaemon) -> None: + """Identify without branch field is refused.""" + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.connect(str(running_broker.socket_path)) + try: + req = BrokerRequest(op="identify", branch="", hmac="x", request_id="no_branch") + client.sendall(req.to_bytes()) + resp = _recv_response(client) + assert resp.ok is False + assert resp.error_code == "IDENTIFY_INVALID" + finally: + client.close() + + def test_secret_file_0600(self, running_broker: BrokerDaemon) -> None: + """Secret file is created with mode 0600.""" + if os.name != "posix": + pytest.skip("POSIX permission check") + mode = running_broker._secret_path.stat().st_mode + assert stat.S_IMODE(mode) == 0o600 # noqa: windows_compat + + def test_secret_changes_across_restarts(self, tmp_path: Path, repo_root: Path) -> None: + """Secret is regenerated on each daemon start.""" + sock1 = tmp_path / "s1.sock" + sock2 = tmp_path / "s2.sock" + secret_path = tmp_path / "secret" + audit = tmp_path / "audit.jsonl" + + d1 = BrokerDaemon( + repo_root=repo_root, + socket_path=sock1, + audit_path=audit, + secret_path=secret_path, + ) + t1 = d1.start_background() + time.sleep(0.15) + secret1 = secret_path.read_bytes() + d1.stop() + t1.join(timeout=3) + + d2 = BrokerDaemon( + repo_root=repo_root, + socket_path=sock2, + audit_path=audit, + secret_path=secret_path, + ) + t2 = d2.start_background() + time.sleep(0.15) + secret2 = secret_path.read_bytes() + d2.stop() + t2.join(timeout=3) + + assert secret1 != secret2 + + +# --------------------------------------------------------------------------- +# Allowlist policy tests +# --------------------------------------------------------------------------- + + +class TestAllowlistPolicy: + """Test identity-scoped allowlist policy.""" + + def test_unidentified_tmp_allowed(self, running_broker: BrokerDaemon, tmp_path: Path) -> None: + """Unidentified connections can delete under /tmp.""" + target = tmp_path / "unid_tmp.txt" + target.write_text("tmp file", encoding="utf-8") + + resp = _send_raw( + running_broker.socket_path, + BrokerRequest(op="delete", path=str(target), request_id="unid_tmp"), + ) + assert resp.ok is True + assert not target.exists() + + def test_unidentified_repo_refused(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Unidentified connections cannot delete repo paths.""" + target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt" + assert target.exists() + + resp = _send_raw( + running_broker.socket_path, + BrokerRequest(op="delete", path=str(target), request_id="unid_repo"), + ) + assert resp.ok is False + assert resp.error_code == "NO_BASE" + assert target.exists() + + def test_builder_own_tree_allowed(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Builder identity can delete files in its own branch tree.""" + target = repo_root / "src" / "aipass" / "testbranch" / "deleteme.txt" + assert target.exists() + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="deleteme.txt", request_id="own_tree"), + ) + assert resp.ok is True + assert not target.exists() + + def test_builder_sibling_refused(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Builder identity cannot delete files in a sibling branch tree.""" + target = repo_root / "src" / "aipass" / "sibling" / "important.txt" + assert target.exists() + + resp = _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path=str(target), request_id="sibling"), + ) + assert resp.ok is False + assert resp.error_code == "NO_BASE" + assert target.exists() + + def test_devpulse_sibling_allowed(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """devpulse identity can delete files in any branch tree.""" + junk = repo_root / "src" / "aipass" / "sibling" / "junk.txt" + junk.write_text("junk", encoding="utf-8") + + resp = _send_identified( + running_broker, + "devpulse", + BrokerRequest(op="delete", path=str(junk), request_id="dp_sib"), + ) + assert resp.ok is True + assert not junk.exists() + + def test_devpulse_denylist_still_blocks(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """devpulse cannot delete paths under denylist dirs (backstop).""" + target = repo_root / "src" / "aipass" / "testbranch" / ".git" / "HEAD" + assert target.exists() + + resp = _send_identified( + running_broker, + "devpulse", + BrokerRequest(op="delete", path=str(target), request_id="dp_deny"), + ) + assert resp.ok is False + assert resp.error_code == "DENYLIST" + assert target.exists() + + @pytest.mark.parametrize("dirname", [".git", ".trinity"]) + def test_devpulse_denylist_backstop( + self, + running_broker: BrokerDaemon, + repo_root: Path, + dirname: str, + ) -> None: + """devpulse is blocked by denylist backstop on protected dirs.""" + protected = repo_root / dirname + protected.mkdir(exist_ok=True) + (protected / "config").write_text("sacred", encoding="utf-8") + + resp = _send_identified( + running_broker, + "devpulse", + BrokerRequest( + op="delete", + path=str(protected / "config"), + request_id=f"dp_deny_{dirname}", + ), + ) + assert resp.ok is False + assert resp.error_code == "DENYLIST" + assert (protected / "config").exists() + + def test_audit_carries_identity_on_grant(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Audit entries for grants include the identity.""" + _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path="deleteme.txt", request_id="aud_grant"), + ) + lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"] + last = delete_entries[-1] + assert last["identity"] == "testbranch" + assert last["result"] == "DELETED" + + def test_audit_carries_identity_on_refusal(self, running_broker: BrokerDaemon, repo_root: Path) -> None: + """Audit entries for refusals include the identity.""" + _send_identified( + running_broker, + "testbranch", + BrokerRequest(op="delete", path=".git/HEAD", request_id="aud_refuse"), + ) + lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"] + last = delete_entries[-1] + assert last["identity"] == "testbranch" + assert last["result"] == "REFUSED" + + def test_audit_null_identity_for_unidentified(self, running_broker: BrokerDaemon, tmp_path: Path) -> None: + """Audit entries for unidentified connections have null identity.""" + target = tmp_path / "aud_unid.txt" + target.write_text("x", encoding="utf-8") + + _send_raw( + running_broker.socket_path, + BrokerRequest(op="delete", path=str(target), request_id="aud_unid"), + ) + lines = running_broker.audit_path.read_text(encoding="utf-8").strip().split("\n") + delete_entries = [json.loads(raw) for raw in lines if json.loads(raw).get("op") == "delete"] + last = delete_entries[-1] + assert last["identity"] is None + + +# --------------------------------------------------------------------------- +# Client tests +# --------------------------------------------------------------------------- + + +class TestClient: + """Test the broker client (sandboxed drone rm path).""" + + def test_is_sandboxed_false(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Not sandboxed when env var is absent.""" + monkeypatch.delenv(BROKER_FD_ENV, raising=False) + assert is_sandboxed() is False + + def test_is_sandboxed_true(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Sandboxed when env var is present.""" + monkeypatch.setenv(BROKER_FD_ENV, "3") + assert is_sandboxed() is True + + def test_broker_delete_no_fd(self, monkeypatch: pytest.MonkeyPatch) -> None: + """broker_delete fails gracefully without fd.""" + monkeypatch.delenv(BROKER_FD_ENV, raising=False) + ok, msg = broker_delete("/tmp/test") + assert ok is False + assert "not set" in msg + + def test_broker_delete_via_socket( + self, + running_broker: BrokerDaemon, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """broker_delete sends request over a real socket fd (unidentified, /tmp).""" + target = tmp_path / "client_delete.txt" + target.write_text("delete me", encoding="utf-8") + + client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client_sock.connect(str(running_broker.socket_path)) + fd = client_sock.fileno() + monkeypatch.setenv(BROKER_FD_ENV, str(fd)) + + ok, msg = broker_delete(str(target)) + assert ok is True + assert "Deleted" in msg + assert not target.exists() + + client_sock.close() + + def test_create_identified_connection(self, running_broker: BrokerDaemon) -> None: + """create_identified_connection returns an authenticated socket.""" + sock = create_identified_connection( + running_broker.socket_path, + running_broker._secret_path, + "testbranch", + ) + try: + assert sock.fileno() >= 0 + finally: + sock.close() + + def test_create_identified_connection_bad_secret(self, running_broker: BrokerDaemon, tmp_path: Path) -> None: + """create_identified_connection raises on bad secret.""" + bad_secret = tmp_path / "bad_secret" + bad_secret.write_bytes(b"wrong" * 8) + + with pytest.raises(RuntimeError, match="identify failed"): + create_identified_connection(running_broker.socket_path, bad_secret, "testbranch") + + +# --------------------------------------------------------------------------- +# rm_handler integration tests +# --------------------------------------------------------------------------- + + +class TestRmBrokerRouting: + """Test that rm module routes through broker when sandboxed.""" + + def test_unsandboxed_uses_direct(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Without AIPASS_BROKER_FD, rm uses direct delete.""" + monkeypatch.delenv(BROKER_FD_ENV, raising=False) + target = tmp_path / "direct_delete.txt" + target.write_text("test", encoding="utf-8") + + from aipass.drone.apps.modules.rm import safe_delete + + results = safe_delete([str(target)]) + assert results[0][1] is True + assert not target.exists() + + def test_sandboxed_uses_broker( + self, + running_broker: BrokerDaemon, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """With AIPASS_BROKER_FD, rm routes through broker (unidentified, /tmp).""" + target = tmp_path / "broker_rm.txt" + target.write_text("delete me", encoding="utf-8") + + client_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client_sock.connect(str(running_broker.socket_path)) + monkeypatch.setenv(BROKER_FD_ENV, str(client_sock.fileno())) + + from aipass.drone.apps.modules.rm import safe_delete + + results = safe_delete([str(target)]) + assert results[0][1] is True + assert not target.exists() + + client_sock.close() diff --git a/src/aipass/hooks/.seedgo/bypass.json b/src/aipass/hooks/.seedgo/bypass.json index d08e1023..4f3eb02d 100644 --- a/src/aipass/hooks/.seedgo/bypass.json +++ b/src/aipass/hooks/.seedgo/bypass.json @@ -4,220 +4,875 @@ "created": "2026-05-18", "updated": "2026-05-28", "description": "Standards bypass configuration for this branch", - "audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported — seedgo's dead_code/unused_function checks are false positives for this architecture." + "audit_context": "DPLAN-0191: Full ownership hardening. All handler wiring verified against .aipass/hooks.json + engine.jsonl firing evidence. Dynamic dispatch via engine._run_handler (importlib.import_module + getattr) means handlers are never statically imported \u2014 seedgo's dead_code/unused_function checks are false positives for this architecture." }, "bypass": [ - {"standard": "dead_code", "reason": "All 15 handler files under apps/handlers/ are invoked dynamically by engine._run_handler (engine.py:60-66) via importlib.import_module + getattr on handler path strings from .aipass/hooks.json. They are never statically imported by design. Each handler verified wired in hooks.json AND fired in engine.jsonl (DPLAN-0191). Follow-up for @seedgo: teach dead_code checker about importlib dynamic dispatch patterns."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "dead_code", "reason": "Bridge called externally by provider settings subprocess — no internal import. Verified wired in ~/.claude/settings.json hook entries."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "unused_function", "reason": "main() called as subprocess entry point from provider settings — never statically imported."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "handlers", "reason": "Bridges import engine module by design — that is their entire purpose."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "json_structure", "reason": "Thin entry point, no JSON operations to log."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "architecture", "reason": "Bridge importing engine module is its architectural purpose."}, - {"file": "apps/handlers/bridges/claude.py", "standard": "imports", "reason": "Bridge imports engine module by design — sole purpose."}, - - {"file": "apps/handlers/prompt/identity.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' — not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl."}, - {"file": "apps/handlers/prompt/identity.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 (UserPromptSubmit.identity_injector)."}, - {"file": "apps/handlers/prompt/identity.py", "standard": "json_structure", "reason": "Uses stdlib json.loads to read passport.json — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/prompt/branch_loader.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl."}, - {"file": "apps/handlers/prompt/branch_loader.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 (UserPromptSubmit.branch_prompt)."}, - {"file": "apps/handlers/prompt/branch_loader.py", "standard": "json_structure", "reason": "No JSON operations — reads markdown files and outputs text."}, - - {"file": "apps/handlers/prompt/global_loader.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.global_loader.handle' — not statically imported by design. Verified wired in UserPromptSubmit.global_prompt + fires in engine.jsonl."}, - {"file": "apps/handlers/prompt/global_loader.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 (UserPromptSubmit.global_prompt)."}, - {"file": "apps/handlers/prompt/global_loader.py", "standard": "json_structure", "reason": "No JSON operations — reads markdown file and outputs text."}, - - {"file": "apps/handlers/security/edit_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' — not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl."}, - {"file": "apps/handlers/security/edit_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.pre_edit_gate)."}, - {"file": "apps/handlers/security/edit_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/git_gate.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' — not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl."}, - {"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."}, - {"file": "apps/handlers/security/subagent_gate.py", "standard": "open_encoding", "reason": "NamedTemporaryFile creates binary wav for Piper TTS — encoding not applicable to binary audio."}, - - {"file": "apps/handlers/lifecycle/auto_fix.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' — not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl."}, - {"file": "apps/handlers/lifecycle/auto_fix.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 (PostToolUse.auto_fix_diagnostics)."}, - {"file": "apps/handlers/lifecycle/auto_fix.py", "standard": "json_structure", "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' — not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl."}, - {"file": "apps/handlers/lifecycle/auto_watchdog.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 (PostToolUse.auto_watchdog)."}, - {"file": "apps/handlers/lifecycle/auto_watchdog.py", "standard": "json_structure", "reason": "Uses stdlib json.dumps to produce additionalContext output — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/lifecycle/compact.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare — fires only during context compaction)."}, - {"file": "apps/handlers/lifecycle/compact.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact — fires during compaction events."}, - {"file": "apps/handlers/lifecycle/compact.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for local.json reading — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/lifecycle/rollover.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' — not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare — fires only during context compaction)."}, - {"file": "apps/handlers/lifecycle/rollover.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover — fires during compaction events."}, - {"file": "apps/handlers/lifecycle/rollover.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for registry and memory file checks — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/lifecycle/auto_process.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' — not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."}, - {"file": "apps/handlers/lifecycle/auto_process.py", "standard": "unused_function", "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process."}, - {"file": "apps/handlers/lifecycle/auto_process.py", "standard": "json_structure", "reason": "Delegates to @memory's auto_process() via importlib — no direct JSON file ops needing json_handler."}, - - {"file": "apps/modules/cadence.py", "standard": "dead_code", "reason": "Cadence module — should_fire() called from global_loader.py and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched."}, - {"file": "apps/modules/cadence.py", "standard": "unused_function", "reason": "should_fire() called from global_loader.py + branch_loader.py; reset_counter() called from compact.py. All callers are dynamically dispatched handlers."}, - {"file": "apps/modules/cadence.py", "standard": "json_structure", "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading — lightweight ephemeral state, not branch data storage."}, - - {"file": "apps/handlers/notification/announce.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' — not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl."}, - {"file": "apps/handlers/notification/announce.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 (Notification.notification_sound)."}, - {"file": "apps/handlers/notification/announce.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."}, - - {"file": "apps/handlers/notification/email.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' — not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl."}, - {"file": "apps/handlers/notification/email.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 (UserPromptSubmit.email_notification)."}, - {"file": "apps/handlers/notification/email.py", "standard": "json_structure", "reason": "Uses stdlib json.loads for inbox parsing — no JSON file ops needing json_handler."}, - - {"file": "apps/handlers/notification/stop_sound.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' — not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl."}, - {"file": "apps/handlers/notification/stop_sound.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 (Stop.stop_sound)."}, - {"file": "apps/handlers/notification/stop_sound.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."}, - - {"file": "apps/handlers/notification/tool_sound.py", "standard": "dead_code", "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' — not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl."}, - {"file": "apps/handlers/notification/tool_sound.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.tool_use_sound)."}, - {"file": "apps/handlers/notification/tool_sound.py", "standard": "json_structure", "reason": "Sound handler — no JSON operations, plays WAV files."}, - - {"file": "apps/handlers/config/loader.py", "standard": "json_structure", "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse — json_handler does not support this."}, - {"file": "apps/handlers/config/diagnostics.py", "standard": "json_structure", "reason": "JSONL append-only diagnostic log — different pattern from branch json_handler storage."}, - - {"file": "apps/modules/engine.py", "standard": "json_structure", "reason": "Engine uses JSONL diagnostic logging, not branch json_handler — different purpose."}, - {"file": "apps/modules/engine.py", "standard": "modules", "reason": "dispatch() is the engine's core purpose — it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module."}, - - {"file": "apps/modules/hooksound.py", "standard": "json_structure", "reason": "Sound mute toggle — touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage."}, - {"file": "apps/modules/hooksound.py", "standard": "trigger", "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle — not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'."}, - - {"file": "apps/modules/hookstatus.py", "standard": "json_structure", "reason": "Read-only config viewer — delegates JSON loading to config/loader.py, no direct JSON file ops."}, - - {"file": "apps/modules/cadence.py", "standard": "modules", "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json — lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs."}, - - {"file": "apps/hooks.py", "standard": "unused_function", "reason": "print_introspection() called by drone's discovery system, not internal code."}, - - {"file": "apps/sound.py", "standard": "unused_function", "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib — static analysis cannot trace the call chain from hooks.json → engine → handler → sound.play()."}, - - {"standard": "test_quality", "reason": "Hooks branch does not use json_handler — has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture."}, - - {"file": "tests/conftest.py", "standard": "architecture", "reason": "Test fixtures live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/conftest.py", "standard": "json_handler", "reason": "Hooks branch does not use json_handler — has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A."}, - {"file": "tests/conftest.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns — those contracts are N/A for a hook dispatch engine."}, - - {"file": "tests/test_engine.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_engine.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_engine.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_engine.py", "standard": "help_text", "reason": "Test data contains command references as part of test fixtures, not user-facing help."}, - {"file": "tests/test_engine.py", "standard": "json_handler", "reason": "Hooks branch does not use json_handler — has its own JSONL logging."}, - {"file": "tests/test_engine.py", "standard": "exception_contracts", "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns."}, - - {"file": "tests/test_tool_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_tool_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_tool_sound.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_tool_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_stop_sound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_stop_sound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_stop_sound.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_stop_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_announce.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_announce.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_announce.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_announce.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_email.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_email.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_email.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_email.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_subagent_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_subagent_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_subagent_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_subagent_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_auto_fix.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_auto_fix.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_auto_fix.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_auto_fix.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - {"file": "tests/test_auto_fix.py", "standard": "commented_logger", "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test — not a commented-out call."}, - {"file": "tests/test_auto_fix.py", "standard": "trigger", "reason": "Test cleanup .unlink() removes temporary state files — not a production file deletion."}, - - {"file": "tests/test_identity.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_identity.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_identity.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_identity.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_branch_loader.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_branch_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_branch_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_branch_loader.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_global_loader.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_global_loader.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_global_loader.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_global_loader.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_compact.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_compact.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_compact.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_compact.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_rollover.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_rollover.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_rollover.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_rollover.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_hookstatus.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_hookstatus.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_hookstatus.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."}, - {"file": "tests/test_hookstatus.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_hooksound.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_hooksound.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_hooksound.py", "standard": "encapsulation", "reason": "Tests import modules directly to test implementation details."}, - {"file": "tests/test_hooksound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_auto_watchdog.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_auto_watchdog.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_auto_watchdog.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_auto_watchdog.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_edit_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_edit_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_edit_gate.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_edit_gate.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_git_gate.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_git_gate.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"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."}, - {"file": "tests/test_sound.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_auto_process.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_auto_process.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_auto_process.py", "standard": "encapsulation", "reason": "Tests import handlers directly to test implementation details."}, - {"file": "tests/test_auto_process.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."}, - - {"file": "tests/test_cadence.py", "standard": "architecture", "reason": "Test files live in tests/, not in the 3-layer apps structure."}, - {"file": "tests/test_cadence.py", "standard": "documentation", "reason": "Test methods use descriptive names as documentation per pytest convention."}, - {"file": "tests/test_cadence.py", "standard": "encapsulation", "reason": "Tests import modules and handlers directly to test implementation details."}, - {"file": "tests/test_cadence.py", "standard": "meta", "reason": "Test files do not need Version/Modified metadata headers."} + { + "standard": "dead_code", + "reason": "All 15 handler files under apps/handlers/ are invoked dynamically by engine._run_handler (engine.py:60-66) via importlib.import_module + getattr on handler path strings from .aipass/hooks.json. They are never statically imported by design. Each handler verified wired in hooks.json AND fired in engine.jsonl (DPLAN-0191). Follow-up for @seedgo: teach dead_code checker about importlib dynamic dispatch patterns." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "dead_code", + "reason": "Bridge called externally by provider settings subprocess \u2014 no internal import. Verified wired in ~/.claude/settings.json hook entries." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "unused_function", + "reason": "main() called as subprocess entry point from provider settings \u2014 never statically imported." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "handlers", + "reason": "Bridges import engine module by design \u2014 that is their entire purpose." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "json_structure", + "reason": "Thin entry point, no JSON operations to log." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "architecture", + "reason": "Bridge importing engine module is its architectural purpose." + }, + { + "file": "apps/handlers/bridges/claude.py", + "standard": "imports", + "reason": "Bridge imports engine module by design \u2014 sole purpose." + }, + { + "file": "apps/handlers/prompt/identity.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.identity.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.identity_injector + fires in engine.jsonl." + }, + { + "file": "apps/handlers/prompt/identity.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 (UserPromptSubmit.identity_injector)." + }, + { + "file": "apps/handlers/prompt/identity.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads to read passport.json \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/prompt/branch_loader.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.branch_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.branch_prompt + fires in engine.jsonl." + }, + { + "file": "apps/handlers/prompt/branch_loader.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 (UserPromptSubmit.branch_prompt)." + }, + { + "file": "apps/handlers/prompt/branch_loader.py", + "standard": "json_structure", + "reason": "No JSON operations \u2014 reads markdown files and outputs text." + }, + { + "file": "apps/handlers/prompt/global_loader.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.prompt.global_loader.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.global_prompt + fires in engine.jsonl." + }, + { + "file": "apps/handlers/prompt/global_loader.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 (UserPromptSubmit.global_prompt)." + }, + { + "file": "apps/handlers/prompt/global_loader.py", + "standard": "json_structure", + "reason": "No JSON operations \u2014 reads markdown file and outputs text." + }, + { + "file": "apps/handlers/security/edit_gate.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.edit_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.pre_edit_gate + fires in engine.jsonl." + }, + { + "file": "apps/handlers/security/edit_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.pre_edit_gate)." + }, + { + "file": "apps/handlers/security/edit_gate.py", + "standard": "json_structure", + "reason": "Security gate uses stdlib json.dumps for hook protocol block responses \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/security/git_gate.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.security.git_gate.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.git_gate + fires in engine.jsonl." + }, + { + "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 \u2014 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' \u2014 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 \u2014 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' \u2014 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 \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/security/subagent_gate.py", + "standard": "open_encoding", + "reason": "NamedTemporaryFile creates binary wav for Piper TTS \u2014 encoding not applicable to binary audio." + }, + { + "file": "apps/handlers/lifecycle/auto_fix.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_fix.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_fix_diagnostics + fires in engine.jsonl." + }, + { + "file": "apps/handlers/lifecycle/auto_fix.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 (PostToolUse.auto_fix_diagnostics)." + }, + { + "file": "apps/handlers/lifecycle/auto_fix.py", + "standard": "json_structure", + "reason": "Diagnostics handler uses stdlib json for hook protocol responses and state file \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/lifecycle/auto_watchdog.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_watchdog.handle' \u2014 not statically imported by design. Verified wired in PostToolUse.auto_watchdog + fires in engine.jsonl." + }, + { + "file": "apps/handlers/lifecycle/auto_watchdog.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 (PostToolUse.auto_watchdog)." + }, + { + "file": "apps/handlers/lifecycle/auto_watchdog.py", + "standard": "json_structure", + "reason": "Uses stdlib json.dumps to produce additionalContext output \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/lifecycle/compact.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.compact.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact (PreCompact events are rare \u2014 fires only during context compaction)." + }, + { + "file": "apps/handlers/lifecycle/compact.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact \u2014 fires during compaction events." + }, + { + "file": "apps/handlers/lifecycle/compact.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for local.json reading \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/lifecycle/rollover.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.rollover.handle' \u2014 not statically imported by design. Verified wired in PreCompact.pre_compact_rollover (PreCompact events are rare \u2014 fires only during context compaction)." + }, + { + "file": "apps/handlers/lifecycle/rollover.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in PreCompact.pre_compact_rollover \u2014 fires during compaction events." + }, + { + "file": "apps/handlers/lifecycle/rollover.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for registry and memory file checks \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/lifecycle/auto_process.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.lifecycle.auto_process.handle' \u2014 not statically imported by design. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process." + }, + { + "file": "apps/handlers/lifecycle/auto_process.py", + "standard": "unused_function", + "reason": "handle() called dynamically by engine._run_handler via importlib.import_module + getattr from hooks.json. Wired in UserPromptSubmit.auto_process + PreCompact.auto_process." + }, + { + "file": "apps/handlers/lifecycle/auto_process.py", + "standard": "json_structure", + "reason": "Delegates to @memory's auto_process() via importlib \u2014 no direct JSON file ops needing json_handler." + }, + { + "file": "apps/modules/cadence.py", + "standard": "dead_code", + "reason": "Cadence module \u2014 should_fire() called from global_loader.py and branch_loader.py guard lines; reset_counter() called from compact.py PreCompact handler. Not statically discoverable because callers are themselves dynamically dispatched." + }, + { + "file": "apps/modules/cadence.py", + "standard": "unused_function", + "reason": "should_fire() called from global_loader.py + branch_loader.py; reset_counter() called from compact.py. All callers are dynamically dispatched handlers." + }, + { + "file": "apps/modules/cadence.py", + "standard": "json_structure", + "reason": "Uses stdlib json for /tmp state file (turn counter) and hooks_json/custom_config/cadence_config.json config loading \u2014 lightweight ephemeral state, not branch data storage." + }, + { + "file": "apps/handlers/notification/announce.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.announce.handle' \u2014 not statically imported by design. Verified wired in Notification.notification_sound + fires in engine.jsonl." + }, + { + "file": "apps/handlers/notification/announce.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 (Notification.notification_sound)." + }, + { + "file": "apps/handlers/notification/announce.py", + "standard": "json_structure", + "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + }, + { + "file": "apps/handlers/notification/email.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.email.handle' \u2014 not statically imported by design. Verified wired in UserPromptSubmit.email_notification + fires in engine.jsonl." + }, + { + "file": "apps/handlers/notification/email.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 (UserPromptSubmit.email_notification)." + }, + { + "file": "apps/handlers/notification/email.py", + "standard": "json_structure", + "reason": "Uses stdlib json.loads for inbox parsing \u2014 no JSON file ops needing json_handler." + }, + { + "file": "apps/handlers/notification/stop_sound.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.stop_sound.handle' \u2014 not statically imported by design. Verified wired in Stop.stop_sound + fires in engine.jsonl." + }, + { + "file": "apps/handlers/notification/stop_sound.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 (Stop.stop_sound)." + }, + { + "file": "apps/handlers/notification/stop_sound.py", + "standard": "json_structure", + "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + }, + { + "file": "apps/handlers/notification/tool_sound.py", + "standard": "dead_code", + "reason": "Invoked dynamically by engine via importlib from hooks.json handler path 'aipass.hooks.apps.handlers.notification.tool_sound.handle' \u2014 not statically imported by design. Verified wired in PreToolUse.tool_use_sound + fires in engine.jsonl." + }, + { + "file": "apps/handlers/notification/tool_sound.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.tool_use_sound)." + }, + { + "file": "apps/handlers/notification/tool_sound.py", + "standard": "json_structure", + "reason": "Sound handler \u2014 no JSON operations, plays WAV files." + }, + { + "file": "apps/handlers/config/loader.py", + "standard": "json_structure", + "reason": "Config loader does $AIPASS_HOME variable expansion before JSON parse \u2014 json_handler does not support this." + }, + { + "file": "apps/handlers/config/diagnostics.py", + "standard": "json_structure", + "reason": "JSONL append-only diagnostic log \u2014 different pattern from branch json_handler storage." + }, + { + "file": "apps/modules/engine.py", + "standard": "json_structure", + "reason": "Engine uses JSONL diagnostic logging, not branch json_handler \u2014 different purpose." + }, + { + "file": "apps/modules/engine.py", + "standard": "modules", + "reason": "dispatch() is the engine's core purpose \u2014 it IS the module's primary function, not a handler that belongs elsewhere. The engine exists to dispatch; moving dispatch to handlers/ would leave an empty module." + }, + { + "file": "apps/modules/hooksound.py", + "standard": "json_structure", + "reason": "Sound mute toggle \u2014 touches /tmp/aipass-hooks-muted flag file only, no JSON operations or json_handler storage." + }, + { + "file": "apps/modules/hooksound.py", + "standard": "trigger", + "reason": "MUTE_FLAG.unlink() removes a /tmp mute flag file for sound toggle \u2014 not a tracked resource or production data deletion. Deliberate user action via 'drone @hooks hooksound on'." + }, + { + "file": "apps/modules/hookstatus.py", + "standard": "json_structure", + "reason": "Read-only config viewer \u2014 delegates JSON loading to config/loader.py, no direct JSON file ops." + }, + { + "file": "apps/modules/cadence.py", + "standard": "modules", + "reason": "Cadence module reads /tmp state file and hooks_json/custom_config/cadence_config.json with stdlib json \u2014 lightweight ephemeral state (turn counter) + tunable config. json_handler is for persistent branch data, not /tmp session state or config knobs." + }, + { + "file": "apps/hooks.py", + "standard": "unused_function", + "reason": "print_introspection() called by drone's discovery system, not internal code." + }, + { + "file": "apps/sound.py", + "standard": "unused_function", + "reason": "play() called by handler files (stop_sound.py, announce.py) that are dynamically dispatched via importlib \u2014 static analysis cannot trace the call chain from hooks.json \u2192 engine \u2192 handler \u2192 sound.play()." + }, + { + "standard": "test_quality", + "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL diagnostic logging (diagnostics.py) and stdlib json for hook protocol I/O. json_handler coverage, mock_json_handler fixture, and exception_contracts (create_default_raises, save_invalid_raises, invalid_mode_raises) are all N/A for a hook dispatch engine architecture." + }, + { + "file": "tests/conftest.py", + "standard": "architecture", + "reason": "Test fixtures live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/conftest.py", + "standard": "json_handler", + "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging and stdlib json for hook protocol. mock_json_handler fixture is N/A." + }, + { + "file": "tests/conftest.py", + "standard": "exception_contracts", + "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns \u2014 those contracts are N/A for a hook dispatch engine." + }, + { + "file": "tests/test_engine.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_engine.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_engine.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_engine.py", + "standard": "help_text", + "reason": "Test data contains command references as part of test fixtures, not user-facing help." + }, + { + "file": "tests/test_engine.py", + "standard": "json_handler", + "reason": "Hooks branch does not use json_handler \u2014 has its own JSONL logging." + }, + { + "file": "tests/test_engine.py", + "standard": "exception_contracts", + "reason": "Hooks has no json_handler create_default/save_invalid/invalid_mode patterns." + }, + { + "file": "tests/test_tool_sound.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_tool_sound.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_tool_sound.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_tool_sound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_stop_sound.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_stop_sound.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_stop_sound.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_stop_sound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_announce.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_announce.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_announce.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_announce.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_email.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_email.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_email.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_email.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_subagent_gate.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "commented_logger", + "reason": "Test data contains '# logger.debug(msg)' as input to pattern checker under test \u2014 not a commented-out call." + }, + { + "file": "tests/test_auto_fix.py", + "standard": "trigger", + "reason": "Test cleanup .unlink() removes temporary state files \u2014 not a production file deletion." + }, + { + "file": "tests/test_identity.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_identity.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_identity.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_identity.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_branch_loader.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_branch_loader.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_branch_loader.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_branch_loader.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_global_loader.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_global_loader.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_global_loader.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_global_loader.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_compact.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_compact.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_compact.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_compact.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_rollover.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_rollover.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_rollover.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_rollover.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_hookstatus.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_hookstatus.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_hookstatus.py", + "standard": "encapsulation", + "reason": "Tests import modules directly to test implementation details." + }, + { + "file": "tests/test_hookstatus.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_hooksound.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_hooksound.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_hooksound.py", + "standard": "encapsulation", + "reason": "Tests import modules directly to test implementation details." + }, + { + "file": "tests/test_hooksound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_auto_watchdog.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_auto_watchdog.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_auto_watchdog.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_auto_watchdog.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_edit_gate.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_edit_gate.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_edit_gate.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_edit_gate.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_git_gate.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_git_gate.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "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." + }, + { + "file": "tests/test_sound.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_auto_process.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_auto_process.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_auto_process.py", + "standard": "encapsulation", + "reason": "Tests import handlers directly to test implementation details." + }, + { + "file": "tests/test_auto_process.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "tests/test_cadence.py", + "standard": "architecture", + "reason": "Test files live in tests/, not in the 3-layer apps structure." + }, + { + "file": "tests/test_cadence.py", + "standard": "documentation", + "reason": "Test methods use descriptive names as documentation per pytest convention." + }, + { + "file": "tests/test_cadence.py", + "standard": "encapsulation", + "reason": "Tests import modules and handlers directly to test implementation details." + }, + { + "file": "tests/test_cadence.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "apps/modules/sandbox.py", + "standard": "json_structure", + "reason": "Uses stdlib json.dump to write ephemeral srt config to a NamedTemporaryFile \u2014 not a tracked JSON resource needing json_handler." + }, + { + "file": "apps/modules/sandbox.py", + "standard": "trigger", + "reason": "Path.unlink() removes an ephemeral NamedTemporaryFile (srt config) created seconds earlier in the same function \u2014 not a tracked resource or production data deletion." + }, + { + "file": "artifacts/sandbox_phase1_demo.py", + "standard": "architecture", + "reason": "Live demo artifact for FPLAN-0250 Phase 1 acceptance \u2014 not production code, lives in artifacts/ per the phase brief." + }, + { + "file": "artifacts/sandbox_phase1_demo.py", + "standard": "debug_print", + "reason": "Demo script uses print() for human-readable acceptance test output \u2014 not a module with CLI service." + }, + { + "file": "artifacts/sandbox_phase1_demo.py", + "standard": "imports", + "reason": "sys.path insert needed to run standalone demo from artifacts/ \u2014 not a pip-installed module entry point." + }, + { + "file": "artifacts/sandbox_phase1_demo.py", + "standard": "documentation", + "reason": "Demo helper function \u2014 docstrings omitted for brevity in a non-production artifact." + }, + { + "file": "artifacts/sandbox_phase1_demo.py", + "standard": "help_text", + "reason": "Demo run instructions reference python3 as the invocation command \u2014 this is a standalone script, not a drone-routed module." + }, + { + "file": "tests/test_sandbox.py", + "standard": "architecture", + "reason": "Test file lives in tests/ per hooks convention \u2014 not a module or handler." + }, + { + "file": "tests/test_sandbox.py", + "standard": "documentation", + "reason": "Test methods use descriptive names \u2014 docstrings redundant per hooks test convention." + }, + { + "file": "tests/test_sandbox.py", + "standard": "meta", + "reason": "Test files do not need Version/Modified metadata headers." + }, + { + "file": "apps/modules/sandbox.py", + "standard": "modules", + "reason": "Read-only passport.json check in _is_devpulse() to detect branch role for policy generation \u2014 not a file-write operation, not a handler-level concern. Module reads sibling passports to determine writable/RO map." + }, + { + "file": "apps/modules/sandbox.py", + "standard": "unused_function", + "reason": "sandbox_launch(), build_policy(), build_srt_config(), and resolve_bwrap_command() are consumed CROSS-BRANCH by ai_mail's dispatch_monitor.py (the launch seam at Phase 4). seedgo's intra-branch static analysis cannot see these callers. Verified: dispatch_monitor imports sandbox module to wire build_policy + sandbox_launch at agent launch." + } ], "notes": { - "removed_2026-05-19": "Stripped 4 illegitimate bypasses — hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", - "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings — handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns." + "removed_2026-05-19": "Stripped 4 illegitimate bypasses \u2014 hooks.py/cli, hooks.py/cli_flags, engine.py/modules, engine.py/introspection. Code fixed to meet standards instead.", + "dplan_0191_2026-05-28": "Added dead_code + unused_function bypasses for all 15 dynamically-dispatched handlers after verifying each is wired in .aipass/hooks.json AND fires in engine.jsonl. Root cause: engine._run_handler (engine.py:60-66) uses importlib.import_module + getattr on hooks.json handler strings \u2014 handlers are never statically imported. Follow-up for @seedgo: teach dead_code/unused_function about dynamic importlib dispatch patterns." } } diff --git a/src/aipass/hooks/README.md b/src/aipass/hooks/README.md index 17cfac04..6943855a 100644 --- a/src/aipass/hooks/README.md +++ b/src/aipass/hooks/README.md @@ -51,7 +51,8 @@ src/aipass/hooks/ │ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating) │ │ ├── engine.py # Core dispatch — routes events to handlers │ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) -│ │ └── hookstatus.py # Config viewer (drone @hooks status) +│ │ ├── hookstatus.py # Config viewer (drone @hooks status) +│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator │ ├── handlers/ │ │ ├── bridges/ # One per provider (thin normalization) │ │ │ └── claude.py # Claude Code bridge @@ -62,7 +63,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 +│ │ │ ├── rm_gate.py # Guardrail — catches accidental rm -rf, 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) @@ -79,7 +80,7 @@ src/aipass/hooks/ │ └── diagnostics.py # JSONL logging for hook execution ├── logs/ │ └── engine.jsonl # JSONL diagnostics (every hook execution) -└── tests/ # 435 tests across 21 test files +└── tests/ # 472 tests across 22 test files ``` ## How It Works @@ -101,13 +102,40 @@ 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, rm_gate | Security gates + sound | +| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + guardrails + sound | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog | | SubagentStop | subagent_gate | Seedgo validation | | Stop | stop_sound | Achievement bell | | Notification | announce | Announcement tone | | PreCompact | compact, rollover | Memory archival + rollover | +## Kernel Sandbox (srt/bwrap) + +The sandbox module (`apps/modules/sandbox.py`) provides the kernel-level filesystem boundary for agent sessions. It wraps Anthropic's `@anthropic-ai/sandbox-runtime` (srt) library, which uses bubblewrap (bwrap) + Landlock + seccomp on Linux to enforce write/read restrictions at the OS level. + +### Key Functions + +| Function | What it does | +|---|---| +| `build_policy(branch_path)` | Generates per-role writable/RO map from branch passport | +| `sandbox_launch(cmd, cwd, policy)` | Resolves bwrap command via srt, spawns sandboxed process | +| `build_srt_config(policy)` | Converts policy dict to srt config format | + +### Policy Rules + +- **Every agent**: own branch tree + /tmp + shared channels (system_logs, .ai_central, memory_pool, AIPASS_REGISTRY.json, flow_json) + sibling mail/dashboard carve-ins + ~/.claude/projects/ +- **devpulse only**: .git writable (the only committer) +- **All other agents**: .git read-only, sibling source trees read-only +- **Deny**: broker_secret (deny_read + deny_write for all roles) + +Bind-mount, not isolation: the sandbox preserves the shared live filesystem. Reads stay open everywhere. Only writes to protected paths are blocked at the kernel level (EROFS). + +### Architecture + +The Node helper (`_srt_resolve.mjs`) resolves the globally-installed srt library via `process.execPath` (ESM resolution doesn't walk to global node_modules). The resolver runs with CWD set to `/var/tmp` to prevent srt's mandatory-deny mask files from polluting the branch directory. + +The @drone broker validates sandbox policy before agent launch. @ai_mail's dispatch_monitor wires `build_policy` + `sandbox_launch` at the launch seam. + ## Integration Points ### Depends On @@ -118,9 +146,10 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im ### Provides To -All branches via hook dispatch. Every Claude Code session routes through the engine. +- All branches via hook dispatch — every Claude Code session routes through the engine +- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary -*Last Updated: 2026-06-02* +*Last Updated: 2026-06-10* --- diff --git a/src/aipass/hooks/apps/handlers/security/rm_gate.py b/src/aipass/hooks/apps/handlers/security/rm_gate.py index c17d54f3..177556c6 100644 --- a/src/aipass/hooks/apps/handlers/security/rm_gate.py +++ b/src/aipass/hooks/apps/handlers/security/rm_gate.py @@ -1,14 +1,19 @@ # =================== AIPass ==================== # Name: rm_gate.py # Version: 1.0.0 -# Description: Blocks raw recursive rm commands (PreToolUse) +# Description: Guardrail — catches accidental rm -rf and teaches drone rm (PreToolUse) # Branch: hooks # Layer: apps/handlers/security # Created: 2026-06-02 # Modified: 2026-06-02 # ============================================= -"""Blocks raw recursive rm and teaches drone rm.""" +"""Early-feedback guardrail — catches accidental recursive rm and teaches drone rm. + +Belt-and-suspenders: the actual filesystem boundary is the kernel sandbox +(srt/bwrap) enforced at agent launch. This hook provides fast, helpful feedback +before the sandbox would block the operation at the kernel level. +""" import json import re @@ -17,10 +22,10 @@ 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" + "Heads up — raw recursive rm is not the right tool here. Use:\n" + " drone rm # project-aware delete (allows project + /tmp, refuses outside)\n" "\n" - "This applies to all recursive rm variants (rm -rf, rm -r, rm -R, rm --recursive)." + "This guardrail catches rm -rf, rm -r, rm -R, and rm --recursive." ) _BLOCK_ALLOW = {"stdout": "", "exit_code": 0} diff --git a/src/aipass/hooks/apps/modules/_srt_resolve.mjs b/src/aipass/hooks/apps/modules/_srt_resolve.mjs new file mode 100644 index 00000000..44a1e652 --- /dev/null +++ b/src/aipass/hooks/apps/modules/_srt_resolve.mjs @@ -0,0 +1,34 @@ +// _srt_resolve.mjs — Resolves bwrap command via @anthropic-ai/sandbox-runtime library. +// Called by sandbox.py. Reads config JSON from file (argv[1]), command string (argv[2]). +// Prints the shell-quoted bwrap command to stdout. Exits 0 on success, 1 on error. +// +// srt is installed globally (npm i -g). ESM resolution walks up from this file's +// directory, never reaching the global node_modules. We derive the path from the +// running Node binary instead. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const nodePrefix = dirname(dirname(process.execPath)); +const srtEntry = join(nodePrefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js'); +const { SandboxManager } = await import(pathToFileURL(srtEntry).href); + +const configPath = process.argv[2]; +const command = process.argv[3]; + +if (!configPath || !command) { + process.stderr.write('usage: _srt_resolve.mjs \n'); + process.exit(1); +} + +try { + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + await SandboxManager.initialize(config); + const wrapped = await SandboxManager.wrapWithSandbox(command, '/bin/bash', config); + process.stdout.write(wrapped); + await SandboxManager.reset(); +} catch (err) { + process.stderr.write(`srt-resolve error: ${err.message}\n`); + process.exit(1); +} diff --git a/src/aipass/hooks/apps/modules/sandbox.py b/src/aipass/hooks/apps/modules/sandbox.py new file mode 100644 index 00000000..ea669fdf --- /dev/null +++ b/src/aipass/hooks/apps/modules/sandbox.py @@ -0,0 +1,284 @@ +# =================== AIPass ==================== +# Name: sandbox.py +# Version: 1.0.0 +# Description: Sandbox wrapper — launches commands inside srt (kernel FS boundary) +# Branch: hooks +# Layer: apps/modules +# Created: 2026-06-09 +# Modified: 2026-06-09 +# ============================================= + +"""Sandbox wrapper — launches commands inside srt kernel filesystem boundary. + +Accepts a policy (writable/RO path map) + command + cwd + env, resolves the +bwrap command via @anthropic-ai/sandbox-runtime, and spawns inside the sandbox. +Phase 1 of FPLAN-0250 / DPLAN-0202. +""" + +import json +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from aipass.cli.apps.modules import err_console +from aipass.prax.apps.modules.logger import system_logger as logger + +CONSOLE = err_console + +_MODULE_DIR = Path(__file__).resolve().parent +_SRT_RESOLVE = _MODULE_DIR / "_srt_resolve.mjs" + +_VAR_TMP = Path("/var/tmp") + + +def _srt_resolve_cwd() -> str: + """Return a CWD for the srt resolver that is outside any allow_write path. + + srt auto-denies DANGEROUS_FILES (.bashrc, .gitconfig, …) resolved relative + to process.cwd(). When the deny target doesn't exist and its ancestor IS in + allow_write, bwrap creates 0-byte mount-point files that persist after exit. + Running the resolver from /var/tmp (never in allow_write) makes srt skip + those entries entirely — no bwrap args, no mount points, no pollution. + """ + if _VAR_TMP.is_dir(): + return str(_VAR_TMP) + return tempfile.gettempdir() + + +HELP_COMMANDS = [ + ("sandbox", "Launch a command inside the kernel sandbox"), +] + + +def _find_node() -> str: + node = shutil.which("node") + if node: + return node + msg = "node not found in PATH — required for srt sandbox" + raise FileNotFoundError(msg) + + +def _find_rg() -> str: + rg = shutil.which("rg") + if rg: + return rg + fallback = Path.home() / ".local" / "bin" / "rg" + if fallback.is_file(): + return str(fallback) + msg = "ripgrep (rg) not found — required by srt for mandatory-deny scan" + raise FileNotFoundError(msg) + + +def _find_repo_root(branch_path: Path) -> Path: + """Walk up from branch_path to find the repo root (contains .git).""" + current = branch_path.resolve() + while current != current.parent: + if (current / ".git").exists(): + return current + current = current.parent + msg = f"No .git found above {branch_path}" + raise FileNotFoundError(msg) + + +def _is_devpulse(branch_path: Path) -> bool: + """Check if a branch is devpulse (the only committer) via passport.""" + passport = branch_path / ".trinity" / "passport.json" + if passport.is_file(): + try: + data = json.loads(passport.read_text(encoding="utf-8")) + return data.get("branch_info", {}).get("branch_name") == "devpulse" + except (json.JSONDecodeError, OSError) as exc: + logger.info("sandbox: failed to read passport for %s: %s", branch_path.name, exc) + return branch_path.name == "devpulse" + + +def _claude_project_dir(branch_path: Path) -> Path: + """Derive the ~/.claude/projects/ directory for a branch.""" + encoded = str(branch_path.resolve()).replace("/", "-") + return Path.home() / ".claude" / "projects" / encoded + + +def _find_src_aipass(repo_root: Path) -> Path: + """Locate the src/aipass/ directory within the repo.""" + return repo_root / "src" / "aipass" + + +def build_policy(branch_path: str | Path) -> dict: + """Generate sandbox policy for a branch agent. + + Returns a policy dict compatible with sandbox_launch / build_srt_config: + allow_write: list of writable paths + deny_write: broker secret only (it sits inside the writable .ai_central) + deny_read: broker secret only — agents must never read it, or a + path-connected broker client could forge a devpulse identity. + Everything else stays readable (shared live filesystem). + """ + branch_path = Path(branch_path).resolve() + repo_root = _find_repo_root(branch_path) + src_aipass = _find_src_aipass(repo_root) + branch_name = branch_path.name + is_dp = _is_devpulse(branch_path) + + allow_write: list[str] = [] + + allow_write.append(str(branch_path)) + + allow_write.append("/tmp") + tmpdir = os.environ.get("TMPDIR") + if tmpdir and tmpdir != "/tmp": + allow_write.append(tmpdir) + + allow_write.extend( + [ + str(repo_root / "system_logs"), + str(repo_root / ".ai_central"), + str(src_aipass / "memory" / "memory_pool"), + str(repo_root / "AIPASS_REGISTRY.json"), + str(src_aipass / "flow" / "flow_json"), + ] + ) + + for sibling in sorted(src_aipass.iterdir()): + if not sibling.is_dir(): + continue + if sibling.name == branch_name or sibling.name.startswith("_"): + continue + mail_dir = sibling / ".ai_mail.local" + if mail_dir.is_dir(): + allow_write.append(str(mail_dir)) + dashboard = sibling / "DASHBOARD.local.json" + if dashboard.is_file(): + allow_write.append(str(dashboard)) + + if is_dp: + allow_write.append(str(repo_root / ".git")) + + claude_proj = _claude_project_dir(branch_path) + if claude_proj.is_dir(): + allow_write.append(str(claude_proj)) + + broker_secret = repo_root / ".ai_central" / "broker_secret" + return { + "allow_write": allow_write, + "deny_write": [str(broker_secret)], + "deny_read": [str(broker_secret)], + } + + +def build_srt_config(policy: dict) -> dict: + """Convert a policy dict to srt config format. + + Policy keys: + allow_write: list[str] — paths the sandboxed process may write to + deny_write: list[str] — paths to deny write within writable (optional) + deny_read: list[str] — paths to deny read (optional) + """ + return { + "network": { + "allowAllUnixSockets": True, + }, + "filesystem": { + "denyRead": [str(p) for p in policy.get("deny_read", [])], + "allowWrite": [str(p) for p in policy["allow_write"]], + "denyWrite": [str(p) for p in policy.get("deny_write", [])], + }, + "ripgrep": { + "command": _find_rg(), + }, + } + + +def resolve_bwrap_command(command: str, srt_config: dict) -> str: + """Call the Node.js srt resolver to get the bwrap shell command.""" + node = _find_node() + + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".json", + prefix="srt-config-", + delete=False, + encoding="utf-8", + ) as f: + json.dump(srt_config, f) + config_path = f.name + + try: + result = subprocess.run( + [node, str(_SRT_RESOLVE), config_path, command], + capture_output=True, + text=True, + timeout=30, + check=False, + cwd=_srt_resolve_cwd(), + ) + if result.returncode != 0: + stderr = result.stderr.strip() + msg = f"srt resolve failed (exit {result.returncode}): {stderr}" + raise RuntimeError(msg) + wrapped = result.stdout.strip() + if not wrapped: + msg = "srt resolve returned empty command" + raise RuntimeError(msg) + return wrapped + finally: + Path(config_path).unlink(missing_ok=True) + + +def sandbox_launch( + command: str, + *, + cwd: str | Path | None = None, + policy: dict, + env: dict | None = None, +) -> subprocess.Popen: + """Launch a command inside the srt kernel sandbox. + + Args: + command: Shell command string to run inside the sandbox. + cwd: Working directory for the sandboxed process. + policy: Dict with allow_write (required), deny_write, deny_read (optional). + env: Environment variables (defaults to current env). + + Returns: + subprocess.Popen handle for the sandboxed process. + """ + srt_config = build_srt_config(policy) + bwrap_cmd = resolve_bwrap_command(command, srt_config) + + logger.info("sandbox_launch: wrapping command in srt sandbox") + + launch_env = env if env is not None else dict(os.environ) + + return subprocess.Popen( + ["/bin/bash", "-c", bwrap_cmd], + cwd=str(cwd) if cwd else None, + env=launch_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def print_introspection() -> None: + """Print module structure for drone routing.""" + CONSOLE.print("[bold cyan]sandbox[/bold cyan] — Kernel filesystem boundary via srt") + CONSOLE.print(" Phase 1: wrapper module only (not yet wired into dispatch)") + CONSOLE.print(" Use sandbox_launch() programmatically.") + + +def handle_command(command: str, args: list) -> bool: + """Route sandbox commands from drone @hooks.""" + if command == "sandbox": + if not args: + print_introspection() + return True + + sub = args[0] + if sub in ("--help", "-h", "help"): + CONSOLE.print("[bold cyan]sandbox[/bold cyan] — Kernel filesystem boundary via srt") + CONSOLE.print() + CONSOLE.print(" drone @hooks sandbox Show sandbox module status") + return True + + return False diff --git a/src/aipass/hooks/tests/test_sandbox.py b/src/aipass/hooks/tests/test_sandbox.py new file mode 100644 index 00000000..147b2d4a --- /dev/null +++ b/src/aipass/hooks/tests/test_sandbox.py @@ -0,0 +1,484 @@ +# =================== AIPass ==================== +# Name: test_sandbox.py +# Version: 1.0.0 +# Description: Tests for sandbox wrapper module +# Branch: hooks +# Created: 2026-06-09 +# Modified: 2026-06-09 +# ============================================= + +"""Tests for apps/modules/sandbox.py.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + + +class TestBuildSrtConfig: + """Config generation from policy dict.""" + + def test_minimal_policy(self): + from aipass.hooks.apps.modules.sandbox import build_srt_config + + with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"): + config = build_srt_config({"allow_write": ["/tmp"]}) + + assert config["network"] == {"allowAllUnixSockets": True} + assert config["filesystem"]["allowWrite"] == ["/tmp"] + assert config["filesystem"]["denyRead"] == [] + assert config["filesystem"]["denyWrite"] == [] + assert config["ripgrep"]["command"] == "/usr/bin/rg" + + def test_full_policy(self): + from aipass.hooks.apps.modules.sandbox import build_srt_config + + policy = { + "allow_write": ["/tmp", "/home/user/branch"], + "deny_write": ["/home/user/branch/.git"], + "deny_read": ["/etc/shadow"], + } + with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"): + config = build_srt_config(policy) + + assert config["filesystem"]["allowWrite"] == ["/tmp", "/home/user/branch"] + assert config["filesystem"]["denyWrite"] == ["/home/user/branch/.git"] + assert config["filesystem"]["denyRead"] == ["/etc/shadow"] + + def test_paths_stringified(self): + from aipass.hooks.apps.modules.sandbox import build_srt_config + + policy = {"allow_write": [Path("/tmp"), Path("/home/x")]} + with patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"): + config = build_srt_config(policy) + + assert all(isinstance(p, str) for p in config["filesystem"]["allowWrite"]) + + def test_missing_allow_write_raises(self): + from aipass.hooks.apps.modules.sandbox import build_srt_config + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_rg", return_value="/usr/bin/rg"), + pytest.raises(KeyError), + ): + build_srt_config({}) + + +class TestFindNode: + """Node.js binary discovery.""" + + def test_finds_node_on_path(self): + from aipass.hooks.apps.modules.sandbox import _find_node + + with patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value="/usr/bin/node"): + assert _find_node() == "/usr/bin/node" + + def test_raises_when_not_found(self): + from aipass.hooks.apps.modules.sandbox import _find_node + + with ( + patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None), + pytest.raises(FileNotFoundError, match="node not found"), + ): + _find_node() + + +class TestFindRg: + """Ripgrep binary discovery.""" + + def test_finds_rg_on_path(self): + from aipass.hooks.apps.modules.sandbox import _find_rg + + with patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value="/usr/bin/rg"): + assert _find_rg() == "/usr/bin/rg" + + def test_falls_back_to_local_bin(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import _find_rg + + fake_rg = tmp_path / ".local" / "bin" / "rg" + fake_rg.parent.mkdir(parents=True) + fake_rg.touch() + + with ( + patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None), + patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path), + ): + assert _find_rg() == str(fake_rg) + + def test_raises_when_not_found(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import _find_rg + + with ( + patch("aipass.hooks.apps.modules.sandbox.shutil.which", return_value=None), + patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path), + pytest.raises(FileNotFoundError, match="ripgrep"), + ): + _find_rg() + + +class TestResolveBwrapCommand: + """Bwrap command resolution via Node helper.""" + + def test_returns_bwrap_string(self): + from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command + + fake_result = MagicMock() + fake_result.returncode = 0 + fake_result.stdout = "bwrap --ro-bind / / -- /bin/bash -c 'echo hello'" + fake_result.stderr = "" + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"), + patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result), + ): + cmd = resolve_bwrap_command("echo hello", {"network": {}}) + + assert "bwrap" in cmd + + def test_raises_on_nonzero_exit(self): + from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command + + fake_result = MagicMock() + fake_result.returncode = 1 + fake_result.stdout = "" + fake_result.stderr = "some error" + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"), + patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result), + pytest.raises(RuntimeError, match="srt resolve failed"), + ): + resolve_bwrap_command("echo hello", {"network": {}}) + + def test_raises_on_empty_output(self): + from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command + + fake_result = MagicMock() + fake_result.returncode = 0 + fake_result.stdout = "" + fake_result.stderr = "" + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"), + patch("aipass.hooks.apps.modules.sandbox.subprocess.run", return_value=fake_result), + pytest.raises(RuntimeError, match="empty command"), + ): + resolve_bwrap_command("echo hello", {"network": {}}) + + def test_resolver_cwd_is_not_branch_dir(self): + """srt resolves DANGEROUS_FILES relative to CWD. Using /var/tmp (or + fallback) prevents mount-point pollution in the branch directory.""" + from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command + + fake_result = MagicMock() + fake_result.returncode = 0 + fake_result.stdout = "bwrap --test" + fake_result.stderr = "" + + captured_kwargs = {} + + def capture_run(args, **kwargs): + captured_kwargs.update(kwargs) + return fake_result + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"), + patch("aipass.hooks.apps.modules.sandbox.subprocess.run", side_effect=capture_run), + ): + resolve_bwrap_command("echo hello", {"network": {}}) + + cwd = captured_kwargs.get("cwd", "") + assert cwd and not cwd.startswith(str(Path.cwd())) + + def test_cleans_up_temp_file(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import resolve_bwrap_command + + fake_result = MagicMock() + fake_result.returncode = 0 + fake_result.stdout = "bwrap --test" + fake_result.stderr = "" + + created_files = [] + + def capture_run(args, **kwargs): + config_path = args[2] + created_files.append(config_path) + return fake_result + + with ( + patch("aipass.hooks.apps.modules.sandbox._find_node", return_value="/usr/bin/node"), + patch("aipass.hooks.apps.modules.sandbox.subprocess.run", side_effect=capture_run), + ): + resolve_bwrap_command("echo hello", {"network": {}}) + + assert len(created_files) == 1 + assert not Path(created_files[0]).exists() + + +class TestSandboxLaunch: + """Full launch flow (mocked resolver).""" + + def test_returns_popen(self): + from aipass.hooks.apps.modules.sandbox import sandbox_launch + + fake_popen = MagicMock() + + with ( + patch( + "aipass.hooks.apps.modules.sandbox.resolve_bwrap_command", + return_value="bwrap --test -- /bin/bash -c 'echo hi'", + ), + patch( + "aipass.hooks.apps.modules.sandbox.build_srt_config", + return_value={"network": {}}, + ), + patch( + "aipass.hooks.apps.modules.sandbox.subprocess.Popen", + return_value=fake_popen, + ) as mock_popen, + ): + result = sandbox_launch("echo hi", policy={"allow_write": ["/tmp"]}) + + assert result is fake_popen + call_args = mock_popen.call_args + assert call_args[0][0] == ["/bin/bash", "-c", "bwrap --test -- /bin/bash -c 'echo hi'"] + + def test_passes_cwd(self): + from aipass.hooks.apps.modules.sandbox import sandbox_launch + + with ( + patch( + "aipass.hooks.apps.modules.sandbox.resolve_bwrap_command", + return_value="bwrap --test", + ), + patch( + "aipass.hooks.apps.modules.sandbox.build_srt_config", + return_value={"network": {}}, + ), + patch("aipass.hooks.apps.modules.sandbox.subprocess.Popen") as mock_popen, + ): + sandbox_launch("echo hi", cwd="/tmp/test", policy={"allow_write": ["/tmp"]}) + + assert mock_popen.call_args[1]["cwd"] == "/tmp/test" + + def test_passes_custom_env(self): + from aipass.hooks.apps.modules.sandbox import sandbox_launch + + custom_env = {"PATH": "/usr/bin", "HOME": "/tmp"} + + with ( + patch( + "aipass.hooks.apps.modules.sandbox.resolve_bwrap_command", + return_value="bwrap --test", + ), + patch( + "aipass.hooks.apps.modules.sandbox.build_srt_config", + return_value={"network": {}}, + ), + patch("aipass.hooks.apps.modules.sandbox.subprocess.Popen") as mock_popen, + ): + sandbox_launch("echo hi", policy={"allow_write": ["/tmp"]}, env=custom_env) + + assert mock_popen.call_args[1]["env"] is custom_env + + +class TestSrtResolveCwd: + """CWD selection for srt resolver — prevents mask-placeholder pollution.""" + + def test_returns_var_tmp_when_available(self): + from aipass.hooks.apps.modules.sandbox import _srt_resolve_cwd + + mock_var = MagicMock() + mock_var.is_dir.return_value = True + mock_var.__str__ = MagicMock(return_value="/var/tmp") + with patch("aipass.hooks.apps.modules.sandbox._VAR_TMP", mock_var): + assert _srt_resolve_cwd() == "/var/tmp" + + def test_falls_back_to_tempdir(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import _srt_resolve_cwd + + with ( + patch("aipass.hooks.apps.modules.sandbox._VAR_TMP") as mock_var, + patch("aipass.hooks.apps.modules.sandbox.tempfile.gettempdir", return_value=str(tmp_path)), + ): + mock_var.is_dir.return_value = False + assert _srt_resolve_cwd() == str(tmp_path) + + +class TestBuildPolicy: + """Policy generation from branch path.""" + + def _make_branch(self, tmp_path, name, citizen_class="builder", is_devpulse=False): + """Create a minimal branch structure for testing.""" + import json + + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + src_aipass = repo / "src" / "aipass" + src_aipass.mkdir(parents=True) + + branch = src_aipass / name + branch.mkdir() + trinity = branch / ".trinity" + trinity.mkdir() + passport = { + "branch_info": {"branch_name": "devpulse" if is_devpulse else name}, + "identity": {"citizen_class": citizen_class}, + } + (trinity / "passport.json").write_text(json.dumps(passport), encoding="utf-8") + + for shared in ["system_logs", ".ai_central"]: + (repo / shared).mkdir() + (src_aipass / "memory" / "memory_pool").mkdir(parents=True) + (repo / "AIPASS_REGISTRY.json").touch() + (src_aipass / "flow" / "flow_json").mkdir(parents=True) + + return branch + + def _make_sibling(self, branch_path, name, with_mail=True, with_dashboard=True): + """Create a sibling branch with mail/dashboard.""" + src_aipass = branch_path.parent + sibling = src_aipass / name + sibling.mkdir() + if with_mail: + (sibling / ".ai_mail.local").mkdir() + if with_dashboard: + (sibling / "DASHBOARD.local.json").touch() + return sibling + + def test_builder_includes_own_tree(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + policy = build_policy(branch) + assert str(branch) in policy["allow_write"] + + def test_builder_includes_tmp(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + policy = build_policy(branch) + assert "/tmp" in policy["allow_write"] + + def test_builder_includes_shared_channels(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + repo = tmp_path / "repo" + policy = build_policy(branch) + assert str(repo / "system_logs") in policy["allow_write"] + assert str(repo / ".ai_central") in policy["allow_write"] + assert str(repo / "AIPASS_REGISTRY.json") in policy["allow_write"] + + def test_builder_excludes_git(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + repo = tmp_path / "repo" + policy = build_policy(branch) + assert str(repo / ".git") not in policy["allow_write"] + + def test_devpulse_includes_git(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "devpulse", is_devpulse=True) + repo = tmp_path / "repo" + policy = build_policy(branch) + assert str(repo / ".git") in policy["allow_write"] + + def test_sibling_mail_writable(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + sibling = self._make_sibling(branch, "hooks") + policy = build_policy(branch) + assert str(sibling / ".ai_mail.local") in policy["allow_write"] + + def test_sibling_dashboard_writable(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + sibling = self._make_sibling(branch, "hooks") + policy = build_policy(branch) + assert str(sibling / "DASHBOARD.local.json") in policy["allow_write"] + + def test_sibling_source_not_writable(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + sibling = self._make_sibling(branch, "hooks") + policy = build_policy(branch) + assert str(sibling) not in policy["allow_write"] + + def test_policy_shape(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + policy = build_policy(branch) + assert "allow_write" in policy + assert "deny_write" in policy + assert "deny_read" in policy + secret = str(tmp_path / "repo" / ".ai_central" / "broker_secret") + assert policy["deny_write"] == [secret] + assert policy["deny_read"] == [secret] + + def test_broker_secret_masked_for_all_roles(self, tmp_path): + """The broker secret sits inside writable .ai_central — it must be + deny_read AND deny_write for every role, or a sandboxed agent could + read it and forge a devpulse identity to the broker.""" + from aipass.hooks.apps.modules.sandbox import build_policy + + for name in ("seedgo", "devpulse"): + base = tmp_path / f"case_{name}" + base.mkdir() + branch = self._make_branch(base, name) + repo_root = base / "repo" + policy = build_policy(branch) + secret = str(repo_root / ".ai_central" / "broker_secret") + assert secret in policy["deny_read"] + assert secret in policy["deny_write"] + assert str(repo_root / ".ai_central") in policy["allow_write"] + + def test_claude_project_dir_included(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + branch = self._make_branch(tmp_path, "seedgo") + encoded = str(branch.resolve()).replace("/", "-") + claude_proj = tmp_path / ".claude" / "projects" / encoded + claude_proj.mkdir(parents=True) + + with patch("aipass.hooks.apps.modules.sandbox.Path.home", return_value=tmp_path): + policy = build_policy(branch) + + assert str(claude_proj) in policy["allow_write"] + + def test_no_repo_root_raises(self, tmp_path): + from aipass.hooks.apps.modules.sandbox import build_policy + + bare = tmp_path / "no_repo" / "branch" + bare.mkdir(parents=True) + with pytest.raises(FileNotFoundError, match="No .git found"): + build_policy(bare) + + +class TestHandleCommand: + """Drone routing for sandbox module.""" + + def test_sandbox_no_args_calls_introspection(self): + from aipass.hooks.apps.modules.sandbox import handle_command + + result = handle_command("sandbox", []) + assert result is True + + def test_sandbox_help(self): + from aipass.hooks.apps.modules.sandbox import handle_command + + result = handle_command("sandbox", ["--help"]) + assert result is True + + def test_unknown_command_returns_false(self): + from aipass.hooks.apps.modules.sandbox import handle_command + + result = handle_command("other", []) + assert result is False From 17863ac4c5b2a07ce0271456734de90f35ac5323 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 18:26:26 -0700 Subject: [PATCH 14/20] =?UTF-8?q?refactor(shared):=20re-home=20aipass.comm?= =?UTF-8?q?on=20into=20its=20steward=20=E2=80=94=20src/aipass/aipass/share?= =?UTF-8?q?d=20(FPLAN-0260)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/aipass/common was the only non-citizen directory in the agent namespace. Per @seedgo design review: moved into @aipass (owner) as aipass.aipass.shared, content byte-identical. @spawn imports across (blessed shared-infra category, same as aipass.prax/cli). New subprocess guard test pins the bootstrap-safety invariant (shared/ loads zero branch deps — aipass init stays pre-drone-safe). 9 import/doc sites updated, 9 documented seedgo bypasses (pre-infra leaf, stdlib-only by design). aipass 480 + spawn 315 tests green, both audits 100%, repo-wide zero refs to the old path. --- CHANGELOG.md | 11 ++++ src/aipass/aipass/.seedgo/bypass.json | 47 ++++++++++++++- .../aipass/apps/handlers/json/json_handler.py | 8 +-- src/aipass/aipass/apps/modules/doctor.py | 2 +- src/aipass/aipass/apps/modules/doctor_fix.py | 2 +- src/aipass/aipass/shared/__init__.py | 1 + .../{common => aipass/shared}/json_handler.py | 2 +- .../{common => aipass/shared}/json_ops.py | 2 +- .../shared}/registry_discovery.py | 2 +- src/aipass/aipass/tests/test_json_handler.py | 4 +- .../tests/test_shared_bootstrap_safety.py | 58 +++++++++++++++++++ .../aipass/tests/test_structure_scan.py | 4 +- src/aipass/common/__init__.py | 1 - src/aipass/spawn/.seedgo/bypass.json | 2 +- .../spawn/apps/handlers/json/json_handler.py | 8 +-- src/aipass/spawn/apps/handlers/json_ops.py | 8 +-- src/aipass/spawn/apps/handlers/registry.py | 6 +- .../builder/.spawn/.template_registry.json | 6 +- src/aipass/spawn/tests/test_json_handler.py | 2 +- 19 files changed, 145 insertions(+), 31 deletions(-) create mode 100644 src/aipass/aipass/shared/__init__.py rename src/aipass/{common => aipass/shared}/json_handler.py (99%) rename src/aipass/{common => aipass/shared}/json_ops.py (99%) rename src/aipass/{common => aipass/shared}/registry_discovery.py (98%) create mode 100644 src/aipass/aipass/tests/test_shared_bootstrap_safety.py delete mode 100644 src/aipass/common/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ab1ac464..07d14c4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,17 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Changed +- **Shared leaf library re-homed: `aipass.common` → `aipass.aipass.shared` (FPLAN-0260).** + `src/aipass/common/` was the only non-citizen directory in the agent namespace — + a shared lib (json_handler / json_ops / registry_discovery, extracted in + TDPLAN-0006 P2) parked as a sibling to the agents with no owner. Per @seedgo + design review it now lives inside its steward at `src/aipass/aipass/shared/`, + owned by @aipass; @spawn imports across (same blessed shared-infra category as + `aipass.prax`/`aipass.cli`). Content byte-identical; ~9 import/doc sites updated + across aipass+spawn. A new subprocess guard test pins the bootstrap-safety + invariant: importing `shared/` loads zero branch dependencies, so `aipass init` + keeps working pre-drone on fresh machines. Note: `aipass.common` shipped in the + v2.5.2 wheel; it was internal plumbing — no deprecation shim. - **Action-gated hook sound.** Piper now speaks only when a hook actually *does* something — handlers return a `sound` key the engine plays, instead of announcing on every invocation. Skipped loaders are silent. Quieter and honest. diff --git a/src/aipass/aipass/.seedgo/bypass.json b/src/aipass/aipass/.seedgo/bypass.json index aae07fe7..503b2889 100644 --- a/src/aipass/aipass/.seedgo/bypass.json +++ b/src/aipass/aipass/.seedgo/bypass.json @@ -274,7 +274,7 @@ { "file": "apps/handlers/json/json_handler.py", "standard": "test_quality", - "reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test." + "reason": "save_json now raises ValueError on invalid structure (aipass.aipass.shared contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test." }, { "file": "tests/test_sandbox_check.py", @@ -290,6 +290,51 @@ "file": "tests/test_sandbox_check.py", "standard": "documentation", "reason": "Test methods use descriptive names (test_flag_off_by_default, test_bwrap_functional_live) that are self-documenting. Adding docstrings to 41 test functions adds noise without value." + }, + { + "file": "shared/json_handler.py", + "standard": "architecture", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/json_handler.py", + "standard": "log_visibility", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/json_handler.py", + "standard": "trigger", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/json_ops.py", + "standard": "architecture", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/json_ops.py", + "standard": "log_visibility", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/json_ops.py", + "standard": "trigger", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "shared/registry_discovery.py", + "standard": "architecture", + "reason": "pre-infra leaf — stdlib-only by design, must not import branch dependencies (loads pre-drone for aipass init)" + }, + { + "file": "tests/test_shared_bootstrap_safety.py", + "standard": "architecture", + "reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only." + }, + { + "file": "shared/json_ops.py", + "standard": "unused_function", + "reason": "backup_json() is consumed by @spawn (cross-branch caller). Appears unused in @aipass-only scan but is a shared API." } ] } diff --git a/src/aipass/aipass/apps/handlers/json/json_handler.py b/src/aipass/aipass/apps/handlers/json/json_handler.py index eda90966..f73bc123 100644 --- a/src/aipass/aipass/apps/handlers/json/json_handler.py +++ b/src/aipass/aipass/apps/handlers/json/json_handler.py @@ -1,14 +1,14 @@ # =================== AIPass ==================== # Name: json_handler.py -# Description: Branch-local shim — delegates to aipass.common.json_handler +# Description: Branch-local shim — delegates to aipass.aipass.shared.json_handler # Version: 2.0.0 # Created: 2026-04-16 # Modified: 2026-06-06 # ============================================= -"""Branch-local JSON handler — thin shim over the shared ``aipass.common`` library. +"""Branch-local JSON handler — thin shim over the shared ``aipass.aipass.shared`` library. -All logic lives in ``aipass.common.json_handler.JsonHandler``. +All logic lives in ``aipass.aipass.shared.json_handler.JsonHandler``. This module binds a ``JsonHandler`` instance to the aipass branch's ``aipass_json/`` directory and re-exports the public API as module-level functions so existing callers (``json_handler.log_operation(...)``) keep working. @@ -20,7 +20,7 @@ import inspect from pathlib import Path from typing import Any, Dict, Optional -from aipass.common.json_handler import JsonHandler +from aipass.aipass.shared.json_handler import JsonHandler def _get_caller_module_name() -> str: diff --git a/src/aipass/aipass/apps/modules/doctor.py b/src/aipass/aipass/apps/modules/doctor.py index 06303c0c..0ec8217e 100644 --- a/src/aipass/aipass/apps/modules/doctor.py +++ b/src/aipass/aipass/apps/modules/doctor.py @@ -20,7 +20,7 @@ from typing import Dict, List, NamedTuple from aipass.cli.apps.modules import console from aipass.prax import logger -from aipass.common.registry_discovery import find_registry as _discover_registry +from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry from aipass.aipass.apps.handlers.json import json_handler from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import ( diff --git a/src/aipass/aipass/apps/modules/doctor_fix.py b/src/aipass/aipass/apps/modules/doctor_fix.py index a59c8a59..5f0cc5bf 100644 --- a/src/aipass/aipass/apps/modules/doctor_fix.py +++ b/src/aipass/aipass/apps/modules/doctor_fix.py @@ -25,7 +25,7 @@ from typing import List, NamedTuple from aipass.cli.apps.modules import console from aipass.prax import logger -from aipass.common.registry_discovery import find_registry as _discover_registry +from aipass.aipass.shared.registry_discovery import find_registry as _discover_registry from aipass.aipass.apps.handlers.json import json_handler from aipass.aipass.apps.handlers.structure_scan.structure_scanner import ( diff --git a/src/aipass/aipass/shared/__init__.py b/src/aipass/aipass/shared/__init__.py new file mode 100644 index 00000000..b9c53739 --- /dev/null +++ b/src/aipass/aipass/shared/__init__.py @@ -0,0 +1 @@ +# aipass.aipass.shared — shared leaf utilities (no branch dependencies, loads pre-drone) diff --git a/src/aipass/common/json_handler.py b/src/aipass/aipass/shared/json_handler.py similarity index 99% rename from src/aipass/common/json_handler.py rename to src/aipass/aipass/shared/json_handler.py index 90033cc5..dc690f36 100644 --- a/src/aipass/common/json_handler.py +++ b/src/aipass/aipass/shared/json_handler.py @@ -3,7 +3,7 @@ # Description: Shared JSON handler with injectable storage directory # Version: 1.0.0 # Created: 2026-06-06 -# Modified: 2026-06-06 +# Modified: 2026-06-10 # ============================================= """Shared JSON handler — auto-creating, self-healing JSON system. diff --git a/src/aipass/common/json_ops.py b/src/aipass/aipass/shared/json_ops.py similarity index 99% rename from src/aipass/common/json_ops.py rename to src/aipass/aipass/shared/json_ops.py index ef749764..c77665d3 100644 --- a/src/aipass/common/json_ops.py +++ b/src/aipass/aipass/shared/json_ops.py @@ -3,7 +3,7 @@ # Description: Shared JSON operations — deep merge and backup # Version: 1.0.0 # Created: 2026-06-06 -# Modified: 2026-06-06 +# Modified: 2026-06-10 # ============================================= """Shared JSON operations — deep merge and backup utilities. diff --git a/src/aipass/common/registry_discovery.py b/src/aipass/aipass/shared/registry_discovery.py similarity index 98% rename from src/aipass/common/registry_discovery.py rename to src/aipass/aipass/shared/registry_discovery.py index 9ac1e39c..2f111f68 100644 --- a/src/aipass/common/registry_discovery.py +++ b/src/aipass/aipass/shared/registry_discovery.py @@ -3,7 +3,7 @@ # Description: Shared registry file discovery (walk-up search) # Version: 1.0.0 # Created: 2026-06-06 -# Modified: 2026-06-06 +# Modified: 2026-06-10 # ============================================= """Registry discovery — find *_REGISTRY.json by walking up the directory tree. diff --git a/src/aipass/aipass/tests/test_json_handler.py b/src/aipass/aipass/tests/test_json_handler.py index fefc5ed8..17f1035d 100644 --- a/src/aipass/aipass/tests/test_json_handler.py +++ b/src/aipass/aipass/tests/test_json_handler.py @@ -60,7 +60,7 @@ class TestDefaultFactory: def test_unknown_type_raises(self): """Unknown json_type raises ValueError.""" - from aipass.common.json_handler import JsonHandler + from aipass.aipass.shared.json_handler import JsonHandler with pytest.raises(ValueError): JsonHandler._create_default("unknown_type", "test_mod") @@ -220,7 +220,7 @@ class TestSave: ro_dir.mkdir() with patch("aipass.aipass.apps.handlers.json.json_handler.AIPASS_JSON_DIR", ro_dir): data = {"module_name": "s", "version": "1.0.0", "config": {}, "created": "2026-01-01"} - with patch("aipass.common.json_handler.JsonHandler.write_json", return_value=False): + with patch("aipass.aipass.shared.json_handler.JsonHandler.write_json", return_value=False): result = save_json("s", "config", data) assert result is False diff --git a/src/aipass/aipass/tests/test_shared_bootstrap_safety.py b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py new file mode 100644 index 00000000..5a0928b4 --- /dev/null +++ b/src/aipass/aipass/tests/test_shared_bootstrap_safety.py @@ -0,0 +1,58 @@ +# =================== AIPass ==================== +# Name: test_shared_bootstrap_safety.py +# Description: Guard test — shared/ must stay stdlib-only (loads pre-drone) +# Version: 1.0.0 +# Created: 2026-06-10 +# Modified: 2026-06-10 +# ============================================= + +"""Guard test: importing aipass.aipass.shared must NOT pull in branch dependencies. + +The shared/ package is used by bootstrap.py during `aipass init` on fresh machines +where drone/prax/trigger don't exist yet. If shared/ ever imports a branch +dependency, init breaks. This test enforces the invariant via subprocess isolation. +""" + +import subprocess +import sys + +ALLOWED_PREFIXES = ("aipass.aipass.shared",) +ALLOWED_EXACT = {"aipass", "aipass.aipass"} + +SCRIPT = """\ +import sys + +import aipass.aipass.shared.json_handler +import aipass.aipass.shared.json_ops +import aipass.aipass.shared.registry_discovery + +bad = [] +for name in sorted(sys.modules): + if not name.startswith("aipass"): + continue + if name in {allowed_exact}: + continue + if any(name.startswith(p) for p in {allowed_prefixes}): + continue + bad.append(name) + +if bad: + print("FAIL: branch dependencies loaded: " + ", ".join(bad)) + sys.exit(1) +print("OK") +""".format( + allowed_exact=repr(ALLOWED_EXACT), + allowed_prefixes=repr(ALLOWED_PREFIXES), +) + + +class TestSharedBootstrapSafety: + def test_no_branch_deps_loaded(self): + """Importing all shared modules must not pull in any branch code.""" + result = subprocess.run( + [sys.executable, "-c", SCRIPT], + capture_output=True, + text=True, + timeout=30, + ) + assert result.returncode == 0, f"shared/ pulled in branch dependencies:\n{result.stdout}\n{result.stderr}" diff --git a/src/aipass/aipass/tests/test_structure_scan.py b/src/aipass/aipass/tests/test_structure_scan.py index 25a14604..11f68444 100644 --- a/src/aipass/aipass/tests/test_structure_scan.py +++ b/src/aipass/aipass/tests/test_structure_scan.py @@ -357,7 +357,7 @@ class TestRegistryConsistency: class TestFindRegistry: def test_finds_registry(self, tmp_path: Path) -> None: """Shared find_registry finds *_REGISTRY.json from start_path.""" - from aipass.common.registry_discovery import find_registry + from aipass.aipass.shared.registry_discovery import find_registry (tmp_path / "AIPASS_REGISTRY.json").write_text("{}", encoding="utf-8") result = find_registry(start_path=tmp_path) @@ -366,7 +366,7 @@ class TestFindRegistry: def test_fallback_when_missing(self, tmp_path: Path) -> None: """Shared find_registry returns fallback when no registry in isolated dir.""" - from aipass.common.registry_discovery import find_registry + from aipass.aipass.shared.registry_discovery import find_registry isolated = tmp_path / "no_registry" isolated.mkdir() diff --git a/src/aipass/common/__init__.py b/src/aipass/common/__init__.py deleted file mode 100644 index 4321460c..00000000 --- a/src/aipass/common/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# aipass.common — shared leaf utilities (no branch dependencies) diff --git a/src/aipass/spawn/.seedgo/bypass.json b/src/aipass/spawn/.seedgo/bypass.json index c4bbc0a8..8858505f 100644 --- a/src/aipass/spawn/.seedgo/bypass.json +++ b/src/aipass/spawn/.seedgo/bypass.json @@ -103,7 +103,7 @@ { "file": "apps/handlers/json/json_handler.py", "standard": "naming", - "reason": "Module-level names are function re-exports from aipass.common.json_handler.JsonHandler, not constants. Lowercase is correct for callable bindings." + "reason": "Module-level names are function re-exports from aipass.aipass.shared.json_handler.JsonHandler, not constants. Lowercase is correct for callable bindings." }, { "file": "apps/handlers/delete_ops.py", diff --git a/src/aipass/spawn/apps/handlers/json/json_handler.py b/src/aipass/spawn/apps/handlers/json/json_handler.py index 28361e76..01580975 100644 --- a/src/aipass/spawn/apps/handlers/json/json_handler.py +++ b/src/aipass/spawn/apps/handlers/json/json_handler.py @@ -1,12 +1,12 @@ # =================== AIPass ==================== # Name: json_handler.py -# Description: Spawn JSON handler — configured instance of aipass.common +# Description: Spawn JSON handler — configured instance of aipass.aipass.shared # Version: 3.0.0 # Created: 2026-03-07 -# Modified: 2026-06-06 +# Modified: 2026-06-10 # ============================================= -"""Spawn JSON handler — thin shim over aipass.common.json_handler. +"""Spawn JSON handler — thin shim over aipass.aipass.shared.json_handler. Creates a JsonHandler instance configured with spawn's json_dir. All functions are re-exported for backward-compatible imports. @@ -14,7 +14,7 @@ All functions are re-exported for backward-compatible imports. from pathlib import Path -from aipass.common.json_handler import JsonHandler +from aipass.aipass.shared.json_handler import JsonHandler _SPAWN_ROOT = Path(__file__).resolve().parents[3] _JSON_DIR = _SPAWN_ROOT / "spawn_json" diff --git a/src/aipass/spawn/apps/handlers/json_ops.py b/src/aipass/spawn/apps/handlers/json_ops.py index 7fb0b9a3..6637004a 100644 --- a/src/aipass/spawn/apps/handlers/json_ops.py +++ b/src/aipass/spawn/apps/handlers/json_ops.py @@ -1,13 +1,13 @@ # =================== AIPass ==================== # Name: json_ops.py -# Description: JSON operations — re-exports from aipass.common +# Description: JSON operations — re-exports from aipass.aipass.shared # Version: 2.0.0 # Created: 2026-03-07 -# Modified: 2026-06-06 +# Modified: 2026-06-10 # ============================================= -"""JSON operations — thin re-export from aipass.common.json_ops.""" +"""JSON operations — thin re-export from aipass.aipass.shared.json_ops.""" -from aipass.common.json_ops import backup_json, deep_merge +from aipass.aipass.shared.json_ops import backup_json, deep_merge __all__ = ["deep_merge", "backup_json"] diff --git a/src/aipass/spawn/apps/handlers/registry.py b/src/aipass/spawn/apps/handlers/registry.py index 41a78fe5..21bfc368 100644 --- a/src/aipass/spawn/apps/handlers/registry.py +++ b/src/aipass/spawn/apps/handlers/registry.py @@ -3,7 +3,7 @@ # Description: *_REGISTRY.json discovery and CRUD operations # Version: 1.0.0 # Created: 2026-03-05 -# Modified: 2026-03-13 +# Modified: 2026-06-10 # ============================================= """*_REGISTRY.json discovery and CRUD operations.""" @@ -34,7 +34,7 @@ def branches_as_list(branches): def find_registry(start_path=None): - """Find *_REGISTRY.json — delegates to aipass.common.registry_discovery. + """Find *_REGISTRY.json — delegates to aipass.aipass.shared.registry_discovery. Passes spawn's package root as the fallback for editable installs. @@ -44,7 +44,7 @@ def find_registry(start_path=None): Returns: Path to *_REGISTRY.json. """ - from aipass.common.registry_discovery import find_registry as _common_find + from aipass.aipass.shared.registry_discovery import find_registry as _common_find return _common_find(start_path=start_path, package_root=Path(__file__).resolve().parent) diff --git a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json index 1c54b2d8..b051554a 100644 --- a/src/aipass/spawn/templates/builder/.spawn/.template_registry.json +++ b/src/aipass/spawn/templates/builder/.spawn/.template_registry.json @@ -1,7 +1,7 @@ { "metadata": { "version": "1.0.0", - "last_updated": "2026-06-07", + "last_updated": "2026-06-10", "description": "Template file tracking registry for ID-based updates" }, "files": { @@ -149,7 +149,7 @@ "content_hash": "a4cf0a8e3b4f", "has_branch_placeholder": false }, - "f018": { + "f015": { "path": "apps/modules/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", @@ -263,7 +263,7 @@ "content_hash": "28e9ae373563", "has_branch_placeholder": false }, - "f015": { + "f018": { "path": "apps/plugins/__init__.py", "name": "__init__.py", "content_hash": "e3b0c44298fc", diff --git a/src/aipass/spawn/tests/test_json_handler.py b/src/aipass/spawn/tests/test_json_handler.py index 1dd50db1..5bc3ac6b 100644 --- a/src/aipass/spawn/tests/test_json_handler.py +++ b/src/aipass/spawn/tests/test_json_handler.py @@ -186,7 +186,7 @@ def isolate_json_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setattr(_mod, _JSON_DIR_ATTR, str(tmp_path)) else: monkeypatch.setattr(_mod, _JSON_DIR_ATTR, tmp_path) - # Patch the JsonHandler instance if one exists (aipass.common migration) + # Patch the JsonHandler instance if one exists (aipass.aipass.shared migration) if hasattr(_mod, "_handler") and hasattr(_mod._handler, "_json_dir"): monkeypatch.setattr(_mod._handler, "_json_dir", tmp_path) return tmp_path From 53340ab169ab8e276a6826ee0d5b9bc1cfdad031 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 22:09:01 -0700 Subject: [PATCH 15/20] =?UTF-8?q?fix(seedgo):=20audit=20local=3D=3DCI=20pa?= =?UTF-8?q?rity=20=E2=80=94=20output-dir=20skips=20+=20diagnostics=20fail-?= =?UTF-8?q?honesty=20+=20pyright=20determinism=20(FPLAN-0261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hoist per-checker SKIP_DIRS to shared SOURCE_SKIP_DIRS (artifacts/dropbox are output dirs, not source; no git coupling). Diagnostics: python3->sys.executable, parse/run failures now fail loud instead of silent 0-errors-clean, and pin pyright resolution with --pythonpath sys.executable. drone bypasses test-only broker start_background. Proven all-13-branches-100% deterministic in an unactivated shell (local==CI). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++ src/aipass/drone/.seedgo/bypass.json | 6 ++ src/aipass/seedgo/.seedgo/bypass.json | 10 ++ .../handlers/aipass_proof/content_naming.py | 3 +- .../apps/handlers/aipass_proof/interface.py | 3 +- .../apps/handlers/aipass_proof/triplet.py | 3 +- .../aipass_standards/dead_code_check.py | 26 +---- .../handlers/aipass_standards/skip_dirs.py | 50 ++++++++++ .../aipass_standards/test_quality_check.py | 24 +---- .../aipass_standards/unused_function_check.py | 26 +---- .../handlers/diagnostics/diagnostics_check.py | 97 ++++++++++++++----- 11 files changed, 169 insertions(+), 97 deletions(-) create mode 100644 src/aipass/seedgo/apps/handlers/aipass_standards/skip_dirs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 07d14c4a..59d1fa68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ## [2026.W24] - 2026-06-08 +### Fixed + +- **seedgo audit local↔CI parity (FPLAN-0261).** The local `seedgo` audit could + silently diverge from CI, breaking the "pass locally first, then ship" gate. + Three independent causes, all closed without coupling any checker to git + (`.gitignore` is git's concern, not the audit's): (1) usage-scanning checkers + (`unused_function`, `dead_code`, +4) `rglob`'d gitignored *output* dirs + (`artifacts/`, `dropbox/`), so a stray local file could mark a function "used" + that a clean checkout correctly flags — every per-checker skip list hoisted to + one shared `SOURCE_SKIP_DIRS` (output dirs simply aren't source). (2) The + `diagnostics` standard shelled out to bare `python3 -m pyright` (system python, + no pyright) and, on the resulting JSON-parse failure, returned *0 errors = clean* + — a silent false-green; now uses `sys.executable` and **fails loud**. (3) pyright + resolved imports against PATH-python, so results flipped with `.venv` activation + — pinned via `--pythonpath sys.executable`. The audit is now deterministic + local == CI (proven all-13-branches-100% in an unactivated shell). Also: `drone` + bypasses the test-only broker `start_background` (intentional API, not dead code). + ### Added - **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).** diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index 458d05b9..f6559622 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -266,6 +266,12 @@ "standard": "unused_function", "lines": [655], "reason": "get_introspective() called dynamically via getattr() by module_registry_handler.py:219 for internal module introspection. Also tested in test_git_module, test_system_pr, test_devpulse_plugins, test_git_access." + }, + { + "file": "apps/handlers/broker/daemon.py", + "standard": "unused_function", + "lines": [420], + "reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code." } ], "notes": { diff --git a/src/aipass/seedgo/.seedgo/bypass.json b/src/aipass/seedgo/.seedgo/bypass.json index f44de80d..0acabab0 100644 --- a/src/aipass/seedgo/.seedgo/bypass.json +++ b/src/aipass/seedgo/.seedgo/bypass.json @@ -261,6 +261,16 @@ { "file": "tests/test_coverage_arch_checklist.py", "reason": "Test file: outside 3-layer structure by convention, imports handlers directly for unit testing, and test functions omit docstrings by pytest convention." + }, + { + "file": "apps/handlers/aipass_standards/skip_dirs.py", + "standard": "json_structure", + "reason": "Pure constants module — defines SOURCE_SKIP_DIRS frozenset only, no operations to log." + }, + { + "file": "apps/handlers/aipass_proof/", + "standard": "handlers", + "reason": "Proof handlers import SOURCE_SKIP_DIRS from aipass_standards/skip_dirs.py — shared constant, same-branch cross-handler import, intentional (FPLAN-0261)." } ], "notes": { diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py b/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py index b81ae026..f2b7e1f0 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/content_naming.py @@ -26,9 +26,10 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS # Directories to skip during scanning -_SKIP_DIRS = {".archive", ".sorting_unprocessed", "__pycache__"} +_SKIP_DIRS = SOURCE_SKIP_DIRS def _parse_public_functions(file_path: Path) -> list[str]: diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py b/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py index e06b075d..096028ff 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/interface.py @@ -28,11 +28,12 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS VALID_SCOPES = {"all_files", "entry_point", "branch_level"} # Directories / files to skip inside the pack -_SKIP_DIRS = frozenset({".archive", ".sorting_unprocessed"}) +_SKIP_DIRS = SOURCE_SKIP_DIRS # -- AST helpers --------------------------------------------------------------- diff --git a/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py b/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py index f8f870e2..671eb64a 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py +++ b/src/aipass/seedgo/apps/handlers/aipass_proof/triplet.py @@ -23,9 +23,10 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS # Directories to skip entirely -_SKIP_DIRS = {".archive", ".sorting_unprocessed", "__pycache__"} +_SKIP_DIRS = SOURCE_SKIP_DIRS def _top_level_files(pack_dir: Path) -> list[Path]: diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py index 0e521676..f8b95016 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/dead_code_check.py @@ -24,34 +24,12 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS AUDIT_SCOPE = "branch_level" # Directories to skip when collecting source files -_SKIP_DIRS = { - "__pycache__", - ".archive", - ".mypy_cache", - ".ruff_cache", - ".pytest_cache", - "json_templates", - "logs", - "tools", - ".venv", - "venv", - "node_modules", - ".git", - "site-packages", - ".trinity", - ".aipass", - ".ai_mail.local", - ".spawn", - "backups", - "reports", - "docs", - "tests", - ".sorting_unprocessed", -} +_SKIP_DIRS = SOURCE_SKIP_DIRS | {"tests", "json_templates"} # ============================================= diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/skip_dirs.py b/src/aipass/seedgo/apps/handlers/aipass_standards/skip_dirs.py new file mode 100644 index 00000000..3d5b89f7 --- /dev/null +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/skip_dirs.py @@ -0,0 +1,50 @@ +# =================== AIPass ==================== +# Name: skip_dirs.py +# Description: Shared source-skip directories for branch-tree-scanning checkers +# Version: 1.0.0 +# Created: 2026-06-10 +# Modified: 2026-06-10 +# ============================================= + +"""Shared skip-directory set for checkers that rglob branch trees. + +Single source of truth so per-checker lists cannot drift. +Output dirs (artifacts, dropbox, docs.local, system_logs) are runtime +products, not committed source — scanning them causes local-vs-CI +audit divergence (FPLAN-0261). +""" + +SOURCE_SKIP_DIRS: frozenset[str] = frozenset( + { + # Build / cache + "__pycache__", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + # Environment + ".venv", + "venv", + "node_modules", + ".git", + "site-packages", + # Archive / staging + ".archive", + ".sorting_unprocessed", + # Project infrastructure (not auditable source) + ".trinity", + ".aipass", + ".ai_mail.local", + ".spawn", + # Output / runtime (gitignored — causes local-vs-CI divergence) + "artifacts", + "dropbox", + "docs.local", + "system_logs", + # Data / non-source + "logs", + "tools", + "backups", + "reports", + "docs", + } +) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py index 2771ba42..1c3026cc 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/test_quality_check.py @@ -29,32 +29,12 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS AUDIT_SCOPE = "branch_level" # -- Directories to skip when scanning for module coverage -------------------- -SKIP_DIRS: set[str] = { - "__pycache__", - ".archive", - ".mypy_cache", - ".ruff_cache", - ".pytest_cache", - ".venv", - "venv", - "node_modules", - ".git", - "site-packages", - "logs", - "tools", - ".trinity", - ".aipass", - ".ai_mail.local", - ".spawn", - "backups", - "reports", - "docs", - ".sorting_unprocessed", -} +SKIP_DIRS: set[str] = set(SOURCE_SKIP_DIRS) # -- Regex patterns for module coverage (from test_coverage_check.py) --------- RE_TEST_FUNC = re.compile(r"^\s*(?:async\s+)?def\s+(test_\w+)", re.MULTILINE) diff --git a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py index 6e13b8a1..7752a24a 100644 --- a/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py +++ b/src/aipass/seedgo/apps/handlers/aipass_standards/unused_function_check.py @@ -32,34 +32,12 @@ from pathlib import Path from aipass.prax import logger from aipass.seedgo.apps.handlers.json import json_handler from aipass.seedgo.apps.handlers.bypass.utils import is_bypassed +from aipass.seedgo.apps.handlers.aipass_standards.skip_dirs import SOURCE_SKIP_DIRS AUDIT_SCOPE = "branch_level" # -- Directories to skip when collecting source files ------------------------- -SKIP_DIRS = { - "__pycache__", - ".archive", - "logs", - "tests", - "json_templates", - "tools", - ".trinity", - ".aipass", - ".ai_mail.local", - ".venv", - "venv", - "node_modules", - ".git", - "site-packages", - ".mypy_cache", - ".ruff_cache", - ".pytest_cache", - ".spawn", - "backups", - "reports", - "docs", - ".sorting_unprocessed", -} +SKIP_DIRS = SOURCE_SKIP_DIRS | {"tests", "json_templates"} # -- Function names excluded from analysis ------------------------------------ EXCLUDED_NAMES = { diff --git a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py index 4b98c7e0..caaf75ba 100644 --- a/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py +++ b/src/aipass/seedgo/apps/handlers/diagnostics/diagnostics_check.py @@ -25,13 +25,12 @@ from typing import Dict, List, Optional from rich.console import Console -console = Console() - -# Import ignore patterns from bypass handler from aipass.prax import logger from aipass.seedgo.apps.handlers.bypass.ignore_handler import get_audit_ignore_patterns from aipass.seedgo.apps.handlers.json import json_handler +console = Console() + AUDIT_SCOPE = "branch_level" # Diagnostics handler directory (where this file lives) @@ -86,19 +85,29 @@ def check_file(file_path: str) -> Dict: try: # Run pyright with JSON output result = subprocess.run( - ["python3", "-m", "pyright", "--outputjson", str(path)], capture_output=True, text=True, timeout=30 + [sys.executable, "-m", "pyright", "--pythonpath", sys.executable, "--outputjson", str(path)], + capture_output=True, + text=True, + timeout=30, ) # Parse JSON output try: output = json.loads(result.stdout) except json.JSONDecodeError: - logger.info("Failed to parse pyright JSON output for %s", file_path) + logger.error("Failed to parse pyright JSON output for %s", file_path) return { "file": str(file_path), - "errors": 0, + "errors": 1, "warnings": 0, - "diagnostics": [], + "diagnostics": [ + { + "line": 0, + "severity": "error", + "message": "Pyright failed to run — cannot parse output", + "rule": "", + } + ], "error": f"Failed to parse pyright output: {result.stderr or result.stdout}", } @@ -125,11 +134,23 @@ def check_file(file_path: str) -> Dict: return {"file": str(file_path), "errors": errors, "warnings": warnings, "diagnostics": diagnostics} except subprocess.TimeoutExpired: - logger.info("Pyright timed out for %s", file_path) - return {"file": str(file_path), "errors": 0, "warnings": 0, "diagnostics": [], "error": "Pyright timed out"} + logger.error("Pyright timed out for %s", file_path) + return { + "file": str(file_path), + "errors": 1, + "warnings": 0, + "diagnostics": [{"line": 0, "severity": "error", "message": "Pyright timed out", "rule": ""}], + "error": "Pyright timed out", + } except Exception as e: - logger.info("Pyright check failed for %s: %s", file_path, e) - return {"file": str(file_path), "errors": 0, "warnings": 0, "diagnostics": [], "error": str(e)} + logger.error("Pyright check failed for %s: %s", file_path, e) + return { + "file": str(file_path), + "errors": 1, + "warnings": 0, + "diagnostics": [{"line": 0, "severity": "error", "message": f"Pyright failed: {e}", "rule": ""}], + "error": str(e), + } def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: @@ -164,7 +185,7 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: # Run pyright on entire directory for efficiency try: result = subprocess.run( - ["python3", "-m", "pyright", "--outputjson", str(path)], + [sys.executable, "-m", "pyright", "--pythonpath", sys.executable, "--outputjson", str(path)], capture_output=True, text=True, timeout=300, # 5 minutes for full directory @@ -173,13 +194,27 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: try: output = json.loads(result.stdout) except json.JSONDecodeError: - logger.info("Failed to parse pyright JSON output for directory %s", directory) + logger.error("Failed to parse pyright JSON output for directory %s", directory) return { "total_files": 0, - "files_with_errors": 0, - "total_errors": 0, + "files_with_errors": 1, + "total_errors": 1, "total_warnings": 0, - "results": [], + "results": [ + { + "file": str(directory), + "errors": 1, + "warnings": 0, + "diagnostics": [ + { + "line": 0, + "severity": "error", + "message": "Pyright failed to run — cannot parse output", + "rule": "", + } + ], + } + ], "error": "Failed to parse pyright output", } @@ -227,23 +262,37 @@ def check_directory(directory: str, pattern: str = "**/*.py") -> Dict: } except subprocess.TimeoutExpired: - logger.info("Pyright timed out for directory %s", directory) + logger.error("Pyright timed out for directory %s", directory) return { "total_files": 0, - "files_with_errors": 0, - "total_errors": 0, + "files_with_errors": 1, + "total_errors": 1, "total_warnings": 0, - "results": [], + "results": [ + { + "file": str(directory), + "errors": 1, + "warnings": 0, + "diagnostics": [{"line": 0, "severity": "error", "message": "Pyright timed out", "rule": ""}], + } + ], "error": "Pyright timed out (directory too large?)", } except Exception as e: - logger.info("Pyright directory check failed for %s: %s", directory, e) + logger.error("Pyright directory check failed for %s: %s", directory, e) return { "total_files": 0, - "files_with_errors": 0, - "total_errors": 0, + "files_with_errors": 1, + "total_errors": 1, "total_warnings": 0, - "results": [], + "results": [ + { + "file": str(directory), + "errors": 1, + "warnings": 0, + "diagnostics": [{"line": 0, "severity": "error", "message": f"Pyright failed: {e}", "rule": ""}], + } + ], "error": str(e), } From e33cf2bfbe162578e3393546e37218b2d10363ea Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 22:50:44 -0700 Subject: [PATCH 16/20] =?UTF-8?q?fix(ci):=20guard=20Linux-only=20sandbox?= =?UTF-8?q?=20tests=20for=20Windows=20=E2=80=94=20skipif(sys.platform=20!?= =?UTF-8?q?=3D=20linux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_broker.py (AF_UNIX sockets + openat2) and test_sandbox.py (bwrap) are Linux-only; module-level pytestmark skips them on windows-latest while leaving Linux runs unchanged. Unblocks the windows-setup CI check (red since the sandbox build 0b4ba63). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ src/aipass/drone/tests/test_broker.py | 5 +++++ src/aipass/hooks/tests/test_sandbox.py | 3 +++ 3 files changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59d1fa68..dd4e2c04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,12 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format — pinned via `--pythonpath sys.executable`. The audit is now deterministic local == CI (proven all-13-branches-100% in an unactivated shell). Also: `drone` bypasses the test-only broker `start_background` (intentional API, not dead code). +- **windows-setup CI: guard Linux-only sandbox tests.** The kernel-sandbox build + is Linux-only (bwrap, `AF_UNIX` sockets, `openat2`); the code already guards on + `sys.platform`, but `drone/tests/test_broker.py` and `hooks/tests/test_sandbox.py` + ran unconditionally and failed on `windows-latest`. Added module-level + `pytestmark = pytest.mark.skipif(sys.platform != "linux", …)` so they skip on + Windows and run unchanged on Linux. ### Added diff --git a/src/aipass/drone/tests/test_broker.py b/src/aipass/drone/tests/test_broker.py index 4e81d51b..b9ea145d 100644 --- a/src/aipass/drone/tests/test_broker.py +++ b/src/aipass/drone/tests/test_broker.py @@ -16,6 +16,7 @@ client broker_delete / create_identified_connection, and rm broker routing. from __future__ import annotations +import sys import hashlib import hmac as hmac_mod import json @@ -38,6 +39,10 @@ from aipass.drone.apps.handlers.broker.client import ( ) from aipass.drone.apps.handlers.json import json_handler +pytestmark = pytest.mark.skipif( + sys.platform != "linux", + reason="broker is Linux-only: AF_UNIX sockets + openat2 RESOLVE_BENEATH", +) json_handler.log_operation("test_broker_load", {}) diff --git a/src/aipass/hooks/tests/test_sandbox.py b/src/aipass/hooks/tests/test_sandbox.py index 147b2d4a..83c94009 100644 --- a/src/aipass/hooks/tests/test_sandbox.py +++ b/src/aipass/hooks/tests/test_sandbox.py @@ -9,11 +9,14 @@ """Tests for apps/modules/sandbox.py.""" +import sys from pathlib import Path from unittest.mock import MagicMock, patch import pytest +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="sandbox is Linux-only: bwrap mount namespaces") + class TestBuildSrtConfig: """Config generation from policy dict.""" From 707f54a6f2c62416b5f6c58931cb992a0ec21f9a Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 23:04:52 -0700 Subject: [PATCH 17/20] =?UTF-8?q?fix(ci):=20guard=20remaining=20AF=5FUNIX?= =?UTF-8?q?=20broker=20tests=20for=20Windows=20=E2=80=94=20ai=5Fmail=20+?= =?UTF-8?q?=20aipass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 13 +++++++++---- src/aipass/ai_mail/tests/test_dispatch_monitor.py | 1 + src/aipass/aipass/tests/test_sandbox_check.py | 3 +++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd4e2c04..f710f5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,10 +29,15 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format bypasses the test-only broker `start_background` (intentional API, not dead code). - **windows-setup CI: guard Linux-only sandbox tests.** The kernel-sandbox build is Linux-only (bwrap, `AF_UNIX` sockets, `openat2`); the code already guards on - `sys.platform`, but `drone/tests/test_broker.py` and `hooks/tests/test_sandbox.py` - ran unconditionally and failed on `windows-latest`. Added module-level - `pytestmark = pytest.mark.skipif(sys.platform != "linux", …)` so they skip on - Windows and run unchanged on Linux. + `sys.platform`, but four test surfaces ran unconditionally and failed on + `windows-latest`. Module-level `pytestmark = pytest.mark.skipif(sys.platform != + "linux", …)` on `drone/tests/test_broker.py` and `hooks/tests/test_sandbox.py`; + scoped guards on the remaining `AF_UNIX` broker-socket tests — + `ai_mail/.../test_dispatch_monitor.py::TestBrokerRealE2E` (class) and + `aipass/.../test_sandbox_check.py::TestCheckBrokerAlive` socket-connect tests + (method-level, so the graceful no-broker paths still run on Windows). All skip on + Windows and run unchanged on Linux. windows-setup was green pre-sandbox-merge + (`00edd8b`) and red since (`0b4ba63`); this closes it. ### Added diff --git a/src/aipass/ai_mail/tests/test_dispatch_monitor.py b/src/aipass/ai_mail/tests/test_dispatch_monitor.py index 903ab95f..a1fae84f 100644 --- a/src/aipass/ai_mail/tests/test_dispatch_monitor.py +++ b/src/aipass/ai_mail/tests/test_dispatch_monitor.py @@ -1721,6 +1721,7 @@ class TestBrokerFdHandshake: assert env_snapshots[0]["AIPASS_BROKER_FD"] == "10" +@pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker daemon is Linux-only") class TestBrokerRealE2E: """Real multi-process e2e: broker daemon, identified connection, child reads fd.""" diff --git a/src/aipass/aipass/tests/test_sandbox_check.py b/src/aipass/aipass/tests/test_sandbox_check.py index 1e7099cc..09ddfefa 100644 --- a/src/aipass/aipass/tests/test_sandbox_check.py +++ b/src/aipass/aipass/tests/test_sandbox_check.py @@ -11,6 +11,7 @@ import shutil import socket import subprocess +import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -291,6 +292,7 @@ class TestCheckBrokerAlive: assert result["alive"] is False assert "missing" in result["detail"] + @pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only") def test_socket_connect_success(self, tmp_path): ai_central = tmp_path / ".ai_central" ai_central.mkdir() @@ -306,6 +308,7 @@ class TestCheckBrokerAlive: finally: server.close() + @pytest.mark.skipif(sys.platform != "linux", reason="AF_UNIX broker sockets are Linux-only") def test_socket_connect_refused(self, tmp_path): ai_central = tmp_path / ".ai_central" ai_central.mkdir() From ed17630b766385315999f82e9756a33dde8fdcd7 Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Wed, 10 Jun 2026 23:23:56 -0700 Subject: [PATCH 18/20] =?UTF-8?q?fix(drone):=20broker=20start=5Fbackground?= =?UTF-8?q?=20blocks=20until=20listening=20=E2=80=94=20kill=20connect-befo?= =?UTF-8?q?re-bind=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 +++++++++ src/aipass/drone/.seedgo/bypass.json | 2 +- src/aipass/drone/apps/handlers/broker/daemon.py | 9 +++++++-- src/aipass/drone/tests/test_broker.py | 5 ----- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f710f5f0..702c28f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format (method-level, so the graceful no-broker paths still run on Windows). All skip on Windows and run unchanged on Linux. windows-setup was green pre-sandbox-merge (`00edd8b`) and red since (`0b4ba63`); this closes it. +- **Broker `start_background` connect-before-bind race.** `drone`'s out-of-sandbox + broker daemon started via `start_background()`, which returned *before* the + `AF_UNIX` socket was bound — callers then raced the bind, and on a slower machine + `create_identified_connection()` hit `FileNotFoundError` (socket not yet present). + Deterministic locally (`test_delete_nested_file` 0/5), green in CI only by timing + luck — latent flakiness. Fixed with a `threading.Event` set right after `listen()`; + `start_background(timeout=5.0)` now blocks on it and **raises** if the socket never + binds, so callers never guess a `sleep`. Removed the 4 blind `time.sleep(0.15)` + waits from the broker tests. Verified 55/55 broker tests, formerly-failing test 10/10. ### Added diff --git a/src/aipass/drone/.seedgo/bypass.json b/src/aipass/drone/.seedgo/bypass.json index f6559622..92c590d0 100644 --- a/src/aipass/drone/.seedgo/bypass.json +++ b/src/aipass/drone/.seedgo/bypass.json @@ -270,7 +270,7 @@ { "file": "apps/handlers/broker/daemon.py", "standard": "unused_function", - "lines": [420], + "lines": [422], "reason": "Threaded broker entrypoint, exercised by tests/test_broker.py; production uses blocking start(); intentionally not called in shipped non-test code." } ], diff --git a/src/aipass/drone/apps/handlers/broker/daemon.py b/src/aipass/drone/apps/handlers/broker/daemon.py index 1a9db1b4..4414422d 100644 --- a/src/aipass/drone/apps/handlers/broker/daemon.py +++ b/src/aipass/drone/apps/handlers/broker/daemon.py @@ -125,6 +125,7 @@ class BrokerDaemon: self._secret: bytes = b"" self._server: socket.socket | None = None self._running = False + self._listening = threading.Event() self._lock = threading.Lock() json_handler.log_operation( "broker_init", @@ -400,6 +401,7 @@ class BrokerDaemon: self._server.listen(5) self._server.settimeout(1.0) self._running = True + self._listening.set() logger.info("broker: listening on %s", self.socket_path) json_handler.log_operation("broker_start", {"socket": str(self.socket_path)}) @@ -417,10 +419,13 @@ class BrokerDaemon: logger.error("broker: accept error: %s", exc) break - def start_background(self) -> threading.Thread: - """Start the broker in a background thread. Returns the thread.""" + def start_background(self, timeout: float = 5.0) -> threading.Thread: + """Start the broker in a background thread, blocking until listening.""" + self._listening.clear() t = threading.Thread(target=self.start, daemon=True, name="drone-broker") t.start() + if not self._listening.wait(timeout): + raise RuntimeError(f"broker failed to start listening within {timeout}s") return t def stop(self) -> None: diff --git a/src/aipass/drone/tests/test_broker.py b/src/aipass/drone/tests/test_broker.py index b9ea145d..5692da41 100644 --- a/src/aipass/drone/tests/test_broker.py +++ b/src/aipass/drone/tests/test_broker.py @@ -23,7 +23,6 @@ import json import socket import os import stat -import time from pathlib import Path import pytest @@ -125,7 +124,6 @@ def broker(tmp_path: Path, repo_root: Path) -> BrokerDaemon: def running_broker(broker: BrokerDaemon): """Start a broker in background, yield it, stop on teardown.""" t = broker.start_background() - time.sleep(0.15) yield broker broker.stop() t.join(timeout=3) @@ -367,7 +365,6 @@ class TestBrokerDaemon: def test_stop_cleans_socket(self, broker: BrokerDaemon) -> None: """Stopping the broker removes the socket file.""" t = broker.start_background() - time.sleep(0.15) assert broker.socket_path.exists() broker.stop() t.join(timeout=3) @@ -574,7 +571,6 @@ class TestIdentity: secret_path=secret_path, ) t1 = d1.start_background() - time.sleep(0.15) secret1 = secret_path.read_bytes() d1.stop() t1.join(timeout=3) @@ -586,7 +582,6 @@ class TestIdentity: secret_path=secret_path, ) t2 = d2.start_background() - time.sleep(0.15) secret2 = secret_path.read_bytes() d2.stop() t2.join(timeout=3) From 54d55abebc4ea5c8542775021b6d39f1cf1e1e1b Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 11 Jun 2026 00:12:32 -0700 Subject: [PATCH 19/20] feat(aipass): init detects missing Claude Code, offers install (yes/no) --- CHANGELOG.md | 7 +++ src/aipass/aipass/apps/modules/init_flow.py | 51 ++++++++++++++++++ src/aipass/aipass/tests/test_init_flow.py | 60 +++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 702c28f0..6bf23f0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format ### Added +- **`aipass init` detects missing Claude Code.** Stage 6 (CLI choice) now checks + `shutil.which("claude")` when the picked CLI is Claude Code. If absent: interactive + runs prompt `Install now? [Y/n]` and run the canonical installer on yes (native + `claude.ai/install.sh`, PowerShell on Windows, `npm` fallback, 300s timeout, loud on + failure); non-interactive runs warn and continue. The whole system routes through + Claude Code (hook bridge, dispatch, prompt injection), so init no longer silently + assumes the runtime is present. Only fires when the chosen CLI is `claude`. - **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).** Every autonomous agent can now launch inside a kernel-enforced mount namespace (`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open diff --git a/src/aipass/aipass/apps/modules/init_flow.py b/src/aipass/aipass/apps/modules/init_flow.py index f776efc4..86b02a31 100644 --- a/src/aipass/aipass/apps/modules/init_flow.py +++ b/src/aipass/aipass/apps/modules/init_flow.py @@ -377,6 +377,54 @@ def stage_5_style_questions( return {"style": style} +def _install_claude_code() -> bool: + """Run the canonical Claude Code installer, platform-aware. Returns True on success.""" + if sys.platform == "win32": + cmd = ["powershell", "-Command", "irm https://claude.ai/install.ps1 | iex"] + else: + cmd = ["bash", "-c", "curl -fsSL https://claude.ai/install.sh | bash"] + + try: + result = subprocess.run(cmd, timeout=300) + if result.returncode == 0 and shutil.which("claude"): + return True + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.warning("[init_flow] Claude Code installer failed: %s", exc) + + if shutil.which("npm"): + console.print("[dim]Native installer didn't work — trying npm fallback...[/dim]") + try: + result = subprocess.run( + ["npm", "install", "-g", "@anthropic-ai/claude-code"], + timeout=300, + ) + if result.returncode == 0 and shutil.which("claude"): + return True + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.warning("[init_flow] npm fallback install failed: %s", exc) + + return False + + +def _handle_missing_claude(non_interactive: bool) -> None: + """Prompt to install Claude Code when missing, or warn in non-interactive mode.""" + if non_interactive: + warning("[bold yellow]Claude Code ('claude') is not installed.[/bold yellow]") + console.print(" Stage 11 handoff requires it. Install manually before then.") + return + + raw = _prompt("Claude Code ('claude') not found. Install now? [Y/n]", "Y") + if raw.lower() in ("y", "yes", ""): + console.print("[dim]Installing Claude Code...[/dim]") + if _install_claude_code(): + console.print("[green]✓[/green] Claude Code installed successfully.") + else: + warning("[bold yellow]Installation failed.[/bold yellow]") + console.print(" Install manually: https://claude.ai/download") + else: + console.print("[dim]Skipped. Stage 11 handoff will need 'claude' on PATH.[/dim]") + + def stage_6_tool_choice( non_interactive: bool = False, cli_override: str | None = None, @@ -393,6 +441,9 @@ def stage_6_tool_choice( else: cli_choice = _choose("Which CLI tool do you use?", CLI_CHOICES, default="claude") + if cli_choice == "claude" and not shutil.which("claude"): + _handle_missing_claude(non_interactive) + if non_interactive: flag_variant = "default" else: diff --git a/src/aipass/aipass/tests/test_init_flow.py b/src/aipass/aipass/tests/test_init_flow.py index a6b60b58..7e8b3251 100644 --- a/src/aipass/aipass/tests/test_init_flow.py +++ b/src/aipass/aipass/tests/test_init_flow.py @@ -488,6 +488,66 @@ class TestStages: result = stage_6_tool_choice(non_interactive=True, cli_override="codex") assert result["cli"] == "codex" + def test_stage_6_claude_present_no_prompt(self, tmp_local_json) -> None: + """When claude is on PATH, no install prompt is shown.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch(f"{_MOD}.console"): + with patch(f"{_MOD}.shutil.which", return_value="/usr/bin/claude"): + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + with patch(f"{_MOD}._handle_missing_claude") as mock_handle: + result = stage_6_tool_choice(non_interactive=True) + mock_handle.assert_not_called() + assert result["cli"] == "claude" + + @patch(f"{_MOD}._choose", return_value="default") + @patch(f"{_MOD}._install_claude_code", return_value=True) + @patch(f"{_MOD}._prompt", return_value="Y") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_interactive_yes( + self, _con, _which, _prompt, mock_install, _choose, tmp_local_json + ) -> None: + """Missing claude + interactive + yes → installer invoked.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=False, cli_override="claude") + mock_install.assert_called_once() + assert result["cli"] == "claude" + + @patch(f"{_MOD}._choose", return_value="default") + @patch(f"{_MOD}._install_claude_code") + @patch(f"{_MOD}._prompt", return_value="n") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_interactive_no( + self, _con, _which, _prompt, mock_install, _choose, tmp_local_json + ) -> None: + """Missing claude + interactive + no → no install, continues.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=False, cli_override="claude") + mock_install.assert_not_called() + assert result["cli"] == "claude" + + @patch(f"{_MOD}.warning") + @patch(f"{_MOD}._install_claude_code") + @patch(f"{_MOD}.shutil.which", return_value=None) + @patch(f"{_MOD}.console") + def test_stage_6_claude_missing_non_interactive_warns( + self, _con, _which, mock_install, mock_warn, tmp_local_json + ) -> None: + """Missing claude + non-interactive → warning, no install.""" + mock_profile_mod = MagicMock() + mock_profile_mod.get_user_profile.return_value = {} + with patch.dict("sys.modules", {"aipass.aipass.apps.modules.profile": mock_profile_mod}): + result = stage_6_tool_choice(non_interactive=True) + mock_install.assert_not_called() + mock_warn.assert_called_once() + assert result["cli"] == "claude" + def test_stage_7_skipped_when_no_docker(self, tmp_local_json) -> None: """Docker offer is skipped when has_docker=False.""" with patch(f"{_MOD}.console"): From 2af856d81d21aa48fe84988ff46b2529c1646f2d Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 11 Jun 2026 12:49:07 -0700 Subject: [PATCH 20/20] =?UTF-8?q?chore(release):=20v2.5.3=20=E2=80=94=20da?= =?UTF-8?q?te-based=20CHANGELOG=20headers=20+=20rename=20sunday=5Fmerge=20?= =?UTF-8?q?playbook=20to=20merge=20(drop=20weekly=20cadence)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 15 ++++++----- pyproject.toml | 2 +- src/aipass/__init__.py | 2 +- .../devpulse/.aipass/aipass_local_prompt.md | 2 +- src/aipass/flow/apps/modules/create_plan.py | 2 +- .../flow/apps/modules/template_manager.py | 4 +-- .../flow/templates/playbook_plans/default.md | 2 +- .../{sunday_merge.md => merge.md} | 26 +++++++++---------- 8 files changed, 28 insertions(+), 27 deletions(-) rename src/aipass/flow/templates/playbook_plans/{sunday_merge.md => merge.md} (89%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf23f0d..ddc1cd2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,14 @@ All notable changes to AIPass will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project uses [Calendar Versioning](https://calver.org/) in the format -`YYYY.WNN` (year and ISO week number). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +Entries are grouped by merge under a dated section header (`YYYY-MM-DD`). Package +releases follow [SemVer](https://semver.org/) and are tracked by the git tag and +PyPI version — not the changelog header. --- -## [2026.W24] - 2026-06-08 +## [2026-06-11] ### Fixed @@ -152,7 +153,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format --- -## [2026.W23] - 2026-06-02 +## [2026-06-02] ### Fixed @@ -493,7 +494,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format --- -## [2026.W22] - 2026-05-30 +## [2026-05-30] ### Added @@ -555,7 +556,7 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format --- -## [2026.W21] - 2026-05-25 +## [2026-05-25] First weekly release. AIPass now follows a Sunday release cadence: changes accumulate on `dev` throughout the week and merge to `main` as a single diff --git a/pyproject.toml b/pyproject.toml index faf0d209..951b18ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "aipass" -version = "2.5.2" +version = "2.5.3" 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" diff --git a/src/aipass/__init__.py b/src/aipass/__init__.py index dc734538..a79a2acc 100644 --- a/src/aipass/__init__.py +++ b/src/aipass/__init__.py @@ -4,4 +4,4 @@ pip install aipass https://github.com/AIOSAI/AIPass """ -__version__ = "2.5.2" +__version__ = "2.5.3" diff --git a/src/aipass/devpulse/.aipass/aipass_local_prompt.md b/src/aipass/devpulse/.aipass/aipass_local_prompt.md index 0493c7f6..af37ea27 100644 --- a/src/aipass/devpulse/.aipass/aipass_local_prompt.md +++ b/src/aipass/devpulse/.aipass/aipass_local_prompt.md @@ -102,7 +102,7 @@ drone, seedgo, prax, cli, ai_mail, api, flow, spawn, trigger, memory, aipass, ho - CWD = identity. Visit other branches, don't move in. - Git awareness: after completing work, `drone @git status`. Suggest commit if coherent. Don't force, don't let pile up. - Git workflow: commit → dev-pr → wait for CI. Every commit must be pushed. Local-only commits are invisible. After fixing CI, push immediately (dev-pr reports "PR already open" = pushed). -- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries to the current week's `[YYYY.WNN]` section as work lands — don't batch at end of week. Sunday = merge to main + tag. +- CHANGELOG: update `CHANGELOG.md` when committing/pushing. Add entries under the current dated section (`[YYYY-MM-DD]`, one per merge) as work lands — don't batch. Merge to main + tag on demand (no fixed weekly cadence). - Never `docker cp` into containers. Merge PR → git pull → test. - Sub-agents build, you PR. diff --git a/src/aipass/flow/apps/modules/create_plan.py b/src/aipass/flow/apps/modules/create_plan.py index c4be5617..401d9b4e 100755 --- a/src/aipass/flow/apps/modules/create_plan.py +++ b/src/aipass/flow/apps/modules/create_plan.py @@ -153,7 +153,7 @@ def print_help(): console.print("[yellow]TEMPLATE SELECTION:[/yellow]") console.print(" The 4th arg selects a non-default template within a type.") console.print(" Any .md file stem in the type's templates/ dir works.") - console.print(' [dim]drone @flow create . "Subject" sunday_merge pplan[/dim]') + console.print(' [dim]drone @flow create . "Subject" merge pplan[/dim]') console.print(' [dim]drone @flow create . "Subject" master[/dim] # FPLAN master') console.print() diff --git a/src/aipass/flow/apps/modules/template_manager.py b/src/aipass/flow/apps/modules/template_manager.py index a5eac074..2562192b 100644 --- a/src/aipass/flow/apps/modules/template_manager.py +++ b/src/aipass/flow/apps/modules/template_manager.py @@ -88,12 +88,12 @@ def print_help(): console.print() console.print("[bold]HOW TO ADD A NEW PLAN TYPE / SOP TEMPLATE:[/bold]") console.print(" 1. Create a directory under templates/ (e.g. templates/playbook_plans/)") - console.print(" 2. Add one or more .md template files (e.g. default.md, sunday_merge.md)") + console.print(" 2. Add one or more .md template files (e.g. default.md, merge.md)") console.print(" 3. Register with your chosen prefix:") console.print(" drone @flow register playbook_plans PBPLAN") console.print(" 4. Create plans:") console.print(' drone @flow create . "Subject" pbplan') - console.print(' drone @flow create . "Subject" sunday_merge pbplan') + console.print(' drone @flow create . "Subject" merge pplan') console.print() console.print(" [dim]Auto-registration runs on any flow command if you skip step 3,[/dim]") console.print(" [dim]but derives the prefix automatically. Use register to choose your own.[/dim]") diff --git a/src/aipass/flow/templates/playbook_plans/default.md b/src/aipass/flow/templates/playbook_plans/default.md index 0ef8f107..960e38af 100644 --- a/src/aipass/flow/templates/playbook_plans/default.md +++ b/src/aipass/flow/templates/playbook_plans/default.md @@ -10,7 +10,7 @@ ## What Are Playbooks? Playbooks (PBPLANs) are **throwaway SOP runs** — a checklist stamped from a reusable -template for a recurring operation (Sunday merge, release cut, branch onboarding, +template for a recurring operation (merge, release cut, branch onboarding, incident response). You tick steps off as you go, log what happened, then close. - **The template = the SOP.** Stable. Refine it over time as the process improves. diff --git a/src/aipass/flow/templates/playbook_plans/sunday_merge.md b/src/aipass/flow/templates/playbook_plans/merge.md similarity index 89% rename from src/aipass/flow/templates/playbook_plans/sunday_merge.md rename to src/aipass/flow/templates/playbook_plans/merge.md index 3649beab..53b2061c 100644 --- a/src/aipass/flow/templates/playbook_plans/sunday_merge.md +++ b/src/aipass/flow/templates/playbook_plans/merge.md @@ -1,17 +1,17 @@ -# {plan_number} - {subject} (SUNDAY MERGE) +# {plan_number} - {subject} (MERGE) **Created**: {today} **Branch**: {location} **Status**: Active -**Type**: Playbook — Sunday Merge SOP +**Type**: Playbook — Merge SOP --- ## Purpose -The weekly `dev → main` merge + release tag. Run by **devpulse** (only branch with git -write). Tick each step as you go; fill the **Run Summary** with PR numbers and tags for -the vectorized trail. Close when done. +The `dev → main` merge + release tag — run on-demand, not on a fixed weekly cadence. +Run by **devpulse** (only branch with git write). Tick each step as you go; fill the +**Run Summary** with PR numbers and tags for the vectorized trail. Close when done. > All git writes go through `drone @git` — **run drone from a branch dir** (it needs > `.trinity/passport.json` in the cwd; running from the repo root fails with "No @@ -65,21 +65,21 @@ commit** on main. Your `dev` branch keeps its **original** commits. Git compares ## 1. Pre-flight - [ ] On `dev`, working tree understood: `drone @git status --all` -- [ ] Confirm what's shipping this week — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok) +- [ ] Confirm what's shipping — scan uncommitted changes + already-pushed dev commits ahead of main: `git rev-list --count main..dev` (read git, raw ok) - [ ] No surprise files (stray `/tmp` artifacts, test pollution, `.recovery`/`.archive` churn). Clean = archive, never delete. - [ ] **Version state check** (informs the bump decision): read the **two** release-tied versions — `grep '^version' pyproject.toml` and `grep __version__ src/aipass/__init__.py` (they should match; if drifted, note it) — and what PyPI already has: `curl -s https://pypi.org/pypi/aipass/json | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])"`. PyPI rejects a duplicate, so the target must be > published. -- [ ] Decide: **release tag this week?** (tag = PyPI publish + GitHub Release). If yes, note target version. (Significance call is the user's — the PATCH-default rule below is guidance, and the actual release history is a useful tie-breaker.) +- [ ] Decide: **release tag this merge?** (tag = PyPI publish + GitHub Release). If yes, note target version. (Significance call is the user's — the PATCH-default rule below is guidance, and the actual release history is a useful tie-breaker.) ## 2. Verify, commit, CHANGELOG - [ ] **Run the CI audit gate LOCALLY before pushing** (local == CI, S199 parity — catches red before the PR): `cd && .venv/bin/python .github/scripts/seedgo_audit.py` → expect all 13 branches `>=100%`, exit 0. Uses a relative `src/aipass` path, so run from the repo **root**, not a branch dir. -- [ ] Update `CHANGELOG.md` — add entries under the current week's `[YYYY.WNN]` section (don't batch; mostly done as work landed). Sort into Added / Changed / Fixed. +- [ ] Update `CHANGELOG.md` — add entries under a dated section header `## [YYYY-MM-DD]` (the merge date), one section per merge. Sort into Added / Changed / Fixed. - [ ] Commit: `drone @git commit "msg" --all` (from a branch dir, e.g. devpulse). New/untracked files (e.g. new templates) — confirm they got staged: `git ls-files ` after; `--all` may not pick up untracked. - [ ] Every commit pushed — local-only commits are invisible ## 3. Open / update the PR -- [ ] `drone @git dev-pr "Week summary: what's shipping"` +- [ ] `drone @git dev-pr "Merge summary: what's shipping"` - [ ] "PR already open" in output = push succeeded onto the existing PR (expected on re-runs) - [ ] Record the PR number → Run Summary @@ -110,12 +110,12 @@ The PR gate (verified against `.github/workflows/`): ## 7. Release tag (only if cutting a release) -**Versioning rule — bump by SIGNIFICANCE, not cadence** (keeps the version from inflating weekly): -- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default, most weeks +**Versioning rule — bump by SIGNIFICANCE, not cadence:** +- **PATCH** (`x.y.Z+1`) = fix / internal / standards / UX only → the default for most merges - **MINOR** (`x.Y+1.0`) = a new backward-compatible user-facing feature shipped - **MAJOR** (`X+1.0.0`) = breaking public-API change -(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG keeps its `YYYY.WNN` header as a date index.) +(aipass is a 2.x library others pin → keep SemVer; the CHANGELOG uses `YYYY-MM-DD` dated section headers.) How the release fires (verified `publish.yml`): a `v*` **git tag push** runs build → PyPI publish → GitHub Release. Key facts: - PyPI version = `pyproject.toml [project] version` at the tagged commit — **NOT** the tag string (the tag only *triggers* the build). @@ -159,7 +159,7 @@ Steps: ## Listen (TTS-friendly summary) -Write a plain English summary of this Sunday merge here when done. No markdown, no symbols, +Write a plain English summary of this merge here when done. No markdown, no symbols, no tables, no code blocks, no asterisks, no bullet points. Just natural sentences for text to speech. ---