feat(sandbox): kernel filesystem boundary for agent containment (DPLAN-0202/FPLAN-0250)

Every autonomous agent can launch inside a kernel-enforced mount namespace
(srt -> bwrap+seccomp): reads stay open (shared live FS preserved, bind-mount not
isolation; own-tree writes land live), but rm/python/find/Write on .git or sibling
trees hit EROFS. /tmp + own tree writable; .git RW devpulse, RO builders. Inert by
default behind AIPASS_SANDBOX_ENABLED (off); flag-off path byte-identical to old.

hooks: srt wrapper + per-role build_policy + broker_secret mask; rm_gate demoted.
drone: out-of-sandbox broker (identity allowlist, openat2 RESOLVE_BENEATH, HMAC
handshake over inherited fd, audit); drone rm via broker when sandboxed.
ai_mail: dispatch gate + broker-fd wiring (fail-loud exit -4, never silent).
aipass: doctor Sandbox group + setup.sh prereqs (LOUD on missing).

Proven by a live 16-check red-team suite. seedgo 100% + 2859 tests green across
all 5 touched branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AIOSAI
2026-06-10 13:16:22 -07:00
co-authored by Claude Opus 4.8
parent 0c6e8ac425
commit 0b4ba63fae
29 changed files with 5448 additions and 245 deletions
+21
View File
@@ -12,6 +12,27 @@ and this project uses [Calendar Versioning](https://calver.org/) in the format
### Added ### 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 - **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 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 prior copy stays in the conversation. They now fire together every 5th turn
+86
View File
@@ -226,6 +226,92 @@ if [ "$IS_WINDOWS" -eq 1 ]; then
fi fi
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 --- # --- Verify CLI entry points ---
FAIL=0 FAIL=0
+6 -1
View File
@@ -93,7 +93,12 @@
{ {
"file": "apps/handlers/dispatch/dispatch_monitor.py", "file": "apps/handlers/dispatch/dispatch_monitor.py",
"standard": "handlers", "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", "file": "apps/handlers/dispatch/wake.py",
@@ -23,6 +23,8 @@ is guaranteed.
import json import json
import os import os
import shlex
import socket
import sys import sys
import subprocess import subprocess
import time import time
@@ -41,6 +43,43 @@ HARD_TIMEOUT = 7200 # 2 hours
POLL_INTERVAL = 5 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: 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.""" """Send return-to-sender bounce email via drone."""
subject = f"BOUNCE: Dispatch to {branch_email} failed" 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( 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: ) -> tuple:
""" """
Run claude with startup timeout check. 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) logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e)
try: try:
process = subprocess.Popen( popen_kwargs = {
claude_cmd, "stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL, "stderr": stderr_fh,
stderr=stderr_fh, "cwd": cwd,
cwd=cwd, "env": spawn_env,
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: except Exception as e:
logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e) logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e)
if stdout_fh is not None: if stdout_fh is not None:
@@ -338,6 +380,11 @@ def main():
start_time = time.time() 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 ───────────────────────────── # ─── Retry Loop: 3 Strikes ─────────────────────────────
# Strike 1: original command (resume if -c was passed) # Strike 1: original command (resume if -c was passed)
# Strike 2: same command again (transient failure) # Strike 2: same command again (transient failure)
@@ -356,14 +403,60 @@ def main():
cmd = claude_cmd cmd = claude_cmd
mode = "resume" if has_resume else "fresh" 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: if stderr_fh is not None:
stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n") stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n")
stderr_fh.flush() stderr_fh.flush()
exit_code, startup_failed = _run_with_startup_check( 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}) attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode})
# Success — done # Success — done
@@ -9,7 +9,9 @@
"""Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting.""" """Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting."""
import json import json
import os
import subprocess import subprocess
import sys
import time import time
import pytest import pytest
from pathlib import Path from pathlib import Path
@@ -20,11 +22,13 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
_check_jsonl_activity, _check_jsonl_activity,
_check_rate_limited, _check_rate_limited,
_get_jsonl_projects_dir, _get_jsonl_projects_dir,
_is_sandbox_enabled,
_kill_process, _kill_process,
_make_fresh_cmd, _make_fresh_cmd,
_run_with_startup_check, _run_with_startup_check,
_send_bounce, _send_bounce,
_snapshot_jsonl_sizes, _snapshot_jsonl_sizes,
_wrap_for_sandbox,
main, 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 = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log"
stdout_log.parent.mkdir(parents=True, exist_ok=True) 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 # Simulate writing max_turns output
stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8") stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
return (0, False) return (0, False)
@@ -810,7 +814,7 @@ def test_env_vars_set_correctly(monkeypatch, main_argv):
captured_env = {} 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) captured_env.update(env)
return (0, False) 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 = branch_dir / "logs" / "dispatch_stdout.log"
stdout_log.parent.mkdir(parents=True, exist_ok=True) 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 # Write max_turns stop_reason into stdout log
Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8") Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
return (0, False) return (0, False)
@@ -1075,7 +1079,7 @@ def test_env_vars_setup(monkeypatch, main_argv):
captured_env = {} 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) captured_env.update(env)
return (0, False) 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): def test_check_jsonl_activity_missing_dir(tmp_path):
"""Nonexistent directory -> False.""" """Nonexistent directory -> False."""
assert _check_jsonl_activity(tmp_path / "nope", {}) is 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)
@@ -570,13 +570,18 @@ class TestDispatchEnvIsolation:
) )
def test_dispatch_monitor_passes_spawn_env_to_subprocess(self): 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 Without this, all env var isolation is useless — the subprocess
would inherit os.environ instead of the cleaned spawn_env. 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() 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): 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, """When AIPASS_CALLER_BRANCH is set but CWD is outside any branch,
+15
View File
@@ -275,6 +275,21 @@
"file": "apps/handlers/json/json_handler.py", "file": "apps/handlers/json/json_handler.py",
"standard": "test_quality", "standard": "test_quality",
"reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test." "reason": "save_json now raises ValueError on invalid structure (aipass.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."
} }
] ]
} }
@@ -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",
]
@@ -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 <prefix>/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")
+107 -2
View File
@@ -23,6 +23,16 @@ from aipass.prax import logger
from aipass.common.registry_discovery import find_registry as _discover_registry 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.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 ( from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_placement, check_placement,
check_pyproject, check_pyproject,
@@ -545,11 +555,105 @@ def _check_structure() -> List[CheckResult]:
return results 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 --- # --- Main doctor run ---
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int: 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()
console.print("[bold cyan]aipass doctor[/bold cyan]") console.print("[bold cyan]aipass doctor[/bold cyan]")
console.print() console.print()
@@ -560,6 +664,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
("Services", lambda: _check_services(verbose=verbose)), ("Services", lambda: _check_services(verbose=verbose)),
("Community", _check_community), ("Community", _check_community),
("Structure", _check_structure), ("Structure", _check_structure),
("Sandbox", _check_sandbox),
] ]
groups: Dict[str, List[CheckResult]] = {} groups: Dict[str, List[CheckResult]] = {}
with make_doctor_progress() as progress: with make_doctor_progress() as progress:
@@ -620,7 +725,7 @@ def print_introspection() -> None:
console.print("[bold cyan]doctor Module[/bold cyan]") console.print("[bold cyan]doctor Module[/bold cyan]")
console.print("System health aggregation — flutter-doctor-style output") console.print("System health aggregation — flutter-doctor-style output")
console.print() 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("[yellow]Next:[/yellow] [green]aipass doctor[/green] / [green]aipass doctor --fix[/green]")
console.print() console.print()
@@ -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"
)
+25
View File
@@ -84,6 +84,31 @@
"standard": "help_text", "standard": "help_text",
"file": "tools/hook_engine_poc/test_engine.py", "file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — usage example in docstring." "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": { "notes": {
+61
View File
@@ -183,6 +183,67 @@
"standard": "trigger", "standard": "trigger",
"reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events." "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", "file": "CLAUDE.md",
"standard": "architecture", "standard": "architecture",
+13 -5
View File
@@ -143,7 +143,8 @@ drone/
│ │ ├── registry.py # Registry query operations │ │ ├── registry.py # Registry query operations
│ │ ├── commands.py # Custom command shortcut orchestrator │ │ ├── commands.py # Custom command shortcut orchestrator
│ │ ├── git_module.py # Git workflow (tier-based access, 16 commands) │ │ ├── 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 │ ├── handlers/ # Implementation details
│ │ ├── executor.py # Safe subprocess execution (timeout, no shell) │ │ ├── executor.py # Safe subprocess execution (timeout, no shell)
│ │ ├── exceptions.py # Exception hierarchy (10 exception types) │ │ ├── exceptions.py # Exception hierarchy (10 exception types)
@@ -153,6 +154,11 @@ drone/
│ │ ├── module_registry_handler.py # Module loading (internal + external) │ │ ├── module_registry_handler.py # Module loading (internal + external)
│ │ ├── generic_adapter.py # StringIO capture for external modules │ │ ├── generic_adapter.py # StringIO capture for external modules
│ │ ├── routing_config.json # External module declarations │ │ ├── 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/
│ │ │ └── json_handler.py # Structured operation logging │ │ │ └── json_handler.py # Structured operation logging
│ │ ├── scanning/ │ │ ├── scanning/
@@ -187,7 +193,8 @@ drone/
│ └── hook_sounds_plugin.py.disabled │ └── hook_sounds_plugin.py.disabled
├── docs/ # Public documentation ├── docs/ # Public documentation
├── docs.local/ # Investigation reports and policies ├── 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 ### Routing Flow
@@ -325,7 +332,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
## Testing ## Testing
704 tests across 21 test files, covering all layers: 807 tests across 22 test files, covering all layers:
| Area | Files | Tests | | 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 | | 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 | | 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 | | 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 | | 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` 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) [← Back to AIPass](../../../README.md)
@@ -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
@@ -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}"
@@ -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/<branch>/).
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", {})
@@ -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)
@@ -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", ""),
)
@@ -146,6 +146,11 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
Returns a list of ``(original_path, success, message)`` tuples. Returns a list of ``(original_path, success, message)`` tuples.
Every path is checked independently; a refused path does not block others. 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() roots = get_allowed_roots()
if not roots: if not roots:
return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths] return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths]
+119
View File
@@ -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 <command>")
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")
+12
View File
@@ -20,6 +20,10 @@ from aipass.drone.apps.handlers.json import json_handler
from aipass.drone.apps.handlers.rm_handler import ( from aipass.drone.apps.handlers.rm_handler import (
safe_delete as _safe_delete, 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 = { DRONE_MODULE = {
"name": "rm", "name": "rm",
@@ -32,8 +36,16 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
"""Delete paths with containment checks. """Delete paths with containment checks.
Returns list of ``(original_path, success, message)`` tuples. 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)) 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) return _safe_delete(paths)
+853
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+35 -6
View File
@@ -51,7 +51,8 @@ src/aipass/hooks/
│ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating) │ │ ├── cadence.py # Prompt injection cadence (every-Nth-turn gating)
│ │ ├── engine.py # Core dispatch — routes events to handlers │ │ ├── engine.py # Core dispatch — routes events to handlers
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off) │ │ ├── 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/ │ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization) │ │ ├── bridges/ # One per provider (thin normalization)
│ │ │ └── claude.py # Claude Code bridge │ │ │ └── claude.py # Claude Code bridge
@@ -62,7 +63,7 @@ src/aipass/hooks/
│ │ ├── security/ # Enforcement hooks │ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics) │ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
│ │ │ ├── git_gate.py # Enforces git access tiers │ │ │ ├── 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 │ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
│ │ ├── lifecycle/ # Session management hooks │ │ ├── lifecycle/ # Session management hooks
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile) │ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
@@ -79,7 +80,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution │ └── diagnostics.py # JSONL logging for hook execution
├── logs/ ├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution) │ └── engine.jsonl # JSONL diagnostics (every hook execution)
└── tests/ # 435 tests across 21 test files └── tests/ # 472 tests across 22 test files
``` ```
## How It Works ## How It Works
@@ -101,13 +102,40 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description | | Event | Hooks | Description |
|---|---|---| |---|---|---|
| UserPromptSubmit | identity, email, branch_loader, global_loader | Prompt injection + inbox check | | 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 | | PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation | | SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound | Achievement bell | | Stop | stop_sound | Achievement bell |
| Notification | announce | Announcement tone | | Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover | | 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 ## Integration Points
### Depends On ### Depends On
@@ -118,9 +146,10 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
### Provides To ### 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*
--- ---
@@ -1,14 +1,19 @@
# =================== AIPass ==================== # =================== AIPass ====================
# Name: rm_gate.py # Name: rm_gate.py
# Version: 1.0.0 # Version: 1.0.0
# Description: Blocks raw recursive rm commands (PreToolUse) # Description: Guardrail — catches accidental rm -rf and teaches drone rm (PreToolUse)
# Branch: hooks # Branch: hooks
# Layer: apps/handlers/security # Layer: apps/handlers/security
# Created: 2026-06-02 # Created: 2026-06-02
# Modified: 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 json
import re import re
@@ -17,10 +22,10 @@ from aipass.prax.apps.modules.logger import system_logger as logger
RM_REDIRECT = ( RM_REDIRECT = (
"Raw recursive rm is blocked. Use the safe contained delete instead:\n" "Heads up — raw recursive rm is not the right tool here. Use:\n"
" drone rm <path> # safe delete (allows project + /tmp, refuses outside)\n" " drone rm <path> # project-aware delete (allows project + /tmp, refuses outside)\n"
"\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} _BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
@@ -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 <config.json> <command>\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);
}
+284
View File
@@ -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
+484
View File
@@ -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