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.
| 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 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.txtA 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 raisesConfigError. 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 underif __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 tooThe 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 eventToolResult.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.