Sandbox¶
With sandboxing enabled, Raven runs shell commands and stdio MCP server processes in a Boxlite microVM. The VM has its own kernel, resource limits, and network policy. Mounted directories remain accessible according to their configured permissions, including the shared workspace.
1. Installation¶
The sandbox backend (boxlite) is an optional dependency. Install it from your
Raven source checkout:
uv sync --extra sandbox
When the backend is "auto" or "boxlite", a missing dependency raises
SandboxInitError. Raven does not silently fall back to host execution.
DirectExecutor is used when the backend is "none" or no sandbox configuration
is supplied.
The sandbox extra pins boxlite==0.9.5 in pyproject.toml. Use the pinned
version to keep the backend compatible with Raven's executor implementation.
2. Configuration¶
Add a sandbox block under tools in config.json:
{
"tools": {
"sandbox": {
"backend": "auto"
}
}
}
The default backend is "none". Enable "auto" or "boxlite" to use a microVM.
Full reference¶
| Key | Type | Default | Description |
|---|---|---|---|
backend |
"none" \| "auto" \| "boxlite" |
"none" |
"none" runs commands on the host. Both "auto" and "boxlite" currently select Boxlite and raise SandboxInitError if it cannot be initialized. |
image |
str |
"ubuntu:22.04" |
OCI image used for the microVM root filesystem. |
cpus |
int |
2 |
vCPU count allocated to the VM. |
memory_mib |
int |
2048 |
RAM in MiB. |
disk_size_gb |
int \| null |
null |
Disk size in GB. null = ephemeral (boxlite default, no persistent disk). |
allow_net |
bool \| list[str] |
true |
true = unrestricted; false = no network; ["pypi.org", ...] = domain allowlist. An empty list [] is rejected at config validation time — use false to disable networking entirely. |
extra_volumes |
list[[host, vm, mode]] |
[] |
Additional host paths to mount into the VM. Both paths must be absolute. mode is "ro" or "rw". |
default_timeout |
int |
120 |
Per-exec() timeout in seconds when no explicit timeout is passed. |
verify_timeout |
int |
30 |
Timeout in seconds for the startup echo ok probe that confirms the VM is responsive. |
create_timeout |
int |
300 |
Timeout in seconds for image pull + VM creation. Increase for large images or slow registries; decrease if images are always pre-pulled. |
debug |
object |
Disabled | Set debug.enabled to true to enable the local sandbox inspection service used by raven sandbox. |
Common presets¶
Automatic backend selection: currently selects Boxlite and verifies the VM at startup. Initialization fails if the backend is unavailable:
{
"tools": {
"sandbox": { "backend": "auto" }
}
}
Explicit Boxlite backend: selects Boxlite with the same startup checks:
{
"tools": {
"sandbox": { "backend": "boxlite" }
}
}
Custom image, resource limits, and a network allowlist:
{
"tools": {
"sandbox": {
"backend": "boxlite",
"image": "python:3.11-slim",
"cpus": 4,
"memory_mib": 4096,
"disk_size_gb": 20,
"allow_net": ["pypi.org", "files.pythonhosted.org", "api.github.com"],
"default_timeout": 120
}
}
}
Disable network access in the working VM:
{
"tools": {
"sandbox": {
"backend": "boxlite",
"allow_net": false
}
}
}
Disabled (default):
{
"tools": {
"sandbox": { "backend": "none" }
}
}
3. How It Works¶
SandboxExecutor, defined in raven/sandbox/interfaces.py, provides a shared
interface for two execution backends:
| Implementation | When used | Isolation |
|---|---|---|
BoxliteExecutor |
backend = "auto" or "boxlite" |
boxlite microVM — separate kernel, capped resources |
DirectExecutor |
backend = "none" |
None — asyncio.create_subprocess_shell() on the host |
The workspace is mounted at /workspace with read-write access. Filesystem
tools such as ReadFileTool and WriteFileTool use the host path directly.
Reading or writing /workspace/foo.py inside the VM accesses the same file.
AgentLoop.__init__ creates the executor object synchronously without starting
a VM. At runtime startup or before a turn, _start_executor() calls
BoxliteExecutor.start() to create and verify the working VM:
allow_net=True(default): Boxlite creates the working VM directly and downloads the image if it is not already cached.allow_net=Falseor a domain list: a temporary VM with unrestricted networking first pulls and caches the image. Raven then creates the working VM with the configured network restrictions.
Network restrictions apply to the working VM; image preparation still needs
registry access. An unavailable backend or unsupported platform raises
SandboxInitError before sandboxed work can run.
4. Using SandboxExecutor Directly¶
4.1 BoxliteExecutor¶
import asyncio
from pathlib import Path
from raven.config.paths import get_sandbox_dir
from raven.sandbox import build_executor, SandboxConfig
async def main():
sandbox_cfg = SandboxConfig(
backend="boxlite",
image="ubuntu:22.04",
cpus=2,
memory_mib=2048,
)
workspace = Path("/tmp/my-workspace")
workspace.mkdir(exist_ok=True)
# build_executor() returns BoxliteExecutor for backend="auto"/"boxlite"
# __aenter__ creates and verifies the working VM; raises SandboxInitError if unavailable
# sandbox_dir tells the sandbox where a backend keeps its state. It is the
# resolver, not a path: resolving one creates the directory, and this call
# only reaches it when the backend is not "none".
async with build_executor(sandbox_cfg, workspace, sandbox_dir=get_sandbox_dir) as executor:
result = await executor.exec("echo hello from the VM")
print(result.as_text())
# → "hello from the VM\n\nExit code: 0"
result = await executor.exec("python3 --version")
print(result.as_text())
# cwd is translated from host path → /workspace/... automatically
result = await executor.exec("ls -la", cwd=str(workspace))
print(result.as_text())
asyncio.run(main())
exec() returns an ExecResult dataclass:
@dataclass
class ExecResult:
stdout: str
stderr: str
exit_code: int
def as_text(self, max_chars: int = 10_000) -> str: ...
as_text() combines stdout, a STDERR: block when stderr is non-empty, and an
Exit code: N line. The exit code is always included. Output longer than
max_chars is truncated in the middle, with a ... (N chars truncated) ...
marker indicating the omitted content.
Lifecycle — explicit start/stop:
from raven.config.paths import get_sandbox_dir
from raven.sandbox import SandboxInitError
from raven.sandbox.boxlite_executor import BoxliteExecutor
executor = BoxliteExecutor(
image="ubuntu:22.04",
workspace=Path("/tmp/ws"),
sandbox_home=get_sandbox_dir("boxlite"),
)
try:
await executor.start() # creates and verifies the working VM
except SandboxInitError as e:
print(f"Sandbox unavailable: {e}")
raise SystemExit(1)
result = await executor.exec("uname -r")
await executor.stop() # tears down the working VM
Lifecycle — context manager (recommended):
from raven.sandbox import SandboxInitError
try:
async with BoxliteExecutor(
image="ubuntu:22.04", workspace=Path("/tmp/ws"), sandbox_home=get_sandbox_dir("boxlite")
) as executor:
result = await executor.exec("uname -r")
print(result.stdout)
except SandboxInitError as e:
print(f"Sandbox unavailable: {e}")
Timeout:
# Uses default_timeout (120 s) when timeout=None
result = await executor.exec("sleep 10", timeout=5)
print(result.exit_code) # -1
print(result.stderr) # "Command timed out after 5s"
Environment variables:
result = await executor.exec(
"echo $MY_VAR",
env={"MY_VAR": "hello"},
)
print(result.stdout) # "hello\n"
4.2 DirectExecutor¶
DirectExecutor implements the same SandboxExecutor interface but runs commands directly on the host. Use it in tests or when sandboxing is explicitly disabled.
from raven.sandbox.direct_executor import DirectExecutor
async with DirectExecutor() as executor:
result = await executor.exec("pwd")
print(result.as_text())
build_executor() returns a DirectExecutor when backend is "none" or when sandbox_cfg is None:
from raven.config.paths import get_sandbox_dir
from raven.sandbox import build_executor, SandboxConfig
# No config — returns DirectExecutor
executor = build_executor(None, workspace, sandbox_dir=get_sandbox_dir)
# Explicit "none" — also returns DirectExecutor (no probe, no VM).
# sandbox_dir is never called on this path, so no boxlite home is created.
executor = build_executor(SandboxConfig(backend="none"), workspace, sandbox_dir=get_sandbox_dir)
5. Injecting an Executor into ExecTool¶
Pass an executor through the optional executor parameter. If omitted,
ExecTool creates a DirectExecutor and runs commands on the host.
from pathlib import Path
from raven.config.paths import get_sandbox_dir
from raven.sandbox import build_executor, SandboxConfig
from raven.agent.tools.shell import ExecTool
sandbox_cfg = SandboxConfig(backend="boxlite")
workspace = Path("/tmp/ws")
executor = build_executor(sandbox_cfg, workspace, sandbox_dir=get_sandbox_dir)
await executor.start() # creates and verifies the working VM; raises SandboxInitError if unavailable
tool = ExecTool(
working_dir=str(workspace),
timeout=60,
executor=executor, # inject the sandboxed executor
)
# ExecTool.execute() returns a plain string (the formatted output)
output = await tool.execute(command="python3 -c 'print(42)'")
print(output)
# → "42\n\nExit code: 0"
await executor.stop()
Sandboxing does not relax command permissions. The tool registry applies the
same denial and approval rules before dispatching commands to either backend.
Because /workspace is a read-write mount, deleting a file there also deletes
it on the host.
ExecTool enforces its own command and workspace restrictions.
restrict_to_workspace applies to both backends: commands that reference paths
outside the workspace are rejected and logged.
6. Wiring into AgentLoop¶
Pass SandboxConfig when constructing AgentLoop. The executor object is
created immediately; the VM starts when the runtime or a turn needs it:
from raven.agent.loop import AgentLoop
from raven.sandbox import SandboxConfig
loop = AgentLoop(
provider=provider,
workspace=workspace,
sandbox_config=SandboxConfig(
backend="boxlite",
image="python:3.11-slim",
cpus=2,
memory_mib=2048,
),
)
# run() calls _start_executor() before _connect_mcp(), unconditionally
await loop.run()
run_turn() (spine turn entry — CLI / cron / channels):
from raven.spine import ChatType, Origin, Source, TurnRequest
async def emit(event): # receives StreamDelta / Text / ToolEvent / ... events
...
await loop.run_turn(
TurnRequest(
origin=Origin.USER,
source=Source(channel="cli", chat_id="sandbox-demo", sender_id="user", chat_type=ChatType.DM),
text="write a hello-world Python script and run it",
conversation="cli:sandbox-demo",
),
emit,
lambda: [], # drain (INJECT/INTERRUPT) — empty for a plain turn
stream=False,
)
The executor lifecycle is managed by AgentLoop:
| Method | When called | What it does |
|---|---|---|
_start_executor() |
Entry of run_turn() |
Creates and verifies the working VM via executor.start(); idempotent — second call is a no-op; raises SandboxInitError on failure (the spine turns it into a TurnFailed event) |
close_executor() |
Shutdown (also called by close_mcp()) |
Calls _executor_stack.aclose() → executor.stop() → bridge tasks cancelled → VM torn down |
Teardown:
await loop.close_mcp() # closes MCP connections and the sandbox executor together
close_mcp() also calls close_executor(). MCP connections must close before
the VM stops because their stdio server processes run inside it. Repeating
close_executor() after cleanup has no effect.
MCP stdio servers:
When sandbox.backend is "auto" or "boxlite", stdio MCP servers are launched inside the VM rather than on the host. Three asyncio bridge tasks translate between boxlite's streaming execution API and the anyio MemoryObjectStream pairs that ClientSession expects:
_stdout_bridge— reads VM stdout chunks, buffers until\n, parses JSON-RPC, wraps inSessionMessage, forwards to read stream_stdin_bridge— receivesSessionMessagefrom write stream, extracts the innerJSONRPCMessage, serialises to JSON + newline, writes to VM stdin_stderr_bridge— reads VM stderr and forwards to application log atWARNINGlevel
Stderr is the primary signal for diagnosing MCP server startup failures:
WARNING MCP server stderr [npx]: cannot find module '@scope/server'
WARNING MCP server stderr [npx]: Error: ENOENT ...
Non-JSON stdout lines, such as npm progress messages during npx -y ...
startup, are logged at DEBUG level and skipped without interrupting
ClientSession. HTTP/SSE MCP servers use remote connections, so this process
bridge does not apply to them.
7. Wiring into SubagentManager¶
With sandboxing enabled, each subagent runs in its own VM rather than sharing the parent agent's VM.
from raven.agent.subagent import SubagentManager
from raven.sandbox import SandboxConfig
manager = SubagentManager(
provider=provider,
workspace=workspace,
sandbox_config=SandboxConfig(backend="boxlite"),
)
# spawn() returns immediately; the sub-agent runs in the background
handle = await manager.spawn(task="run the test suite and report failures")
_run_subagent() creates an executor for the subagent's workspace and runs the
task inside async with executor:. The VM starts with the task and is cleaned
up when the task finishes, including when it fails.
Each VM starts independently. Pre-pulling images can avoid repeated downloads when many subagents start concurrently; VM creation still has its own cost.
AgentLoop passes its sandbox_config to SubagentManager, so subagents
inherit the same configuration:
# In AgentLoop.__init__ (simplified)
self.subagents = SubagentManager(
...,
sandbox_config=sandbox_config, # same config, isolated VM per sub-agent
)
8. Advanced Configuration¶
8.1 Network policy¶
from raven.sandbox import SandboxConfig
# Full network access (default)
SandboxConfig(backend="boxlite", allow_net=True)
# No network
SandboxConfig(backend="boxlite", allow_net=False)
# Domain allowlist — only these hosts are reachable from inside the VM
SandboxConfig(
backend="boxlite",
allow_net=["pypi.org", "files.pythonhosted.org", "api.github.com"],
)
Note:
allow_net=[](empty list) is rejected at config validation time with aValueError. Useallow_net=Falseto disable networking entirely.
When allow_net is a domain list or False, BoxliteExecutor.start() uses a
temporary VM with unrestricted networking to prepare the OCI image, then
creates the working VM with the restricted policy. This adds an image
preparation step before the working VM starts.
8.2 Extra volume mounts¶
Mount additional host paths into the VM as read-only data or read-write scratch space. Both the host path and the VM path must be absolute.
SandboxConfig(
backend="boxlite",
extra_volumes=[
# [host_path, vm_path, mode]
["/Users/alice/datasets", "/data", "ro"], # read-only dataset
["/tmp/sandbox-cache", "/cache", "rw"], # writable scratch
],
)
JSON equivalent:
{
"tools": {
"sandbox": {
"backend": "boxlite",
"extra_volumes": [
["/Users/alice/datasets", "/data", "ro"],
["/tmp/sandbox-cache", "/cache", "rw"]
]
}
}
}
8.3 Resource limits¶
SandboxConfig(
backend="boxlite",
cpus=4,
memory_mib=8192,
disk_size_gb=50, # omit or set null for ephemeral disk (default)
default_timeout=300, # 5-minute default per command
create_timeout=600, # increase for large images or slow registries
)
Per-call timeout overrides the default:
# This call gets 10 s regardless of default_timeout
result = await executor.exec("sleep 20", timeout=10)
9. How to Run Tests¶
9.1 Prerequisites¶
| Requirement | Notes |
|---|---|
| Python 3.12+ | Check with python3 --version |
uv |
Project package manager; install with curl -LsSf https://astral.sh/uv/install.sh \| sh |
| boxlite (integration tests only) | macOS Apple Silicon M1+ or Linux x86_64/ARM64 with /dev/kvm |
| Node.js / npx (MCP roundtrip test only) | Provided by the node:20-slim OCI image — no local Node required |
Unit tests mock Boxlite and do not require a running VM or KVM. Use Python 3.12 or newer, as required by the project.
9.2 Set up the virtual environment¶
The project uses uv for dependency management.
# Clone and enter the repo
git clone <repo-url>
cd raven
# Create the virtual environment and install all project dependencies
uv sync
uv sync creates the project environment and installs the locked dependencies.
Use uv run for the commands below; manual activation is not required.
9.3 Install dependencies¶
Unit tests: install the core dependencies and development tools:
# Core project and development tools
uv sync
MCP is already a core dependency and does not need to be added separately.
Integration tests — additionally require the sandbox optional extra:
# Install the pinned Boxlite backend
uv sync --extra sandbox
Verify the installation:
# Sandbox package should import cleanly
uv run python -c "from raven.sandbox import build_executor, SandboxConfig; print('sandbox ok')"
# boxlite binary should be available (integration tests only)
uv run python -c "import boxlite; print('boxlite ok')"
9.4 Run unit tests¶
Unit tests cover SandboxConfig, DirectExecutor, a mocked BoxliteExecutor,
ExecTool restrictions, the AgentLoop executor lifecycle, and MCP bridge
tasks. They require no VM or KVM access.
uv run python -m pytest tests/test_sandbox_unit.py -v
Use pytest's summary for the current pass and skip counts. Test counts and runtime depend on the revision and environment.
Useful flags:
# Stop on first failure
uv run python -m pytest tests/test_sandbox_unit.py -x
# Run a specific test class
uv run python -m pytest tests/test_sandbox_unit.py::TestSandboxConfigValidators -v
# Show log output (useful for DEBUG-level bridge tracing)
uv run python -m pytest tests/test_sandbox_unit.py -v -s
# Filter by test name substring
uv run python -m pytest tests/test_sandbox_unit.py -k "translate_cwd"
9.5 Run integration tests¶
Integration tests start real Boxlite VMs. They require:
- The sandbox extra, installed with
uv sync --extra sandbox - macOS Apple Silicon M1+ or Linux with
/dev/kvmaccessible
On Linux without /dev/kvm, pytest skips the entire module.
First run — pre-pull OCI images:
A session-scoped fixture in test_sandbox_real_vm.py prepares ubuntu:22.04
and node:20-slim before the first test. Boxlite caches downloaded images for
later runs. Preparation time depends on the registry, network, and local cache.
If an image cannot be pulled, pytest skips the tests and reports the reason:
SKIPPED OCI image pull failed for 'ubuntu:22.04' — likely a network issue, not a code bug.
Fix: check connectivity or pre-pull manually: boxlite pull ubuntu:22.04
Run all integration tests:
uv run python -m pytest tests/integration/test_sandbox_real_vm.py -v
The MCP roundtrip test (test_npx_mcp_server_everything) installs
@modelcontextprotocol/server-everything via npm install -g inside the node:20-slim VM
on each run, then starts the MCP server and validates the full initialize +
list_tools flow.
Run unit and integration tests together:
uv run python -m pytest tests/test_sandbox_unit.py tests/integration/test_sandbox_real_vm.py -v
Run the full project test suite (all test files, excluding integration):
uv run python -m pytest tests/ --ignore=tests/integration/test_sandbox_real_vm.py -q
9.6 Run a single test¶
# A single test case by full name
uv run python -m pytest "tests/test_sandbox_unit.py::TestBoxliteTranslateCwd::test_subdir_translates_correctly" -v
# A single integration test
uv run python -m pytest "tests/integration/test_sandbox_real_vm.py::TestBoxliteStdioMCPRoundtrip::test_npx_mcp_server_everything" -v -s
9.7 Troubleshooting¶
ModuleNotFoundError: No module named 'boxlite'
uv sync --extra sandbox
ModuleNotFoundError: No module named 'mcp'
Restore the core dependencies in the project environment:
uv sync
PanicException: Another BoxliteRuntime is already using directory
A Boxlite runtime is already using the same state directory. List the candidates, then stop the one that owns the directory:
pgrep -af boxlite # command lines, so you can tell which process owns it
kill <pid> # then wait a couple of seconds and retry
Kill by pid rather than by pattern: a pattern wide enough to match the runtime also matches the shell you typed it in. If you need concurrent runtimes, give them separate sandbox state directories.
Integration tests skipped on Linux
Check that /dev/kvm exists and is accessible:
ls -la /dev/kvm
If KVM is unavailable or access is denied, ask the machine administrator to enable virtualization and grant the required device access to your account.
Image pull timeout (create_timeout exceeded)
The default create_timeout is 300 s. On a slow connection, increase it or pre-pull images manually:
# Pre-pull via a one-off Python script
uv run python -c "
import asyncio, boxlite
async def pull(img):
async with boxlite.SimpleBox(image=img, cpus=1, memory_mib=256): pass
for img in ['ubuntu:22.04', 'node:20-slim']:
print(f'Pulling {img}...')
asyncio.run(pull(img))
print(f' done')
"
10. Platform Requirements¶
| Platform | Requirement |
|---|---|
| macOS | Apple Silicon M1+, macOS 12+ (uses Hypervisor.framework) |
| Linux | x86_64 or ARM64, KVM enabled (/dev/kvm accessible to the current user) |
| Windows | x86_64 WSL2 with KVM enabled (/dev/kvm accessible to the current user) |
On unsupported platforms, sandbox.backend = "none" uses DirectExecutor.
Commands then run directly on the host without VM isolation.
Verify boxlite is installed and the sandbox package is importable:
uv run python -c "import boxlite; print('boxlite ok')"
uv run python -c "from raven.sandbox import build_executor, SandboxConfig; print('sandbox ok')"
To verify end-to-end (requires KVM / Apple Silicon):
uv run python -m pytest tests/integration/test_sandbox_real_vm.py -v