Sandboxes
A sandbox wraps a ToolHost to add isolation. The Agent calls
through the sandbox, the sandbox enforces its policy, then dispatches
to the wrapped host.
| Sandbox | What it isolates |
|---|---|
NoSandbox (default) | nothing. Pass-through |
FilesystemSandbox | path-typed tool args (rejects escapes, symlinks resolved) |
SubprocessSandbox | each tool call runs in a fresh child Python process |
OSSandbox | kernel-enforced filesystem + network isolation (Seatbelt / Bubblewrap) |
FilesystemSandbox
Validates path-typed arguments. Any path that resolves outside the declared roots. Including via symlink, is rejected before the tool runs.
from loomflow import Agent, tool
from loomflow.security import FilesystemSandbox
from loomflow.tools import InProcessToolHost
@tool
def read_file(path: str) -> str:
"""Read file contents."""
return open(path).read()
inner = InProcessToolHost([read_file])
sandbox = FilesystemSandbox(inner, roots=["/Users/me/safe-workspace"])
agent = Agent("...", model="claude-opus-4-7", tools=sandbox)The sandbox auto-detects path-typed arguments by:
- Argument name,
path,file,directory,filename,dirname, etc. - Argument value. Strings containing
/or\(treats them as paths even if not auto-named).
Reject reasons returned to the model:
ERROR: path '/etc/passwd' is outside the allowed roots
ERROR: path '../../etc/passwd' resolves outside ['/Users/me/safe-workspace']
ERROR: symlink target '/etc/passwd' is outside the allowed rootsSubprocessSandbox
Runs each tool call in a fresh child Python process.
from loomflow import Agent
from loomflow.security import SubprocessSandbox
from loomflow.tools import InProcessToolHost
inner = InProcessToolHost([heavy_tool])
sandbox = SubprocessSandbox(
inner,
timeout_seconds=30.0,
)
agent = Agent("...", model="claude-opus-4-7", tools=sandbox)What you get:
- Process isolation. A tool that crashes (segfault, OOM) takes down its own subprocess, not the agent.
- Hard timeout. The parent kills the child if it exceeds
timeout_seconds; the call returnsToolResult.error_(...)with a clear timeout message. - Memory boundary. The child’s heap is independent; large intermediates GC by process exit even if the tool leaks them.
SubprocessSandbox provides process isolation only. For
kernel-enforced filesystem and network isolation, use
OSSandbox.
Constraints
- Wrapped host must be
InProcessToolHost. MCP / external hosts can’t be sandboxed this way (they’re already a process boundary). - Tool function and arguments must be picklable. Module-level functions are fine; closures and locally-defined functions can’t cross the process boundary.
Cost
Spawning a Python subprocess adds per-call latency. Don’t use this for fast tools — the spawn dwarfs the work. Pays off when tools take seconds, can crash, or use a lot of memory.
OSSandbox
Kernel-enforced isolation: each tool call runs in a child process
launched under the platform’s OS sandbox — Seatbelt
(sandbox-exec) on macOS, Bubblewrap (bwrap) on Linux. The
generated profile denies by default and allows only reads, plus writes
under the declared roots. Network access is blocked unless
allow_network=True.
from loomflow import Agent
from loomflow.security import OSSandbox
from loomflow.tools import InProcessToolHost
inner = InProcessToolHost([heavy_tool])
sandbox = OSSandbox(
inner,
roots=["/work"], # at least one writable root required
allow_network=False, # kernel blocks all network access
timeout_seconds=30.0,
)
agent = Agent("...", model="claude-opus-4-7", tools=sandbox)On a platform with no backend (Windows, or Linux without bwrap),
OSSandbox degrades to FilesystemSandbox(SubprocessSandbox(inner)) —
still process-isolated and path-contained, just not kernel-enforced.
The active backend is reported via OSSandbox.mode.
Like SubprocessSandbox, the wrapped host must be InProcessToolHost
and tool functions and arguments must be picklable.
Combining sandboxes
Wrap layer-by-layer. Each sandbox accepts the layer below it as its
inner host:
from loomflow.security import FilesystemSandbox, SubprocessSandbox
from loomflow.tools import InProcessToolHost
inner = InProcessToolHost([read_file, write_file, edit_file])
fs = FilesystemSandbox(inner, roots=["/work"])
both = SubprocessSandbox(fs, timeout_seconds=60)
agent = Agent("...", tools=both)The agent sees both; tool calls go agent → SubprocessSandbox → FilesystemSandbox → InProcessToolHost → tool function. Both layers
get to inspect / reject before the tool runs.
Custom sandboxes
Implement the Sandbox protocol. Wrap a ToolHost and forward the
methods you care about:
from collections.abc import AsyncIterator
from loomflow.core.protocols import ToolHost
from loomflow.core.types import ToolCall, ToolDef, ToolEvent, ToolResult
class MySandbox:
def __init__(self, inner: ToolHost):
self._inner = inner
async def list_tools(self) -> list[ToolDef]:
return await self._inner.list_tools()
async def call(self, call: ToolCall) -> AsyncIterator[ToolEvent]:
# Pre-call check
if not self._allowed(call):
yield ToolEvent.result(call, ToolResult.error_("blocked"))
return
# Forward to inner
async for event in self._inner.call(call):
yield eventFor kernel-enforced OS-level isolation, use the built-in
OSSandbox rather than rolling your own.
Pick the right tool for the job. Filesystem tools → wrap in
FilesystemSandbox. Tools that crash or take long → wrap in
SubprocessSandbox. Untrusted code needing kernel-level filesystem
and network isolation → OSSandbox. MCP servers → already
process-isolated, no sandbox needed. The default NoSandbox is fine
for trusted in-proc tools.