refactor: seal local sandbox abstractions

This commit is contained in:
RuochenPan
2026-09-04 15:13:16 +08:00
parent bc24cdd7a3
commit 03c058aebd
12 changed files with 274 additions and 131 deletions
+23 -48
View File
@@ -8,7 +8,6 @@ import shutil
import signal
import subprocess
import sys
import tempfile
import time
import uuid
from dataclasses import dataclass, field
@@ -27,6 +26,7 @@ from astrbot.core.computer.file_read_utils import (
from astrbot.core.computer.process_sandbox import (
SandboxProcess,
SandboxSpec,
SandboxTimeoutError,
create_process_sandbox,
)
from astrbot.core.utils.astrbot_path import (
@@ -935,46 +935,21 @@ class LocalPythonComponent(PythonComponent):
)
if sandboxed:
sandbox = create_process_sandbox()
sandbox_spec = SandboxSpec(
workspace=working_dir,
allow_network=allow_network,
filesystem_scope=filesystem_scope,
result = sandbox.run(
[sys.executable, "-c", code],
SandboxSpec(
workspace=working_dir,
allow_network=allow_network,
filesystem_scope=filesystem_scope,
),
timeout=timeout,
output_limit=_LOCAL_SANDBOX_MAX_OUTPUT_BYTES,
discard_stdout=silent,
)
with (
tempfile.TemporaryFile() as stdout_file,
tempfile.TemporaryFile() as stderr_file,
):
result = sandbox.run(
[sys.executable, "-c", code],
sandbox_spec,
timeout=timeout,
stdout=subprocess.DEVNULL if silent else stdout_file,
stderr=stderr_file,
)
if silent:
stdout = ""
stdout_limited = False
else:
stdout_file.seek(0)
stdout_bytes = stdout_file.read(
_LOCAL_SANDBOX_MAX_OUTPUT_BYTES + 1
)
stdout_limited = (
len(stdout_bytes) > _LOCAL_SANDBOX_MAX_OUTPUT_BYTES
)
stdout = _decode_shell_output(
stdout_bytes[:_LOCAL_SANDBOX_MAX_OUTPUT_BYTES]
)
stderr_file.seek(0)
stderr_bytes = stderr_file.read(
_LOCAL_SANDBOX_MAX_OUTPUT_BYTES + 1
)
stderr_limited = (
len(stderr_bytes) > _LOCAL_SANDBOX_MAX_OUTPUT_BYTES
)
stderr = _decode_shell_output(
stderr_bytes[:_LOCAL_SANDBOX_MAX_OUTPUT_BYTES]
)
stdout = _decode_shell_output(result.stdout)
stderr = _decode_shell_output(result.stderr)
stdout_limited = result.stdout_limited
stderr_limited = result.stderr_limited
else:
run_command = [
os.environ.get("PYTHON", sys.executable),
@@ -1008,7 +983,7 @@ class LocalPythonComponent(PythonComponent):
"error": execution_error,
}
}
except subprocess.TimeoutExpired:
except (SandboxTimeoutError, subprocess.TimeoutExpired):
return {
"data": {
"output": {"text": "", "images": []},
@@ -1129,10 +1104,6 @@ class LocalFileSystemComponent(FileSystemComponent):
if before_context is not None:
command.extend(["-B", str(before_context)])
command.extend(["--", path or "."])
run_kwargs: dict[str, Any] = {
"capture_output": True,
"timeout": 30,
}
sandbox_workspace: Path | None = None
if sandboxed:
if not sandbox_root:
@@ -1152,11 +1123,15 @@ class LocalFileSystemComponent(FileSystemComponent):
workspace=sandbox_workspace,
workspace_writable=False,
),
**run_kwargs,
timeout=30,
)
else:
result = subprocess.run(command, **run_kwargs)
except subprocess.TimeoutExpired:
result = subprocess.run(
command,
capture_output=True,
timeout=30,
)
except (SandboxTimeoutError, subprocess.TimeoutExpired):
return {
"success": False,
"content": "",
@@ -2,9 +2,14 @@ from __future__ import annotations
import sys
from .base import ProcessSandbox, SandboxLimits, SandboxProcess, SandboxSpec
from .bubblewrap import BubblewrapProcessSandbox
from .seatbelt import SeatbeltProcessSandbox
from .base import (
ProcessSandbox,
SandboxLimits,
SandboxProcess,
SandboxRunResult,
SandboxSpec,
SandboxTimeoutError,
)
def create_process_sandbox() -> ProcessSandbox:
@@ -17,18 +22,22 @@ def create_process_sandbox() -> ProcessSandbox:
RuntimeError: If the current system has no Local sandbox implementation.
"""
if sys.platform.startswith("linux"):
from .bubblewrap import BubblewrapProcessSandbox
return BubblewrapProcessSandbox()
if sys.platform == "darwin":
from .seatbelt import SeatbeltProcessSandbox
return SeatbeltProcessSandbox()
raise RuntimeError(
"The Local execution sandbox is only available on Linux and macOS."
)
raise RuntimeError("No Local process sandbox backend is available.")
__all__ = (
"ProcessSandbox",
"SandboxLimits",
"SandboxProcess",
"SandboxRunResult",
"SandboxSpec",
"SandboxTimeoutError",
"create_process_sandbox",
)
+42 -28
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import os
import subprocess
import sys
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Protocol
from typing import Protocol
@dataclass(frozen=True, slots=True)
@@ -66,6 +64,29 @@ class SandboxSpec:
limits: SandboxLimits = field(default_factory=SandboxLimits)
@dataclass(frozen=True, slots=True)
class SandboxRunResult:
"""Result returned by a synchronous sandbox execution.
Args:
returncode: Process exit status.
stdout: Captured standard output.
stderr: Captured standard error.
stdout_limited: Whether standard output exceeded the requested limit.
stderr_limited: Whether standard error exceeded the requested limit.
"""
returncode: int
stdout: bytes = b""
stderr: bytes = b""
stdout_limited: bool = False
stderr_limited: bool = False
class SandboxTimeoutError(TimeoutError):
"""Raised when a sandbox process exceeds its execution timeout."""
class SandboxStdin(Protocol):
"""Writable stream used by managed sandbox processes."""
@@ -129,14 +150,14 @@ class SandboxProcess(Protocol):
class ProcessSandbox(ABC):
"""Platform-independent launcher for restricted child processes."""
def build_command(
def _prepare_command(
self,
argv: list[str],
spec: SandboxSpec,
*,
env: dict[str, str] | None = None,
) -> list[str]:
"""Validate common inputs and build a platform sandbox command.
) -> tuple[list[str], Path, dict[str, str]]:
"""Validate and normalize a command before platform-specific launch.
Args:
argv: Command and arguments to execute inside the sandbox.
@@ -144,7 +165,7 @@ class ProcessSandbox(ABC):
env: Additional environment variables exposed inside the sandbox.
Returns:
Platform sandbox command and arguments.
Normalized arguments, workspace, and environment values.
Raises:
RuntimeError: If the workspace does not exist.
@@ -172,33 +193,36 @@ class ProcessSandbox(ABC):
raise ValueError(f"Invalid sandbox environment variable name: {key!r}.")
normalized_env[key] = value
return self._build_command(sandbox_argv, workspace, spec, normalized_env)
return sandbox_argv, workspace, normalized_env
@abstractmethod
def run(
self,
argv: list[str],
spec: SandboxSpec,
*,
env: dict[str, str] | None = None,
**kwargs: Any,
) -> subprocess.CompletedProcess[Any]:
timeout: float | None = None,
output_limit: int | None = None,
discard_stdout: bool = False,
) -> SandboxRunResult:
"""Run a restricted process synchronously.
Args:
argv: Command and arguments to execute inside the sandbox.
spec: Filesystem and network access granted to the process.
env: Additional environment variables exposed inside the sandbox.
**kwargs: Extra keyword arguments passed to ``subprocess.run``.
timeout: Maximum wall-clock runtime in seconds.
output_limit: Maximum captured bytes for each output stream.
discard_stdout: Whether to discard standard output.
Returns:
Completed restricted process.
Platform-independent process result.
Raises:
SandboxTimeoutError: If the process exceeds ``timeout``.
"""
return subprocess.run(
self.build_command(argv, spec, env=env),
cwd=spec.workspace.resolve(),
env={"PATH": os.defpath},
**kwargs,
)
raise NotImplementedError
@abstractmethod
async def spawn_shell(
@@ -219,13 +243,3 @@ class ProcessSandbox(ABC):
Running restricted process.
"""
raise NotImplementedError
@abstractmethod
def _build_command(
self,
argv: list[str],
workspace: Path,
spec: SandboxSpec,
env: dict[str, str],
) -> list[str]:
"""Build a platform command after common input validation."""
@@ -3,16 +3,21 @@ from __future__ import annotations
import asyncio
import os
import signal as signal_module
import subprocess
import sys
import tempfile
from abc import abstractmethod
from pathlib import Path
from .base import (
ProcessSandbox,
SandboxLimits,
SandboxProcess,
SandboxRunResult,
SandboxSpec,
SandboxStdin,
SandboxStdout,
SandboxTimeoutError,
)
@@ -78,6 +83,90 @@ class UnixSandboxProcess:
class UnixProcessSandbox(ProcessSandbox):
"""Common launcher behavior for Unix sandbox implementations."""
def build_command(
self,
argv: list[str],
spec: SandboxSpec,
*,
env: dict[str, str] | None = None,
) -> list[str]:
"""Build a Unix sandbox wrapper command.
Args:
argv: Command and arguments to execute inside the sandbox.
spec: Filesystem, network, and resource policy.
env: Additional environment variables exposed inside the sandbox.
Returns:
Platform sandbox command and arguments.
"""
argv, workspace, env = self._prepare_command(argv, spec, env=env)
return self._build_command(argv, workspace, spec, env)
def run(
self,
argv: list[str],
spec: SandboxSpec,
*,
env: dict[str, str] | None = None,
timeout: float | None = None,
output_limit: int | None = None,
discard_stdout: bool = False,
) -> SandboxRunResult:
"""Run a command through the Unix sandbox wrapper.
Args:
argv: Command and arguments to execute inside the sandbox.
spec: Filesystem, network, and resource policy.
env: Additional environment variables exposed inside the sandbox.
timeout: Maximum wall-clock runtime in seconds.
output_limit: Maximum captured bytes for each output stream.
discard_stdout: Whether to discard standard output.
Returns:
Captured process result.
Raises:
SandboxTimeoutError: If the process exceeds ``timeout``.
ValueError: If ``output_limit`` is not positive.
"""
if output_limit is not None and output_limit <= 0:
raise ValueError("Sandbox output limit must be greater than 0.")
with (
tempfile.TemporaryFile() as stdout_file,
tempfile.TemporaryFile() as stderr_file,
):
try:
result = subprocess.run(
self.build_command(argv, spec, env=env),
cwd=spec.workspace.resolve(),
env={"PATH": os.defpath},
timeout=timeout,
stdout=subprocess.DEVNULL if discard_stdout else stdout_file,
stderr=stderr_file,
)
except subprocess.TimeoutExpired as exc:
raise SandboxTimeoutError(
f"Sandbox command timed out after {timeout} seconds."
) from exc
read_size = None if output_limit is None else output_limit + 1
if discard_stdout:
stdout = b""
else:
stdout_file.seek(0)
stdout = stdout_file.read(read_size)
stderr_file.seek(0)
stderr = stderr_file.read(read_size)
return SandboxRunResult(
returncode=result.returncode,
stdout=stdout[:output_limit] if output_limit is not None else stdout,
stderr=stderr[:output_limit] if output_limit is not None else stderr,
stdout_limited=output_limit is not None and len(stdout) > output_limit,
stderr_limited=output_limit is not None and len(stderr) > output_limit,
)
async def spawn_shell(
self,
command: str,
@@ -106,6 +195,16 @@ class UnixProcessSandbox(ProcessSandbox):
)
return UnixSandboxProcess(process)
@abstractmethod
def _build_command(
self,
argv: list[str],
workspace: Path,
spec: SandboxSpec,
env: dict[str, str],
) -> list[str]:
"""Build a Unix sandbox command after common input validation."""
def build_resource_limited_argv(
argv: list[str],
+4 -8
View File
@@ -32,7 +32,6 @@ Local path resolution rule:
import os
import stat
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
@@ -72,9 +71,6 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = {
"provider_settings.computer_use_runtime": "sandbox",
}
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
_RACE_RESISTANT_LOCAL_ACCESS = sys.platform.startswith("linux") or (
sys.platform == "darwin"
)
def _remote_basename(path: str) -> str:
@@ -400,7 +396,7 @@ class FileReadTool(FunctionTool):
context.context.event.unified_msg_origin,
)
file_descriptor = None
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
if restricted:
file_descriptor = open_file_in_allowed_roots(
normalized_path,
_read_allowed_roots(
@@ -496,7 +492,7 @@ class FileWriteTool(FunctionTool):
context.context.event.unified_msg_origin,
)
file_descriptor = None
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
if restricted:
file_descriptor = open_file_in_allowed_roots(
normalized_path,
_write_allowed_roots(
@@ -609,7 +605,7 @@ class FileEditTool(FunctionTool):
context.context.event.unified_msg_origin,
)
file_descriptor = None
if restricted and _RACE_RESISTANT_LOCAL_ACCESS:
if restricted:
file_descriptor = open_file_in_allowed_roots(
normalized_path,
_write_allowed_roots(
@@ -868,7 +864,7 @@ class GrepTool(FunctionTool):
)
contents: list[str] = []
for search_path in search_paths:
sandboxed = restricted and _RACE_RESISTANT_LOCAL_ACCESS
sandboxed = restricted
if sandboxed:
path_object = Path(search_path)
matching_roots = [
+10 -11
View File
@@ -1,10 +1,10 @@
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.computer.process_sandbox import create_process_sandbox
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.astrbot_path import get_astrbot_workspaces_path
from astrbot.core.workspace import (
@@ -162,14 +162,13 @@ def check_local_execution_permission(
"for this role in AstrBot WebUI -> Config -> Normal Config -> AI -> "
"Agent Computer Use -> Local Permission Policies."
)
if policy.requires_sandbox and not (
sys.platform.startswith("linux") or sys.platform == "darwin"
):
return policy, (
"error: Permission denied. Restricted Local execution is only supported "
"on Linux with bubblewrap or macOS with Seatbelt. To use restricted "
"execution on this platform, select `Third-party sandbox` under AstrBot "
"WebUI -> Config -> Normal Config -> AI -> Agent Computer Use -> "
"Computer Use Runtime."
)
if policy.requires_sandbox:
try:
create_process_sandbox()
except RuntimeError as exc:
return policy, (
"error: Permission denied. Restricted Local execution is unavailable: "
f"{exc} Select `Third-party sandbox` under AstrBot WebUI -> Config -> "
"Normal Config -> AI -> Agent Computer Use -> Computer Use Runtime."
)
return policy, None
+27 -7
View File
@@ -36,9 +36,7 @@ def _make_context(
if local_permissions is not None:
provider_settings["computer_use_local_permissions"] = local_permissions
config_holder = SimpleNamespace(
get_config=lambda umo=None: {
"provider_settings": provider_settings
}
get_config=lambda umo=None: {"provider_settings": provider_settings}
)
event = SimpleNamespace(
role=role,
@@ -543,6 +541,32 @@ async def test_restricted_local_member_rejects_workspace_hardlink_alias(
assert outside_file.read_text(encoding="utf-8") == "outside-secret\n"
@pytest.mark.asyncio
async def test_restricted_local_write_fails_closed_without_safe_file_access(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
):
workspace = _setup_local_fs_tools(monkeypatch, tmp_path)
def unavailable_file_access(*_args, **_kwargs):
raise RuntimeError("Restricted file access is unavailable.")
monkeypatch.setattr(
fs_tools,
"open_file_in_allowed_roots",
unavailable_file_access,
)
result = await fs_tools.FileWriteTool().call(
_make_context(role="member"),
path="blocked.txt",
content="must not be written\n",
)
assert "Restricted file access is unavailable" in result
assert not (workspace / "blocked.txt").exists()
@pytest.mark.asyncio
@pytest.mark.skipif(
not (sys.platform.startswith("linux") or sys.platform == "darwin"),
@@ -674,10 +698,6 @@ async def test_restricted_local_edit_reuses_first_opened_file(
@pytest.mark.asyncio
@pytest.mark.skipif(
not (sys.platform.startswith("linux") or sys.platform == "darwin"),
reason="Restricted Local execution sandbox is POSIX-only.",
)
async def test_restricted_local_grep_requests_read_only_os_sandbox(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
+6 -3
View File
@@ -139,10 +139,13 @@ def test_local_permission_policy_denies_disabled_execution():
assert "Local Permission Policies" in error
def test_windows_local_execution_requires_a_full_trust_policy(monkeypatch):
def test_unavailable_local_sandbox_requires_a_full_trust_policy(monkeypatch):
from astrbot.core.tools.computer_tools import util as computer_util
monkeypatch.setattr(computer_util.sys, "platform", "win32")
def unavailable_sandbox():
raise RuntimeError("No Local process sandbox backend is available.")
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
restricted = {
"member": {
"allow_execution": True,
@@ -168,7 +171,7 @@ def test_windows_local_execution_requires_a_full_trust_policy(monkeypatch):
)
assert restricted_error is not None
assert "only supported on Linux" in restricted_error
assert "No Local process sandbox backend" in restricted_error
assert "Third-party sandbox" in restricted_error
assert "Computer Use Runtime" in restricted_error
assert full_error is None
+7 -5
View File
@@ -7,6 +7,7 @@ from pathlib import Path
import pytest
from astrbot.core.computer import process_sandbox
from astrbot.core.computer.booters import local as local_booter
from astrbot.core.computer.booters.local import LocalFileSystemComponent
@@ -230,19 +231,19 @@ def test_local_file_system_component_runs_restricted_search_in_read_only_sandbox
sandbox_calls = []
class FakeSandbox:
def run(self, argv, spec, *, env=None, **kwargs):
def run(self, argv, spec, *, env=None, timeout=None, **kwargs):
sandbox_calls.append(
{
"argv": argv,
"workspace": spec.workspace,
"env": env,
"workspace_writable": spec.workspace_writable,
"timeout": timeout,
"kwargs": kwargs,
}
)
return subprocess.CompletedProcess(
argv,
0,
return process_sandbox.SandboxRunResult(
returncode=0,
stdout=b"target.txt:1:needle\n",
stderr=b"",
)
@@ -294,7 +295,8 @@ def test_local_file_system_component_runs_restricted_search_in_read_only_sandbox
"workspace": tmp_path,
"env": None,
"workspace_writable": False,
"kwargs": {"capture_output": True, "timeout": 30},
"timeout": 30,
"kwargs": {},
}
]
+26 -2
View File
@@ -17,6 +17,7 @@ from astrbot.core.computer.booters.local import (
LocalPythonComponent,
LocalShellComponent,
)
from astrbot.core.computer.process_sandbox.unix import UnixProcessSandbox
class _FakePopen:
@@ -68,7 +69,9 @@ def _build_sandbox_command(
Returns:
Platform sandbox command and arguments.
"""
return process_sandbox.create_process_sandbox().build_command(
sandbox = process_sandbox.create_process_sandbox()
assert isinstance(sandbox, UnixProcessSandbox)
return sandbox.build_command(
argv,
process_sandbox.SandboxSpec(
workspace=workspace,
@@ -278,7 +281,7 @@ def test_process_sandbox_selects_system_implementation(
def test_process_sandbox_fails_closed_on_unsupported_system(monkeypatch):
monkeypatch.setattr(process_sandbox.sys, "platform", "win32")
with pytest.raises(RuntimeError, match="only available on Linux and macOS"):
with pytest.raises(RuntimeError, match="No Local process sandbox backend"):
process_sandbox.create_process_sandbox()
@@ -1017,6 +1020,27 @@ async def test_sandboxed_local_python_caps_returned_output(monkeypatch, tmp_path
assert result["data"]["error"] == "Execution output exceeded 5 bytes."
@pytest.mark.asyncio
async def test_sandboxed_local_python_handles_backend_timeout(monkeypatch, tmp_path):
class FakeSandbox:
def run(self, *_args, **_kwargs):
raise process_sandbox.SandboxTimeoutError
monkeypatch.setattr(
local_booter,
"create_process_sandbox",
lambda: FakeSandbox(),
)
result = await LocalPythonComponent().exec(
"while True: pass",
cwd=str(tmp_path),
sandboxed=True,
)
assert result["data"]["error"] == "Execution timed out."
@pytest.mark.asyncio
async def test_managed_shell_prefers_pwsh_when_available(monkeypatch, tmp_path):
calls = []
+7 -6
View File
@@ -277,11 +277,9 @@ async def test_local_shell_tools_fail_closed_without_sender_identity(
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["linux", "darwin"])
async def test_local_member_shell_uses_platform_sandbox(
async def test_local_member_shell_uses_sandbox_backend(
monkeypatch,
tmp_path,
platform_name,
):
from astrbot.core.tools.computer_tools import shell as shell_tools
from astrbot.core.tools.computer_tools import util as computer_util
@@ -328,7 +326,7 @@ async def test_local_member_shell_uses_platform_sandbox(
async def fake_get_booter(context, session_id):
return booter
monkeypatch.setattr(computer_util.sys, "platform", platform_name)
monkeypatch.setattr(computer_util, "create_process_sandbox", object)
monkeypatch.setattr(shell_tools, "get_booter", fake_get_booter)
monkeypatch.setattr(
shell_tools,
@@ -385,11 +383,14 @@ async def test_local_member_shell_is_denied_without_supported_sandbox(monkeypatc
},
)()
monkeypatch.setattr(computer_util.sys, "platform", "win32")
def unavailable_sandbox():
raise RuntimeError("No Local process sandbox backend is available.")
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
result = await LocalExecuteShellTool().call(wrapper, command="pwd")
assert "Linux with bubblewrap or macOS with Seatbelt" in result
assert "No Local process sandbox backend" in result
@pytest.mark.asyncio
+8 -7
View File
@@ -81,11 +81,9 @@ async def test_local_python_tool_uses_session_workspace(tmp_path, monkeypatch):
@pytest.mark.asyncio
@pytest.mark.parametrize("platform_name", ["linux", "darwin"])
async def test_local_member_python_uses_platform_sandbox(
async def test_local_member_python_uses_sandbox_backend(
tmp_path,
monkeypatch,
platform_name,
):
"""Local member Python execution should require an OS sandbox."""
from astrbot.core.tools.computer_tools import util as computer_util
@@ -99,7 +97,7 @@ async def test_local_member_python_uses_platform_sandbox(
"astrbot.core.tools.computer_tools.python.get_local_booter",
lambda: SimpleNamespace(python=local_python),
)
monkeypatch.setattr(computer_util.sys, "platform", platform_name)
monkeypatch.setattr(computer_util, "create_process_sandbox", object)
monkeypatch.setattr(
"astrbot.core.tools.computer_tools.python.workspace_root_for_context",
AsyncMock(return_value=tmp_path),
@@ -140,10 +138,13 @@ async def test_local_member_python_uses_platform_sandbox(
@pytest.mark.asyncio
async def test_local_member_python_is_denied_without_supported_sandbox(monkeypatch):
"""Local member Python execution should fail closed on other platforms."""
"""Local member Python execution should fail without a sandbox backend."""
from astrbot.core.tools.computer_tools import util as computer_util
monkeypatch.setattr(computer_util.sys, "platform", "win32")
def unavailable_sandbox():
raise RuntimeError("No Local process sandbox backend is available.")
monkeypatch.setattr(computer_util, "create_process_sandbox", unavailable_sandbox)
monkeypatch.setattr(
"astrbot.core.tools.computer_tools.python.get_local_booter",
lambda: pytest.fail("Local Python must not start without an OS sandbox"),
@@ -169,4 +170,4 @@ async def test_local_member_python_is_denied_without_supported_sandbox(monkeypat
result = await LocalPythonTool().call(context, code="print('ok')")
assert "Linux with bubblewrap or macOS with Seatbelt" in result
assert "No Local process sandbox backend" in result