Skip to Content
SecuritySandboxes

Sandboxes

A sandbox limits what a tool can touch when it runs. Use one when the agent’s tools read or write files, run code, or could crash or hang: the sandbox checks or isolates each call so a bad call can’t escape its workspace or take the agent down with it.

A sandbox wraps a ToolHost. 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
OSSandboxkernel-enforced filesystem + network isolation (Seatbelt / Bubblewrap)

FilesystemSandbox

Validates path-typed arguments. Any path that resolves outside the declared roots, including through a symlink, is rejected before the tool runs.

from pathlib import Path from loomflow import Agent, tool from loomflow.security import FilesystemSandbox from loomflow.tools import InProcessToolHost ROOT = Path("/Users/me/safe-workspace") @tool def read_file(path: str) -> str: """Read file contents.""" # Resolve against the root, as the sandbox does: a bare open(path) # would read a relative path from the process's working directory. return (ROOT / path).read_text() inner = InProcessToolHost([read_file]) sandbox = FilesystemSandbox(inner, roots=[ROOT]) agent = Agent("...", model="claude-opus-4-7", tools=sandbox)

The sandbox auto-detects path-typed arguments by:

  • Argument name: path, file, filename, filepath, directory, dir, folder, src, source, dst, destination, target, and names ending in one of these after a _, - or . (output_path, src-files).
  • Argument value: any string containing / or \ is treated as a path even if its name isn’t path-like.

Lists, tuples and dicts are searched too, so {"paths": ["/etc/passwd"]} is caught. To check only specific arguments, pass path_args=("path", "destination").

Relative paths resolve against base=, which defaults to the first root (where the built-in workdir tools open them), not the process’s working directory. file:// URLs and ~ paths are checked as the paths they name. A value the check can’t decide, such as one with a null byte or arguments nested deeper than the scan goes, is refused.

sandbox = FilesystemSandbox(inner, roots=["/work", "/data"], base="/work") # "notes.txt" is checked as /work/notes.txt

A rejected call never reaches the tool; the model gets a denied result like:

FilesystemSandbox: argument 'path' resolves outside the allowed roots ('/etc/passwd')

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 from my_tools import heavy_tool # a plain function in your own module 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, and the call returns an error result with a clear timeout message.
  • Memory boundary: the child’s heap is independent, so large intermediates are freed when the process exits even if the tool leaks them.

SubprocessSandbox provides process isolation only. For kernel-enforced filesystem and network isolation, use OSSandbox.

Constraints

  • The wrapped host must be an InProcessToolHost; anything else raises ConfigError. MCP and other external hosts can’t be sandboxed this way (they’re already a process boundary).
  • The tool function and its arguments must be picklable, because the function is sent to the child process. Register plain module-level functions that live in an importable module. Closures, locally defined functions, and functions defined in the script you run directly can’t cross the process boundary. @tool-decorated module-level functions are fine: they’re sent by name and unwrapped in the child.
  • Child processes are always started with spawn (on Linux too), which re-imports your entry script, so keep the startup code under if __name__ == "__main__":.

Cost

Spawning a Python subprocess adds roughly 100 to 300 ms per call. Don’t use this for fast tools, where the spawn dwarfs the work. It 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 or Bubblewrap (bwrap) on Linux. The generated profile denies by default. The child can read only the system libraries, the Python installation, the tool’s own top-level package (and loomflow), the declared roots and a private per-call temp dir; it can write only under the roots and that temp dir. The rest of your project, your home directory and /etc are unreadable. Network access is blocked unless allow_network=True.

The child starts with a minimal environment (PATH, HOME set to the first root, TMPDIR, locale), not the parent’s, where your API keys live. Pass env={...} for the variables a tool needs. It starts in the first root, so a relative path in a tool resolves there. (Before 0.17.3, Seatbelt’s child started in the parent’s working directory, and every call failed when the profile didn’t let it read that directory.) Its result comes back as JSON, never as a pickle, so a compromised tool can’t run code in the parent; a result that isn’t JSON-serialisable comes back as its str().

from loomflow import Agent from loomflow.security import OSSandbox from loomflow.tools import InProcessToolHost from my_tools import heavy_tool # a plain function in your own module 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, env={"DATA_DIR": "/work/data"}, # added to the child's minimal env ) 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, and allow_network can’t be enforced. The degraded child also inherits the parent’s environment (env= isn’t applied) and returns its result by pickle, as SubprocessSandbox does. The active backend is reported via OSSandbox.mode ("seatbelt", "bubblewrap" or "degraded"); check it at startup if you rely on the kernel guarantees.

Like SubprocessSandbox, the wrapped host must be InProcessToolHost and the tool must be importable by module path (@tool functions, async tools, the functions in a skill’s tools.py and skill scripts are fine) with picklable arguments. A skill tool’s child may also read the skill’s own directory.

Combining sandboxes

Wrap layer by layer. Each sandbox accepts the layer below it as its inner host. SubprocessSandbox must sit directly on the InProcessToolHost, so put FilesystemSandbox on the outside:

from loomflow import Agent from loomflow.security import FilesystemSandbox, SubprocessSandbox from loomflow.tools import InProcessToolHost from my_tools import read_file, write_file # plain module-level functions inner = InProcessToolHost([read_file, write_file]) proc = SubprocessSandbox(inner, timeout_seconds=60) both = FilesystemSandbox(proc, roots=["/work"]) agent = Agent("...", model="claude-opus-4-7", tools=both)

The agent sees both; tool calls go agent → FilesystemSandbox → SubprocessSandbox → InProcessToolHost → tool function. The path check runs first, in process, so a rejected call never pays for a subprocess spawn. This is the same stack OSSandbox falls back to when no kernel backend is available.

Adding tools under a sandbox

Every built-in sandbox has register(tool), which adds a tool to the host it wraps (through an extension layer if that host can’t take one), so calls to it go through the sandbox like the rest:

from loomflow.security import FilesystemSandbox from loomflow.tools import InProcessToolHost from my_tools import read_file, write_file sandbox = FilesystemSandbox(InProcessToolHost([read_file]), roots=["/work"]) sandbox.register(write_file) # its path arguments are checked too

The agent uses this for a skill’s bundled tools: when tools= is a sandbox, the tools a skill brings are registered under it when the skill loads, instead of beside it. The agent’s own framework tools (the workspace notebook, the living plan and load_skill) work on in-process state, so they stay on an in-process layer beside the sandbox. (In 0.17.0 to 0.17.2 they were registered inside it, and every call to them failed under SubprocessSandbox and OSSandbox.)

Custom sandboxes

A sandbox is any ToolHost that wraps another one: implement list_tools, call and watch, check each call, and forward it to the inner host. This one refuses a fixed set of tools:

from collections.abc import AsyncIterator, Mapping from typing import Any from loomflow.core.protocols import ToolHost from loomflow.core.types import ToolDef, ToolEvent, ToolResult class BlockListSandbox: def __init__(self, inner: ToolHost, blocked: set[str]) -> None: self._inner = inner self._blocked = blocked async def list_tools(self, *, query: str | None = None) -> list[ToolDef]: return await self._inner.list_tools(query=query) async def call( self, tool: str, args: Mapping[str, Any], *, call_id: str = "" ) -> ToolResult: if tool in self._blocked: return ToolResult.denied_(call_id, f"{tool} is blocked by this sandbox") return await self._inner.call(tool, args, call_id=call_id) async def watch(self) -> AsyncIterator[ToolEvent]: async for event in self._inner.watch(): yield event

ToolResult.denied_(call_id, reason) reports a refusal, the same way the built-in sandboxes do. Use ToolResult.error_(call_id, message) when the call was allowed but failed.

For kernel-enforced OS-level isolation, use the built-in OSSandbox rather than rolling your own.

Pick the right tool for the job. Wrap filesystem tools in FilesystemSandbox, and tools that crash or run long in SubprocessSandbox. Use OSSandbox for untrusted code that needs kernel-level filesystem and network isolation. MCP servers are already process-isolated and need no sandbox. The default NoSandbox is fine for trusted in-process tools.

Last updated on