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 |
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,
spawn_method="spawn", # default; "fork" is faster on Linux
)
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.
What you don’t get (yet):
- Filesystem isolation, network restrictions, or syscall sandboxing. For OS-level isolation, layer this with Bubblewrap / Seatbelt / Docker / gVisor as a follow-up wrapper.
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 takes ~100–300ms on macOS (which uses
spawn). 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.
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 real OS-level isolation (Bubblewrap / Seatbelt / gVisor), wrap
SubprocessSandbox and replace the spawn step with bwrap /
sandbox-exec / your container runtime.
Pick the right tool for the job. Filesystem tools → wrap in
FilesystemSandbox. Tools that crash or take long → wrap in
SubprocessSandbox. MCP servers → already process-isolated, no
sandbox needed. The default NoSandbox is fine for trusted in-proc
tools.