The SDK offers three local execution tiers, plus a remote OpenShell backend. Select one explicitly based on the access boundary you need; a working directory alone is not a security boundary.
Guarantees by tier
| Tier | Access | Requirements |
|---|---|---|
| process | Host filesystem and network access; temporary working directory | POSIX host |
| docker | Workdir writable, configured mounts read-only, network disabled unless enabled | Docker package and running daemon |
| seatbelt | Workdir writable, allowlisted reads, network disabled unless enabled | macOS with sandbox-exec |
An unavailable explicitly selected backend fails before dispatch. It never silently falls back to host execution.
Remote backend: OpenShell
OpenShellSandbox from harnessx.openshell is a remote backend rather than a tier. It runs run_bash and the file tools in an NVIDIA OpenShell sandbox, with per-program network rules and secrets that commands can use but never read. The loop, model calls, memory and hooks stay in your process. Unlike a local Sandbox, it belongs to the agent it is given: the agent creates it, copies your project in and back, and deletes it. See OpenShell sandbox.
Execute Python
import asyncio
from harnessx import Sandbox, SandboxConfig
async def main():
async with Sandbox(SandboxConfig(tier="process")) as sandbox:
result = await sandbox.execute("print(40 * 10)")
print(result.stdout)
asyncio.run(main())
This local process example has host access. Use docker or seatbelt when an access boundary is required, and verify its configuration on the target host.
Run the bash built-in inside a sandbox
Agent(sandbox=sandbox) binds the sandbox as the engine of the bash built-in, so shell commands the model requests run inside it. The registry exposes the binding as agent.tools.sandbox.
import asyncio
from harnessx import Agent, CliPermissionManager, Sandbox, SandboxConfig
async def main():
config = SandboxConfig(tier="process") # docker or seatbelt for an access boundary
async with Sandbox(config) as sandbox:
async with Agent(
tools=["bash"],
sandbox=sandbox,
permissions=CliPermissionManager(),
) as agent:
result = await agent.run("List the files in the working directory.")
result.raise_for_status()
print(result.output)
asyncio.run(main())
The sandbox is caller-owned; the agent does not close it. Passing a ToolRegistry that is already bound to a different sandbox raises ValueError.
Observe executions
Sandbox(config, hooks=hook_manager) emits HookEvent.SANDBOX_EXEC for every execute() and execute_command() with kind, tier, exit_code, timed_out, execution_time_ms, and denials (what a network policy refused; empty for the local tiers) in ctx.data (the SandboxExecData TypedDict in harnessx.hooks). Agent(sandbox=...) supplies the agent's own hooks when the sandbox has none, so agent.hooks.on(HookEvent.SANDBOX_EXEC, ...) observes the bash built-in's commands. See Middleware & hooks.
Limits and lifecycle
Timeout, CPU, and file-size limits apply as supported by the selected tier. Address-space limiting is platform-dependent and is not a universal memory isolation guarantee. Docker can enforce container memory limits.
The process backend kills and reaps its managed process group on timeout/cancellation. Deliberately detached descendants can escape that group. Cancellation does not reverse completed file or external changes.
Injected sandboxes remain caller-owned. Use their async context manager around the agent or application lifetime.
Try the coding example
uv run python examples/08-sandboxes/sandbox_isolation_tiers.py
uv run python examples/08-sandboxes/sandbox_isolation_tiers.py --tier docker
The default --tier process keeps host filesystem and network access. Use --tier docker, or --tier seatbelt on macOS, to isolate the code.
See Permissions for execution policy and Recovery for interrupted effects.
Backends, file tools and paths
The built-in tools run against an execution backend, described by the ExecutionBackend protocol exported from harnessx. run_bash needs only execute_command. A backend whose owns_filesystem is true takes the file tools too: read_file, write_file, edit_file, delete, glob, grep, list_directory and generate_file run inside it, with the host tools' output. Each file operation is a POSIX shell snippet sent through execute_command, and every path goes through the backend's resolve_path() first.
The local Sandbox has owns_filesystem = False, so with any of the three tiers the file tools stay on the host and only run_bash runs in the sandbox. OpenShellSandbox owns its filesystem.
Sandbox.resolve_path(path, *, write=False) returns the absolute path of path inside the sandbox's working directory, and raises PermissionError for a path outside it:
import asyncio
from harnessx import Sandbox, SandboxConfig
async def main():
async with Sandbox(SandboxConfig(tier="process")) as sandbox:
print(sandbox.resolve_path("out/report.txt")) # inside the working directory
try:
sandbox.resolve_path("../elsewhere.txt")
except PermissionError as error:
print(error)
asyncio.run(main())
execute_command(command, timeout=, stdin=) and execute(code, timeout=) take a per-call limit. It can shorten SandboxConfig.timeout_seconds for that call but never extend it. stdin is bytes fed to the command on the process and seatbelt tiers; the docker tier raises ValueError for it. On those two tiers a command without stdin gets closed stdin instead of inheriting your process's.
The run_bash built-in passes the model's timeout to the sandbox, capped by the configured limit.