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