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:
co-authored by
Claude Opus 4.8
parent
0c6e8ac425
commit
0b4ba63fae
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user