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
- **Kernel filesystem boundary for agent containment (DPLAN-0202 / FPLAN-0250).**
Every autonomous agent can now launch inside a kernel-enforced mount namespace
(`@anthropic-ai/sandbox-runtime` → bwrap+seccomp) where reads stay fully open
(the shared live filesystem is preserved — a bind-mount, *not* isolation: own-tree
writes land live on the real FS instantly) but deletes/overwrites of protected
paths (`.git`, sibling branch trees) fail at the kernel no matter how the call is
phrased — `rm`, `python os.remove`, `find -delete`, Write tool all hit EROFS.
`/tmp` and the agent's own tree stay writable; `.git` is RW for devpulse, RO for
builders. A per-role policy generator (`@hooks build_policy`) derives each branch's
writable/RO map from its passport. Privileged deletes route through an
out-of-sandbox **drone-broker** daemon: identity-scoped allowlist, `openat2`
RESOLVE_BENEATH path re-resolution (confused-deputy proof), HMAC identity handshake
over a pre-connected inherited fd, JSONL audit. `aipass doctor` gained a **Sandbox**
check group (bwrap present+functional, node, srt, rg, broker socket) that is LOUD
when the flag is on and a prereq is missing — never a silent unsandboxed launch.
Proven by a live 16-check red-team suite. **Inert by default** — gated behind
`AIPASS_SANDBOX_ENABLED` (off); flag-off is byte-identical to the old dispatch path.
- **rm_gate demoted to guardrail.** Now framed honestly as early-feedback that
catches the accidental `rm -rf` and teaches `drone rm` — belt-and-suspenders, with
the kernel sandbox as the actual filesystem boundary.
- **Prompt-injection cadence — fire the big loaders every Nth turn.** The global
and branch prompts are large and were re-injected on *every* turn even though a
prior copy stays in the conversation. They now fire together every 5th turn
+86
View File
@@ -226,6 +226,92 @@ if [ "$IS_WINDOWS" -eq 1 ]; then
fi
fi
# --- Sandbox prerequisites (kernel FS boundary) ---
echo ""
echo "Checking sandbox prerequisites ..."
if [ "$IS_WINDOWS" -eq 1 ] || [ "$IS_MACOS" -eq 1 ]; then
echo " kernel sandbox: Linux-only for now, skipping"
else
SB_MISSING=()
# bwrap
if command -v bwrap &>/dev/null; then
echo " bwrap ... $(bwrap --version 2>/dev/null || echo 'found')"
else
echo " bwrap ... MISSING"
echo " sudo apt install bubblewrap"
SB_MISSING+=("bwrap")
fi
# node
if command -v node &>/dev/null; then
echo " node ... $(node --version 2>/dev/null)"
else
echo " node ... MISSING"
echo " Install Node.js: https://nodejs.org/"
SB_MISSING+=("node")
fi
# npm (needed for srt install)
if command -v npm &>/dev/null; then
echo " npm ... $(npm --version 2>/dev/null)"
else
echo " npm ... MISSING"
SB_MISSING+=("npm")
fi
# @anthropic-ai/sandbox-runtime — resolve same way as _srt_resolve.mjs
if command -v node &>/dev/null; then
SRT_PATH=$(node -e "
const p = require('path');
const fs = require('fs');
const prefix = p.dirname(p.dirname(process.execPath));
const entry = p.join(prefix, 'lib/node_modules/@anthropic-ai/sandbox-runtime/dist/index.js');
if (fs.existsSync(entry)) process.stdout.write(entry);
else process.exit(1);
" 2>/dev/null) || SRT_PATH=""
if [ -n "$SRT_PATH" ]; then
echo " srt ... $SRT_PATH"
else
echo " srt ... MISSING"
if command -v npm &>/dev/null; then
echo " Attempting: npm install -g @anthropic-ai/sandbox-runtime"
if npm install -g @anthropic-ai/sandbox-runtime 2>/dev/null; then
echo " srt ... installed"
else
echo " Install failed (may need sudo). Run manually:"
echo " sudo npm install -g @anthropic-ai/sandbox-runtime"
SB_MISSING+=("srt")
fi
else
echo " Install node+npm first, then: npm install -g @anthropic-ai/sandbox-runtime"
SB_MISSING+=("srt")
fi
fi
else
echo " srt ... skipped (no node)"
SB_MISSING+=("srt")
fi
# rg (ripgrep)
if command -v rg &>/dev/null; then
echo " rg ... $(rg --version 2>/dev/null | head -1)"
elif [ -f "$HOME/.local/bin/rg" ]; then
echo " rg ... $HOME/.local/bin/rg"
else
echo " rg ... MISSING"
echo " sudo apt install ripgrep"
SB_MISSING+=("rg")
fi
if [ ${#SB_MISSING[@]} -eq 0 ]; then
echo " sandbox prereqs: READY"
else
echo " sandbox prereqs: INCOMPLETE (${SB_MISSING[*]} missing) — aipass doctor for details"
fi
fi
# --- Verify CLI entry points ---
FAIL=0
+6 -1
View File
@@ -93,7 +93,12 @@
{
"file": "apps/handlers/dispatch/dispatch_monitor.py",
"standard": "handlers",
"reason": "Imports notify.send_notification — same-branch cross-handler import for bounce/completion notifications."
"reason": "Imports notify.send_notification (same-branch cross-handler) for bounce/completion notifications. Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() for Phase 6b broker-fd handshake (FPLAN-0250) — cross-branch handler import authorized by brief."
},
{
"file": "apps/handlers/dispatch/dispatch_monitor.py",
"standard": "encapsulation",
"reason": "Lazy-imports drone.apps.handlers.broker.client.create_identified_connection inside _connect_broker() — cross-branch handler import for Phase 6b broker-fd handshake (FPLAN-0250). Brief explicitly authorizes this import path."
},
{
"file": "apps/handlers/dispatch/wake.py",
@@ -23,6 +23,8 @@ is guaranteed.
import json
import os
import shlex
import socket
import sys
import subprocess
import time
@@ -41,6 +43,43 @@ HARD_TIMEOUT = 7200 # 2 hours
POLL_INTERVAL = 5
def _is_sandbox_enabled() -> bool:
"""Check if dispatch sandbox is enabled via AIPASS_SANDBOX_ENABLED env var."""
return os.environ.get("AIPASS_SANDBOX_ENABLED", "").lower() in ("1", "true", "yes")
def _wrap_for_sandbox(cmd: list, branch_path: Path) -> list:
"""Wrap a claude command in the srt kernel sandbox.
Uses @hooks sandbox building blocks to resolve the bwrap command,
then returns a shell invocation list compatible with Popen.
Raises on ANY failure — caller must not silently fall back to unsandboxed.
"""
from aipass.hooks.apps.modules.sandbox import build_policy, build_srt_config, resolve_bwrap_command
policy = build_policy(branch_path)
srt_config = build_srt_config(policy)
cmd_str = shlex.join(cmd)
bwrap_cmd = resolve_bwrap_command(cmd_str, srt_config)
return ["/bin/bash", "-c", bwrap_cmd]
def _connect_broker(repo_root: Path, branch_name: str) -> socket.socket:
"""Create an identified broker connection for the target branch.
Returns a connected, HMAC-authenticated socket ready to be inherited
by the sandboxed child via pass_fds + AIPASS_BROKER_FD.
Raises on ANY failure — caller must not silently skip the broker.
"""
from aipass.drone.apps.handlers.broker.client import create_identified_connection
socket_path = repo_root / ".ai_central" / "drone_broker.sock"
secret_path = repo_root / ".ai_central" / "broker_secret"
return create_identified_connection(socket_path, secret_path, branch_name)
def _send_bounce(branch_email: str, reason: str, sender: str, lock_file: str, stderr_log: str) -> bool:
"""Send return-to-sender bounce email via drone."""
subject = f"BOUNCE: Dispatch to {branch_email} failed"
@@ -176,7 +215,7 @@ def _kill_process(process: subprocess.Popen, branch_email: str):
def _run_with_startup_check(
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str
claude_cmd: list, stdout_log: str, stderr_fh, cwd: str, spawn_env: dict, branch_email: str, pass_fds: tuple = ()
) -> tuple:
"""
Run claude with startup timeout check.
@@ -194,13 +233,16 @@ def _run_with_startup_check(
logger.warning("[monitor] Failed to open stdout log %s: %s", stdout_log, e)
try:
process = subprocess.Popen(
claude_cmd,
stdout=stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
stderr=stderr_fh,
cwd=cwd,
env=spawn_env,
)
popen_kwargs = {
"stdout": stdout_fh if stdout_fh is not None else subprocess.DEVNULL,
"stderr": stderr_fh,
"cwd": cwd,
"env": spawn_env,
}
if pass_fds:
popen_kwargs["close_fds"] = True
popen_kwargs["pass_fds"] = pass_fds
process = subprocess.Popen(claude_cmd, **popen_kwargs)
except Exception as e:
logger.warning("[monitor] Failed to spawn %s: %s", branch_email, e)
if stdout_fh is not None:
@@ -338,6 +380,11 @@ def main():
start_time = time.time()
# ─── Sandbox Gate ─────────────────────────────────────
sandbox_enabled = _is_sandbox_enabled()
if sandbox_enabled:
logger.info("[monitor] Sandbox ENABLED for %s", branch_email)
# ─── Retry Loop: 3 Strikes ─────────────────────────────
# Strike 1: original command (resume if -c was passed)
# Strike 2: same command again (transient failure)
@@ -356,14 +403,60 @@ def main():
cmd = claude_cmd
mode = "resume" if has_resume else "fresh"
# Sandbox wrap + broker fd: when enabled, wrap cmd and connect broker.
# On failure: abort — NEVER silently launch unsandboxed.
run_cmd = cmd
broker_sock = None
attempt_pass_fds: tuple = ()
if sandbox_enabled:
try:
run_cmd = _wrap_for_sandbox(cmd, branch_path)
except Exception as e:
logger.error(
"[monitor] Sandbox init FAILED for %s: %s — ABORTING (will NOT launch unsandboxed)",
branch_email,
e,
)
exit_code = -4
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
break
try:
broker_sock = _connect_broker(_repo_root, branch_email.lstrip("@"))
broker_fd = broker_sock.fileno()
spawn_env["AIPASS_BROKER_FD"] = str(broker_fd)
attempt_pass_fds = (broker_fd,)
logger.info("[monitor] Broker fd %d connected for %s", broker_fd, branch_email)
except Exception as e:
logger.error(
"[monitor] Broker connect FAILED for %s: %s — ABORTING",
branch_email,
e,
)
exit_code = -4
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": False, "mode": mode})
break
if stderr_fh is not None:
stderr_fh.write(f"\n--- Attempt {attempt}/3 ({mode}) at {time.strftime('%H:%M:%S')} ---\n")
stderr_fh.flush()
exit_code, startup_failed = _run_with_startup_check(
cmd, stdout_log, stderr_fh if stderr_fh is not None else subprocess.DEVNULL, cwd, spawn_env, branch_email
run_cmd,
stdout_log,
stderr_fh if stderr_fh is not None else subprocess.DEVNULL,
cwd,
spawn_env,
branch_email,
pass_fds=attempt_pass_fds,
)
# Close parent's broker socket copy — child owns the fd now.
if broker_sock is not None:
broker_sock.close()
broker_sock = None
spawn_env.pop("AIPASS_BROKER_FD", None)
attempts.append({"attempt": attempt, "exit_code": exit_code, "startup_failed": startup_failed, "mode": mode})
# Success — done
@@ -9,7 +9,9 @@
"""Tests for dispatch_monitor -- startup check, retry loop, bounce, rate limiting."""
import json
import os
import subprocess
import sys
import time
import pytest
from pathlib import Path
@@ -20,11 +22,13 @@ from aipass.ai_mail.apps.handlers.dispatch.dispatch_monitor import (
_check_jsonl_activity,
_check_rate_limited,
_get_jsonl_projects_dir,
_is_sandbox_enabled,
_kill_process,
_make_fresh_cmd,
_run_with_startup_check,
_send_bounce,
_snapshot_jsonl_sizes,
_wrap_for_sandbox,
main,
)
@@ -634,7 +638,7 @@ def test_max_turns_changes_notification_status(monkeypatch, main_argv):
stdout_log = Path(str(lock_file)).parent.parent / "logs" / "dispatch_stdout.log"
stdout_log.parent.mkdir(parents=True, exist_ok=True)
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
# Simulate writing max_turns output
stdout_log.write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
return (0, False)
@@ -810,7 +814,7 @@ def test_env_vars_set_correctly(monkeypatch, main_argv):
captured_env = {}
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
captured_env.update(env)
return (0, False)
@@ -900,7 +904,7 @@ def test_main_max_turns_detected(monkeypatch, main_argv):
stdout_log = branch_dir / "logs" / "dispatch_stdout.log"
stdout_log.parent.mkdir(parents=True, exist_ok=True)
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch):
def fake_run(cmd, stdout_log_path, stderr_fh, cwd, env, branch, **kwargs):
# Write max_turns stop_reason into stdout log
Path(stdout_log_path).write_text('{"stop_reason":"max_turns"}', encoding="utf-8")
return (0, False)
@@ -1075,7 +1079,7 @@ def test_env_vars_setup(monkeypatch, main_argv):
captured_env = {}
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch):
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
captured_env.update(env)
return (0, False)
@@ -1194,3 +1198,625 @@ def test_check_jsonl_activity_no_change(tmp_path):
def test_check_jsonl_activity_missing_dir(tmp_path):
"""Nonexistent directory -> False."""
assert _check_jsonl_activity(tmp_path / "nope", {}) is False
# --- Sandbox gate tests (Phase 4 FPLAN-0250) --------------------------------
class TestIsSandboxEnabled:
"""_is_sandbox_enabled reads AIPASS_SANDBOX_ENABLED from env."""
def test_unset_returns_false(self, monkeypatch):
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
assert _is_sandbox_enabled() is False
def test_empty_returns_false(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "")
assert _is_sandbox_enabled() is False
def test_false_string_returns_false(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "false")
assert _is_sandbox_enabled() is False
def test_zero_returns_false(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "0")
assert _is_sandbox_enabled() is False
def test_one_returns_true(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
assert _is_sandbox_enabled() is True
def test_true_returns_true(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "true")
assert _is_sandbox_enabled() is True
def test_yes_returns_true(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "yes")
assert _is_sandbox_enabled() is True
def test_TRUE_case_insensitive(self, monkeypatch):
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "TRUE")
assert _is_sandbox_enabled() is True
class TestFlagOffOldPath:
"""Flag OFF (default): dispatch uses the original cmd, no sandbox wrapping."""
def test_flag_off_cmd_unchanged(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
captured_cmds = []
def capture_run(cmd, *args, **kwargs):
captured_cmds.append(cmd)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert len(captured_cmds) == 1
assert captured_cmds[0] == ["claude", "-c", "--model", "opus"]
def test_flag_off_wrap_never_called(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
wrap_calls = []
original_wrap = mod._wrap_for_sandbox
def tracking_wrap(*args, **kwargs):
wrap_calls.append(args)
return original_wrap(*args, **kwargs)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_wrap_for_sandbox", tracking_wrap)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
assert wrap_calls == []
class TestFlagOnSandboxPath:
"""Flag ON: dispatch wraps cmd via _wrap_for_sandbox."""
def test_flag_on_cmd_wrapped(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
captured_cmds = []
def capture_run(cmd, *args, **kwargs):
captured_cmds.append(cmd)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap --sandbox " + " ".join(cmd)],
)
mock_sock = MagicMock()
mock_sock.fileno.return_value = 99
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert len(captured_cmds) == 1
assert captured_cmds[0][0] == "/bin/bash"
assert captured_cmds[0][1] == "-c"
assert "bwrap --sandbox" in captured_cmds[0][2]
def test_wrap_calls_building_blocks(self, monkeypatch, tmp_path):
call_log = []
def mock_build_policy(bp):
call_log.append("build_policy")
return {"allow_write": [str(bp)], "deny_write": [], "deny_read": []}
def mock_build_srt_config(policy):
call_log.append("build_srt_config")
return {"filesystem": {"allowWrite": policy["allow_write"]}}
def mock_resolve_bwrap(cmd_str, srt_config):
call_log.append("resolve_bwrap_command")
return f"bwrap --ro-bind / / {cmd_str}"
monkeypatch.setattr("aipass.hooks.apps.modules.sandbox.build_policy", mock_build_policy)
monkeypatch.setattr(
"aipass.hooks.apps.modules.sandbox.build_srt_config",
mock_build_srt_config,
)
monkeypatch.setattr(
"aipass.hooks.apps.modules.sandbox.resolve_bwrap_command",
mock_resolve_bwrap,
)
result = _wrap_for_sandbox(["claude", "--model", "opus"], tmp_path)
assert call_log == ["build_policy", "build_srt_config", "resolve_bwrap_command"]
assert result[0] == "/bin/bash"
assert result[1] == "-c"
assert "claude" in result[2]
class TestBrokenSandboxFailsLoud:
"""Flag ON but sandbox init fails: ABORT, never silently unsandbox."""
def test_sandbox_init_failure_aborts(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
run_calls = []
def capture_run(cmd, *args, **kwargs):
run_calls.append(cmd)
return (0, False)
def broken_wrap(cmd, bp):
raise RuntimeError("srt resolve failed: node not found")
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert run_calls == []
assert exc_info.value.code != 0
def test_sandbox_failure_sends_bounce(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
def broken_wrap(cmd, bp):
raise FileNotFoundError("node not found in PATH")
mock_bounce = MagicMock()
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(mod, "_wrap_for_sandbox", broken_wrap)
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
mock_bounce.assert_called_once()
reason = mock_bounce.call_args[0][1]
assert "sandbox" in reason.lower() or "-4" in reason
def test_never_falls_back_to_unsandboxed(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
wrap_calls = [0]
run_calls = []
def counting_broken_wrap(cmd, bp):
wrap_calls[0] += 1
raise RuntimeError("srt unavailable")
def capture_run(cmd, *args, **kwargs):
run_calls.append(cmd)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(mod, "_wrap_for_sandbox", counting_broken_wrap)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
assert wrap_calls[0] == 1
assert run_calls == []
# --- Broker-fd handshake tests (Phase 6b FPLAN-0250) -------------------------
class TestFlagOffNoBroker:
"""Flag OFF: no broker connection attempted at all."""
def test_flag_off_no_broker_activity(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
connect_calls = []
def tracking_connect(*args, **kwargs):
connect_calls.append(args)
raise RuntimeError("should never be called")
monkeypatch.setattr(mod, "_connect_broker", tracking_connect)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert connect_calls == []
def test_flag_off_no_broker_fd_in_env(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.delenv("AIPASS_SANDBOX_ENABLED", raising=False)
captured_env = {}
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, **kwargs):
captured_env.update(env)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
assert "AIPASS_BROKER_FD" not in captured_env
class TestBrokerDownFailsLoud:
"""Broker down + flag ON → exit -4, agent never spawned."""
def test_broker_connect_failure_aborts(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
run_calls = []
def capture_run(cmd, *args, **kwargs):
run_calls.append(cmd)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(
mod,
"_connect_broker",
MagicMock(side_effect=OSError("broker socket not found")),
)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert run_calls == []
assert exc_info.value.code != 0
def test_broker_bad_hmac_aborts(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
run_calls = []
def capture_run(cmd, *args, **kwargs):
run_calls.append(cmd)
return (0, False)
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(
mod,
"_connect_broker",
MagicMock(side_effect=RuntimeError("Broker identify failed: bad HMAC")),
)
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert run_calls == []
assert exc_info.value.code != 0
def test_broker_failure_sends_bounce(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
mock_bounce = MagicMock()
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(
mod,
"_connect_broker",
MagicMock(side_effect=OSError("socket missing")),
)
monkeypatch.setattr(mod, "_send_bounce", mock_bounce)
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
mock_bounce.assert_called_once()
class TestBrokerFdHandshake:
"""Flag ON + broker up: fd passed to child, parent closes after spawn."""
def test_broker_fd_in_env_and_pass_fds(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
captured_env = {}
captured_pass_fds = []
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
captured_env.update(env)
captured_pass_fds.append(pass_fds)
return (0, False)
mock_sock = MagicMock()
mock_sock.fileno.return_value = 42
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit) as exc_info:
main()
assert exc_info.value.code == 0
assert captured_env.get("AIPASS_BROKER_FD") == "42"
assert captured_pass_fds == [(42,)]
mock_sock.close.assert_called_once()
def test_parent_closes_socket_after_spawn(self, monkeypatch, main_argv):
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
mock_sock = MagicMock()
mock_sock.fileno.return_value = 7
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", MagicMock(return_value=(0, False)))
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
mock_sock.close.assert_called_once()
def test_broker_fd_cleaned_from_env_after_spawn(self, monkeypatch, main_argv):
"""After spawn+close, AIPASS_BROKER_FD removed from spawn_env."""
argv, lock_file, stderr_log = main_argv
monkeypatch.setenv("AIPASS_SANDBOX_ENABLED", "1")
env_snapshots = []
def capture_run(cmd, stdout_log, stderr_fh, cwd, env, branch, pass_fds=()):
env_snapshots.append(dict(env))
return (0, False)
mock_sock = MagicMock()
mock_sock.fileno.return_value = 10
monkeypatch.setattr("sys.argv", argv)
monkeypatch.setattr(mod, "_run_with_startup_check", capture_run)
monkeypatch.setattr(
mod,
"_wrap_for_sandbox",
lambda cmd, bp: ["/bin/bash", "-c", "bwrap " + " ".join(cmd)],
)
monkeypatch.setattr(mod, "_connect_broker", MagicMock(return_value=mock_sock))
monkeypatch.setattr(mod, "_send_bounce", MagicMock())
monkeypatch.setattr(mod, "_check_rate_limited", MagicMock(return_value=False))
monkeypatch.setattr(
"aipass.ai_mail.apps.handlers.paths.find_repo_root",
MagicMock(return_value=Path("/fake/repo")),
)
with pytest.raises(SystemExit):
main()
# During the run, env had the FD
assert env_snapshots[0]["AIPASS_BROKER_FD"] == "10"
class TestBrokerRealE2E:
"""Real multi-process e2e: broker daemon, identified connection, child reads fd."""
def test_child_inherits_broker_fd(self, tmp_path):
"""Start real broker, create identified conn, spawn child that reads AIPASS_BROKER_FD."""
import time as time_mod
from aipass.drone.apps.handlers.broker.daemon import BrokerDaemon
from aipass.drone.apps.handlers.broker.client import create_identified_connection
# Set up repo root with branch dir
repo_root = tmp_path / "repo"
branch_dir = repo_root / "src" / "aipass" / "testbranch"
branch_dir.mkdir(parents=True)
target_file = branch_dir / "deleteme.txt"
target_file.write_text("delete me", encoding="utf-8")
# Start real broker
sock_path = tmp_path / "broker.sock"
audit_path = tmp_path / "audit.jsonl"
secret_path = tmp_path / "secret"
broker = BrokerDaemon(
repo_root=repo_root,
socket_path=sock_path,
audit_path=audit_path,
secret_path=secret_path,
)
t = broker.start_background()
time_mod.sleep(0.5)
try:
# Create identified connection (as the launcher would)
sock = create_identified_connection(sock_path, secret_path, "testbranch")
broker_fd = sock.fileno()
# Spawn a real child that reads AIPASS_BROKER_FD and sends a delete
child_script = tmp_path / "child.py"
child_script.write_text(
"""
import os, socket, json
fd = int(os.environ["AIPASS_BROKER_FD"])
s = socket.socket(fileno=fd)
try:
req = json.dumps({"op": "delete", "path": "deleteme.txt", "request_id": "e2e1"}) + "\\n"
s.sendall(req.encode())
data = b""
while b"\\n" not in data:
chunk = s.recv(4096)
if not chunk:
break
data += chunk
resp = json.loads(data.decode())
# Write result to a file so parent can verify
with open(os.environ["RESULT_FILE"], "w") as f:
json.dump(resp, f)
finally:
s.detach()
""",
encoding="utf-8",
)
result_file = tmp_path / "result.json"
env = os.environ.copy()
env["AIPASS_BROKER_FD"] = str(broker_fd)
env["RESULT_FILE"] = str(result_file)
proc = subprocess.Popen(
[sys.executable, str(child_script)],
env=env,
pass_fds=(broker_fd,),
close_fds=True,
)
# Parent closes its copy
sock.close()
proc.wait(timeout=10)
assert proc.returncode == 0
# Verify the delete happened
assert not target_file.exists()
# Verify the child got a success response
import json as json_mod
result = json_mod.loads(result_file.read_text(encoding="utf-8"))
assert result["ok"] is True
# Verify audit log carries identity
audit_lines = audit_path.read_text(encoding="utf-8").strip().splitlines()
delete_entries = [
json_mod.loads(line) for line in audit_lines if json_mod.loads(line).get("op") == "delete"
]
assert len(delete_entries) >= 1
assert delete_entries[-1]["identity"] == "testbranch"
assert delete_entries[-1]["result"] == "DELETED"
finally:
broker.stop()
t.join(timeout=3)
@@ -570,13 +570,18 @@ class TestDispatchEnvIsolation:
)
def test_dispatch_monitor_passes_spawn_env_to_subprocess(self):
"""dispatch_monitor.py must pass env=spawn_env to subprocess.run.
"""dispatch_monitor.py must pass spawn_env as the subprocess env.
Without this, all env var isolation is useless — the subprocess
would inherit os.environ instead of the cleaned spawn_env.
Accepts either the direct kwarg form (env=spawn_env) or the
popen_kwargs dict form ("env": spawn_env) introduced with the
sandbox broker-fd wiring (FPLAN-0250 Phase 6b).
"""
active_source = self._load_active_source()
assert "env=spawn_env" in active_source, "dispatch_monitor.py must pass env=spawn_env to subprocess.run"
assert "env=spawn_env" in active_source or '"env": spawn_env' in active_source, (
"dispatch_monitor.py must pass spawn_env as the subprocess env"
)
def test_detect_resolves_identity_when_cwd_is_wrong(self, clean_env, tmp_path, list_format_registry):
"""When AIPASS_CALLER_BRANCH is set but CWD is outside any branch,
+15
View File
@@ -275,6 +275,21 @@
"file": "apps/handlers/json/json_handler.py",
"standard": "test_quality",
"reason": "save_json now raises ValueError on invalid structure (aipass.common contract, TDPLAN-0006 P2). Tested via pytest.raises — no False return path to test."
},
{
"file": "tests/test_sandbox_check.py",
"standard": "architecture",
"reason": "Test file lives in tests/ by convention — not in apps/. Standard 3-layer structure applies to production code only."
},
{
"file": "tests/test_sandbox_check.py",
"standard": "encapsulation",
"reason": "Unit tests must import handlers directly (sandbox_checker, progress) to test them in isolation. Entry-point imports would defeat the purpose of unit testing."
},
{
"file": "tests/test_sandbox_check.py",
"standard": "documentation",
"reason": "Test methods use descriptive names (test_flag_off_by_default, test_bwrap_functional_live) that are self-documenting. Adding docstrings to 41 test functions adds noise without value."
}
]
}
@@ -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.aipass.apps.handlers.json import json_handler
from aipass.aipass.apps.handlers.sandbox_check.sandbox_checker import (
check_broker_alive,
check_bwrap_functional,
check_bwrap_present,
check_node_present,
check_rg_present,
check_sandbox_flag,
check_srt_resolvable,
is_linux,
)
from aipass.aipass.apps.handlers.structure_scan.structure_scanner import (
check_placement,
check_pyproject,
@@ -545,11 +555,105 @@ def _check_structure() -> List[CheckResult]:
return results
# --- Sandbox check group ---
def _check_sandbox() -> List[CheckResult]:
"""Run Sandbox group checks — kernel sandbox prerequisites."""
results: List[CheckResult] = []
if not is_linux():
results.append(CheckResult("sandbox", GLYPH_PASS, "kernel sandbox: Linux-only, not checked", ""))
return results
flag = check_sandbox_flag()
flag_on = flag["enabled"]
flag_label = "ON" if flag_on else "OFF"
results.append(CheckResult("sandbox flag", GLYPH_PASS, f"AIPASS_SANDBOX_ENABLED={flag_label}", ""))
def _sev(ok: bool) -> str:
if ok:
return GLYPH_PASS
return GLYPH_FAIL if flag_on else GLYPH_WARN
def _suffix(ok: bool) -> str:
if ok or flag_on:
return ""
return " (inert — flag is off)"
bwrap = check_bwrap_present()
results.append(
CheckResult(
"bwrap",
_sev(bwrap["found"]),
bwrap["path"] or "not found" + _suffix(bwrap["found"]),
"" if bwrap["found"] else "sudo apt install bubblewrap",
)
)
if bwrap["found"]:
func = check_bwrap_functional()
detail = func["detail"]
if not func["ok"] and func["sysctl_value"] is not None:
detail = f"{detail} (apparmor_restrict_unprivileged_userns={func['sysctl_value']})"
results.append(
CheckResult(
"bwrap functional",
_sev(func["ok"]),
detail + _suffix(func["ok"]),
"",
)
)
node = check_node_present()
results.append(
CheckResult(
"node",
_sev(node["found"]),
node["path"] or "not found" + _suffix(node["found"]),
"" if node["found"] else "Install Node.js: https://nodejs.org/",
)
)
srt = check_srt_resolvable()
results.append(
CheckResult(
"srt (@anthropic-ai/sandbox-runtime)",
_sev(srt["found"]),
srt["path"] or "not found" + _suffix(srt["found"]),
"" if srt["found"] else srt["install_hint"],
)
)
rg = check_rg_present()
results.append(
CheckResult(
"rg (ripgrep)",
_sev(rg["found"]),
rg["path"] or "not found" + _suffix(rg["found"]),
"" if rg["found"] else "sudo apt install ripgrep (or static binary to ~/.local/bin/rg)",
)
)
project_root = find_project_root(Path.cwd())
broker = check_broker_alive(project_root)
results.append(
CheckResult(
"broker daemon",
_sev(broker["alive"]),
broker["detail"] + _suffix(broker["alive"]),
"",
)
)
return results
# --- Main doctor run ---
def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = False) -> int:
"""Run all five groups and print results. Returns error count."""
"""Run all six groups and print results. Returns error count."""
console.print()
console.print("[bold cyan]aipass doctor[/bold cyan]")
console.print()
@@ -560,6 +664,7 @@ def run_doctor(verbose: bool = False, interactive: bool = False, fix: bool = Fal
("Services", lambda: _check_services(verbose=verbose)),
("Community", _check_community),
("Structure", _check_structure),
("Sandbox", _check_sandbox),
]
groups: Dict[str, List[CheckResult]] = {}
with make_doctor_progress() as progress:
@@ -620,7 +725,7 @@ def print_introspection() -> None:
console.print("[bold cyan]doctor Module[/bold cyan]")
console.print("System health aggregation — flutter-doctor-style output")
console.print()
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure")
console.print("[yellow]Groups:[/yellow] System, Identity, Services, Community, Structure, Sandbox")
console.print("[yellow]Next:[/yellow] [green]aipass doctor[/green] / [green]aipass doctor --fix[/green]")
console.print()
@@ -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",
"file": "tools/hook_engine_poc/test_engine.py",
"reason": "POC test harness — usage example in docstring."
},
{
"standard": "debug_print",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone red-team diagnostic runner (FPLAN-0250 Phase 6) — print() IS the report output, same as broker_acceptance_test.py."
},
{
"standard": "encapsulation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Red-team tool imports the real broker daemon/client + sandbox module directly to exercise them under live conditions — that is the point of an integration probe, not a handler."
},
{
"standard": "imports",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Standalone script run via 'python tools/...' — sys.path insert lets it import the production modules it red-teams without being pip-installed."
},
{
"standard": "help_text",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Diagnostic script — docstring shows the 'python tools/...' invocation; it is not a drone-routed module."
},
{
"standard": "documentation",
"file": "tools/rm_shim/redteam_suite.py",
"reason": "Result.ok/bad are 2-line internal report helpers in a diagnostic script — self-evident, docstrings redundant."
}
],
"notes": {
+61
View File
@@ -183,6 +183,67 @@
"standard": "trigger",
"reason": "Test file exercises .unlink() to verify deletion behavior — not a production file operation requiring trigger events."
},
{
"file": "tests/test_broker.py",
"standard": "architecture",
"reason": "Test file — intentionally outside 3-layer structure. tests/ is a peer of apps/, not part of it."
},
{
"file": "tests/test_broker.py",
"standard": "encapsulation",
"reason": "Test file imports broker handlers directly to test their public interface. Unit tests require direct access to implementation components."
},
{
"file": "tests/test_broker.py",
"standard": "trigger",
"reason": "Test file exercises .unlink() to clean up test symlinks — not a production file operation requiring trigger events."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "architecture",
"reason": "Acceptance test artifact — standalone demo script, not part of 3-layer production structure."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "encapsulation",
"reason": "Acceptance test imports handlers directly to verify broker daemon behavior end-to-end."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "documentation",
"reason": "Acceptance test script — main() is self-documenting via module docstring and inline comments."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "imports",
"reason": "Acceptance test script uses sys.path.insert to locate the package from the artifacts/ directory."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "help_text",
"reason": "Docstring run instruction shows how to invoke the script — not a production help text."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "meta",
"reason": "Acceptance test artifact — META blocks are for production source files."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "trigger",
"reason": "Acceptance test exercises .unlink() to clean up test symlinks — not production file operations."
},
{
"file": "tests/test_broker.py",
"standard": "windows_compat",
"lines": [556],
"reason": "stat.S_IMODE() guarded by os.name != 'posix' skip at runtime. POSIX-only secret permission test."
},
{
"file": "artifacts/broker_acceptance_test.py",
"standard": "unused_function",
"reason": "Standalone acceptance demo runner — helper functions invoked from the demo main, not a production module (same pattern as the other demo bypasses)."
},
{
"file": "CLAUDE.md",
"standard": "architecture",
+13 -5
View File
@@ -143,7 +143,8 @@ drone/
│ │ ├── registry.py # Registry query operations
│ │ ├── commands.py # Custom command shortcut orchestrator
│ │ ├── git_module.py # Git workflow (tier-based access, 16 commands)
│ │ └── scan.py # Branch command scanning
│ │ ├── scan.py # Branch command scanning
│ │ └── broker.py # Broker daemon orchestrator (sandbox delete)
│ ├── handlers/ # Implementation details
│ │ ├── executor.py # Safe subprocess execution (timeout, no shell)
│ │ ├── exceptions.py # Exception hierarchy (10 exception types)
@@ -153,6 +154,11 @@ drone/
│ │ ├── module_registry_handler.py # Module loading (internal + external)
│ │ ├── generic_adapter.py # StringIO capture for external modules
│ │ ├── routing_config.json # External module declarations
│ │ ├── broker/
│ │ │ ├── daemon.py # Broker daemon (unix socket, openat2, audit)
│ │ │ ├── client.py # Broker client (inherited fd transport)
│ │ │ ├── path_resolver.py # openat2 RESOLVE_BENEATH path resolution
│ │ │ └── protocol.py # Typed JSON-line IPC (BrokerRequest/Response)
│ │ ├── json/
│ │ │ └── json_handler.py # Structured operation logging
│ │ ├── scanning/
@@ -187,7 +193,8 @@ drone/
│ └── hook_sounds_plugin.py.disabled
├── docs/ # Public documentation
├── docs.local/ # Investigation reports and policies
└── tests/ # 704 tests across 21 test files
├── artifacts/ # Live acceptance test scripts
└── tests/ # 807 tests across 22 test files
```
### Routing Flow
@@ -325,7 +332,7 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
## Testing
704 tests across 21 test files, covering all layers:
807 tests across 22 test files, covering all layers:
| Area | Files | Tests |
|------|-------|-------|
@@ -333,7 +340,8 @@ Tip: set AIPASS_HOME=/path/to/AIPass to access all branches
| Git operations | `test_git_module.py`, `test_system_pr.py`, `test_devpulse_plugins.py`, `test_git_access.py` | ~150 |
| Handlers | `test_executor.py`, `test_registry_handler.py`, `test_discovery.py` | ~99 |
| Infrastructure | `test_generic_adapter.py`, `test_module_registry.py`, `test_config.py` | ~66 |
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py` | ~125 |
| Features | `test_commands.py`, `test_scan.py`, `test_json_handler.py`, `test_rm.py` | ~181 |
| Broker | `test_broker.py` | ~55 |
| Standards | `test_cli_routing.py`, `test_contracts.py`, `test_error_resilience.py`, `test_init_provisioning.py` | ~21 |
Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
@@ -348,7 +356,7 @@ Run tests: `cd src/aipass/drone && python -m pytest tests/ -q`
---
**Seedgo:** 100% | **Tests:** 775 pass, 4 skip | **Last Updated:** 2026-06-07
**Seedgo:** 100% | **Tests:** 830 pass, 4 skip | **Last Updated:** 2026-06-10
---
[← Back to AIPass](../../../README.md)
@@ -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.
Every path is checked independently; a refused path does not block others.
"""
return _safe_delete_direct(paths)
def _safe_delete_direct(paths: list[str]) -> list[tuple[str, bool, str]]:
"""Delete paths directly (unsandboxed mode — current behavior)."""
roots = get_allowed_roots()
if not roots:
return [(p, False, "No allowed roots found (no project registry, no temp dir)") for p in paths]
+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 (
safe_delete as _safe_delete,
)
from aipass.drone.apps.handlers.broker.client import (
is_sandboxed as _is_sandboxed,
broker_delete as _broker_delete,
)
DRONE_MODULE = {
"name": "rm",
@@ -32,8 +36,16 @@ def safe_delete(paths: list[str]) -> list[tuple[str, bool, str]]:
"""Delete paths with containment checks.
Returns list of ``(original_path, success, message)`` tuples.
When sandboxed (AIPASS_BROKER_FD set), routes through the broker daemon.
"""
logger.info("rm: requested deletion of %d path(s)", len(paths))
if _is_sandboxed():
json_handler.log_operation("rm_broker", {"paths": paths})
results: list[tuple[str, bool, str]] = []
for path_str in paths:
ok, message = _broker_delete(path_str)
results.append((path_str, ok, message))
return results
return _safe_delete(paths)
+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)
│ │ ├── engine.py # Core dispatch — routes events to handlers
│ │ ├── hooksound.py # Sound control (drone @hooks hooksound on/off)
│ │ └── hookstatus.py # Config viewer (drone @hooks status)
│ │ ├── hookstatus.py # Config viewer (drone @hooks status)
│ │ └── sandbox.py # Kernel sandbox — srt/bwrap wrapper + per-role policy generator
│ ├── handlers/
│ │ ├── bridges/ # One per provider (thin normalization)
│ │ │ └── claude.py # Claude Code bridge
@@ -62,7 +63,7 @@ src/aipass/hooks/
│ │ ├── security/ # Enforcement hooks
│ │ │ ├── edit_gate.py # Blocks unsafe edits (cross-branch, inbox, diagnostics)
│ │ │ ├── git_gate.py # Enforces git access tiers
│ │ │ ├── rm_gate.py # Blocks raw recursive rm, teaches drone rm
│ │ │ ├── rm_gate.py # Guardrail — catches accidental rm -rf, teaches drone rm
│ │ │ └── subagent_gate.py # Blocks sub-agent stop until clean
│ │ ├── lifecycle/ # Session management hooks
│ │ │ ├── auto_fix.py # Post-edit diagnostics (ruff, pyright, py_compile)
@@ -79,7 +80,7 @@ src/aipass/hooks/
│ └── diagnostics.py # JSONL logging for hook execution
├── logs/
│ └── engine.jsonl # JSONL diagnostics (every hook execution)
└── tests/ # 435 tests across 21 test files
└── tests/ # 472 tests across 22 test files
```
## How It Works
@@ -101,13 +102,40 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
| Event | Hooks | Description |
|---|---|---|
| UserPromptSubmit | identity, email, branch_loader, global_loader | Prompt injection + inbox check |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + sound |
| PreToolUse | tool_sound, edit_gate, git_gate, rm_gate | Security gates + guardrails + sound |
| PostToolUse | auto_fix, auto_watchdog | Diagnostics + watchdog |
| SubagentStop | subagent_gate | Seedgo validation |
| Stop | stop_sound | Achievement bell |
| Notification | announce | Announcement tone |
| PreCompact | compact, rollover | Memory archival + rollover |
## Kernel Sandbox (srt/bwrap)
The sandbox module (`apps/modules/sandbox.py`) provides the kernel-level filesystem boundary for agent sessions. It wraps Anthropic's `@anthropic-ai/sandbox-runtime` (srt) library, which uses bubblewrap (bwrap) + Landlock + seccomp on Linux to enforce write/read restrictions at the OS level.
### Key Functions
| Function | What it does |
|---|---|
| `build_policy(branch_path)` | Generates per-role writable/RO map from branch passport |
| `sandbox_launch(cmd, cwd, policy)` | Resolves bwrap command via srt, spawns sandboxed process |
| `build_srt_config(policy)` | Converts policy dict to srt config format |
### Policy Rules
- **Every agent**: own branch tree + /tmp + shared channels (system_logs, .ai_central, memory_pool, AIPASS_REGISTRY.json, flow_json) + sibling mail/dashboard carve-ins + ~/.claude/projects/
- **devpulse only**: .git writable (the only committer)
- **All other agents**: .git read-only, sibling source trees read-only
- **Deny**: broker_secret (deny_read + deny_write for all roles)
Bind-mount, not isolation: the sandbox preserves the shared live filesystem. Reads stay open everywhere. Only writes to protected paths are blocked at the kernel level (EROFS).
### Architecture
The Node helper (`_srt_resolve.mjs`) resolves the globally-installed srt library via `process.execPath` (ESM resolution doesn't walk to global node_modules). The resolver runs with CWD set to `/var/tmp` to prevent srt's mandatory-deny mask files from polluting the branch directory.
The @drone broker validates sandbox policy before agent launch. @ai_mail's dispatch_monitor wires `build_policy` + `sandbox_launch` at the launch seam.
## Integration Points
### Depends On
@@ -118,9 +146,10 @@ Handlers are called **dynamically at runtime** — the engine uses `importlib.im
### Provides To
All branches via hook dispatch. Every Claude Code session routes through the engine.
- All branches via hook dispatch — every Claude Code session routes through the engine
- @ai_mail dispatch_monitor — sandbox_launch + build_policy for agent launch boundary
*Last Updated: 2026-06-02*
*Last Updated: 2026-06-10*
---
@@ -1,14 +1,19 @@
# =================== AIPass ====================
# Name: rm_gate.py
# Version: 1.0.0
# Description: Blocks raw recursive rm commands (PreToolUse)
# Description: Guardrail — catches accidental rm -rf and teaches drone rm (PreToolUse)
# Branch: hooks
# Layer: apps/handlers/security
# Created: 2026-06-02
# Modified: 2026-06-02
# =============================================
"""Blocks raw recursive rm and teaches drone rm."""
"""Early-feedback guardrail — catches accidental recursive rm and teaches drone rm.
Belt-and-suspenders: the actual filesystem boundary is the kernel sandbox
(srt/bwrap) enforced at agent launch. This hook provides fast, helpful feedback
before the sandbox would block the operation at the kernel level.
"""
import json
import re
@@ -17,10 +22,10 @@ from aipass.prax.apps.modules.logger import system_logger as logger
RM_REDIRECT = (
"Raw recursive rm is blocked. Use the safe contained delete instead:\n"
" drone rm <path> # safe delete (allows project + /tmp, refuses outside)\n"
"Heads up — raw recursive rm is not the right tool here. Use:\n"
" drone rm <path> # project-aware delete (allows project + /tmp, refuses outside)\n"
"\n"
"This applies to all recursive rm variants (rm -rf, rm -r, rm -R, rm --recursive)."
"This guardrail catches rm -rf, rm -r, rm -R, and rm --recursive."
)
_BLOCK_ALLOW = {"stdout": "", "exit_code": 0}
@@ -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