Skip to Content
DocsSecuritySandboxes

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.

SandboxWhat it isolates
NoSandbox (default)nothing. Pass-through
FilesystemSandboxpath-typed tool args (rejects escapes, symlinks resolved)
SubprocessSandboxeach 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 roots

SubprocessSandbox

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 returns ToolResult.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 event

For 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.

Last updated on