Agent Sandbox
Public Preview — execution isolation for AI agents with policy-driven
resource limits, tool proxies, network enforcement, and filesystem
checkpointing. Ships five interchangeable backends behind the same
SandboxProvider ABC.
Part of the Agent Governance Toolkit.
Providers at a glance
| Provider | Isolation primitive | Best for | Extra |
|---|---|---|---|
DockerSandboxProvider |
Hardened OCI container (runc, auto-upgrades to gVisor / Kata) | Local dev, CI, self-hosted runners | agt-sandbox[docker] |
HyperLightSandboxProvider |
KVM / mshv / WHP micro-VM via hyperlight-sandbox | Sub-millisecond cold start, per-call VM isolation | agt-sandbox[hyperlight] |
ACASandboxProvider |
Azure Container Apps sandbox (managed) | Production, multi-tenant, no infra to run | agt-sandbox[azure] + the early-access SDK wheel |
MxcSandboxProvider |
OS-native containment via the MXC binary (bubblewrap / AppContainer / Seatbelt / micro-VM) | No daemon, hypervisor SDK, or cloud account; CI and laptops | native MXC binary (no Python dep) — see tutorial |
NonoSandboxProvider |
OS-native kernel sandbox via nono (Landlock on Linux, Seatbelt on macOS) with a filtering network proxy | Kernel-enforced isolation with a pure-Python install; CI and laptops (Linux/macOS only) | agt-sandbox[nono] |
All five implement the same async + sync API (create_session,
execute_code, destroy_session, plus *_async variants) and consume
the same PolicyDocument for resource caps, network allowlists, and
tool allowlists.
Installation
# Everything (Docker + Hyperlight + policy engine):
pip install "agt-sandbox[full]"
# Pick what you need:
pip install "agt-sandbox[docker]"
pip install "agt-sandbox[hyperlight]"
pip install "agt-sandbox[azure,policy]"
pip install "agt-sandbox[nono]" # Linux/macOS only
The Azure data-plane SDK ships as an early-access wheel — pin the URL:
pip install https://github.com/microsoft/azure-container-apps/releases/download/python-sdk-v0.1.0b1-early-access/azure_containerapps_sandbox-0.1.0b1-py3-none-any.whl
Quick start (all five providers)
from agent_sandbox import (
DockerSandboxProvider,
HyperLightSandboxProvider,
ACASandboxProvider,
MxcSandboxProvider,
NonoSandboxProvider,
)
# Pick one:
provider = DockerSandboxProvider()
# provider = HyperLightSandboxProvider(backend="wasm")
# provider = MxcSandboxProvider(backend="bubblewrap")
# provider = NonoSandboxProvider() # Linux/macOS, kernel-enforced
# provider = ACASandboxProvider(
# resource_group="my-rg", sandbox_group="agents",
# region="eastus2", disk="python-3.13",
# ensure_group_location="eastus2",
# )
handle = provider.create_session("agent-1")
out = provider.execute_code("agent-1", handle.session_id, "print('hello')")
print(out.result.stdout)
provider.destroy_session("agent-1", handle.session_id)
1. DockerSandboxProvider — local hardened containers
Each agent session runs in its own container with capabilities dropped, no privilege escalation, a read-only root filesystem, a non-root user, and no network by default.
import asyncio
from agent_sandbox import (
DockerSandboxProvider,
IsolationRuntime,
SandboxConfig,
)
async def run_agent_task():
provider = DockerSandboxProvider(
image="python:3.12-slim",
runtime=IsolationRuntime.AUTO, # auto-upgrade to gVisor / Kata
)
config = SandboxConfig(
timeout_seconds=30,
memory_mb=256,
cpu_limit=0.5,
network_enabled=False,
read_only_fs=True,
)
session = await provider.create_session_async("research-agent", config=config)
try:
execution = await provider.execute_code_async(
"research-agent", session.session_id,
"import json, math; print(json.dumps([math.sqrt(x) for x in range(5)]))",
)
print(execution.result.stdout)
checkpoint = provider.save_state(
"research-agent", session.session_id, "after-step-1",
)
print(f"Checkpoint saved: {checkpoint.image_tag}")
finally:
await provider.destroy_session_async("research-agent", session.session_id)
asyncio.run(run_agent_task())
What the Docker sandbox enforces
| Control | Default |
|---|---|
| Linux capabilities | All dropped (--cap-drop=ALL) |
| Privilege escalation | Blocked (--security-opt=no-new-privileges) |
| Root filesystem | Read-only |
| Container user | nobody (UID 65534) |
| PID limit | 256 |
| Network | Disabled unless explicitly allowed |
| Runtime | runc (auto-upgrades to gVisor or Kata when available) |
| State | save_state / restore_state via image commit |
Filesystem mounts come from the policy: sandbox_mounts.input_dir is
bind-mounted read-only and sandbox_mounts.output_dir read-write
(see Policy-driven configuration).
2. HyperLightSandboxProvider — micro-VM isolation
Backed by the upstream hyperlight-sandbox
runtime. Each session is a fresh micro-VM on KVM (Linux), mshv (Azure
HCL), or WHP (Windows) — typical cold start is well under a millisecond.
Tools are registered as host functions and invoked synchronously from
the guest, gated by the session's policy.tool_allowlist.
from agent_sandbox import HyperLightSandboxProvider
def fetch_arxiv(query: str) -> str:
return f"<results for {query}>"
provider = HyperLightSandboxProvider(
backend="wasm", # or "hyperlightjs" / "nanvix"
module="python_guest", # only meaningful for backend="wasm"
tools={"fetch_arxiv": fetch_arxiv},
)
if not provider.is_available():
raise SystemExit(f"Hyperlight unavailable: {provider.unavailable_reason}")
handle = provider.create_session("agent-1")
out = provider.execute_code(
"agent-1", handle.session_id,
"print(fetch_arxiv('cs.CL'))",
)
print(out.result.stdout)
provider.destroy_session("agent-1", handle.session_id)
Notes:
- Each session owns one OS thread that is the sole code path touching
its
Sandbox— required by the upstream runtime. provider.is_available()probes for a hypervisor and returnsunavailable_reasonif none is present (e.g. on macOS hosts without WHP / KVM passthrough).- Only tools listed in a session's
policy.tool_allowlistare exposed to that session's guest; the rest stay host-side. - Filesystem mounts come from the policy:
sandbox_mounts.input_diris mounted into the guest as read-only/inputandsandbox_mounts.output_diras writable/output.
3. ACASandboxProvider — Azure Container Apps
Runs each session inside a managed Azure Container Apps sandbox via the
early-access azure-containerapps-sandbox Python SDK
(complete reference).
Same API as the other providers; the rest of your code is unchanged.
pip install "agt-sandbox[azure,policy]"
pip install https://github.com/microsoft/azure-container-apps/releases/download/python-sdk-v0.1.0b1-early-access/azure_containerapps_sandbox-0.1.0b1-py3-none-any.whl
az login # or use managed identity in hosted compute
from agent_sandbox import ACASandboxProvider
provider = ACASandboxProvider(
resource_group="my-rg", # must already exist
sandbox_group="agents", # auto-created if ensure_group_location is set
region="eastus2", # selects the data-plane endpoint
subscription_id=None, # falls back to AZURE_SUBSCRIPTION_ID env var
disk="python-3.13", # public disk image with python3 preinstalled
ensure_group_location="eastus2", # create the sandbox group on first use
)
if not provider.is_available():
raise SystemExit(f"ACA unavailable: {provider.unavailable_reason}")
handle = provider.create_session("agent-1")
out = provider.execute_code(
"agent-1", handle.session_id, "print('hello azure')"
)
print(out.result.stdout)
provider.destroy_session("agent-1", handle.session_id)
provider.close()
The provider holds one SandboxGroupClient per (resource_group, sandbox_group) pair and caches the per-sandbox SandboxClient returned
by begin_create_sandbox().result(). When a PolicyDocument is
supplied, network_allowlist is translated into a fail-closed egress
policy (defaultAction: Deny + per-host Allow rules) and applied via
SandboxClient.set_egress_policy. Set defaults.network_default: allow
in the policy if you explicitly want the SDK's default-allow behaviour.
A complete worked example (8 verified branches against live Azure —
allow / policy-deny / egress-block / sanity / tool-allowed /
tool-denied / remote-execution proof / egress audit) lives at
examples/quickstart/aca_sandbox_test.py
and reads its policy from
examples/quickstart/policies/aca_research_agent.yaml.
4. MxcSandboxProvider — OS-native containment via MXC
Runs each execution behind whichever OS-native primitive the host
provides, driven by the MXC
(Microsoft eXecution Container) native binary — bubblewrap / LXC on
Linux, AppContainer on Windows, Seatbelt on macOS, plus experimental
micro-VM backends. No daemon, hypervisor SDK, or cloud account, and
no new Python dependency: MXC ships as a native binary you build
from source and place on PATH (or point to with MXC_BINARY).
from agent_sandbox import MxcSandboxProvider, SandboxConfig
provider = MxcSandboxProvider(backend="bubblewrap") # None = platform default
if not provider.is_available():
raise SystemExit("MXC binary not found; set MXC_BINARY or add it to PATH")
# One-shot: create + execute + destroy in a single call (no session_id
# to track), since the MXC sandbox self-destructs after each run.
execution = provider.run_once(
"agent-1",
"print('hello from mxc')",
config=SandboxConfig(timeout_seconds=20, network_enabled=False),
)
print(execution.result.stdout)
For repeated executions that share the persistent output/ directory,
use the full create_session + execute_code lifecycle instead.
Because the MXC schema has no tool or resource-cap concept,
tool_allowlist is enforced host-side before spawn and max_cpu /
max_memory_mb are carried but not rendered into the MXC config. See
the MXC quickstart tutorial and the
design doc for details.
5. NonoSandboxProvider — OS-native kernel sandbox via nono
Runs each execution behind a capability set enforced by OS-native
kernel primitives — Landlock
on Linux (kernel 5.13+) and Seatbelt on macOS — using the
nono library's nono-py
bindings. No daemon, hypervisor SDK, or cloud account; install with
agt-sandbox[nono] (prebuilt wheels). Network egress is mediated by a
built-in filtering proxy restricted to the policy's network_allowlist.
Linux/macOS only — there are no Windows wheels.
from agent_sandbox import NonoSandboxProvider, SandboxConfig
provider = NonoSandboxProvider()
if not provider.is_available():
raise SystemExit("nono not supported here (needs Linux+Landlock or macOS)")
# One-shot: create + execute + destroy in a single call, since each nono
# sandbox is a fresh forked child that exits after the run.
execution = provider.run_once(
"agent-1",
"print('hello from nono')",
config=SandboxConfig(timeout_seconds=20, network_enabled=False),
)
print(execution.result.stdout)
For repeated executions that share the persistent output/ directory (and
a long-lived network proxy), use the full create_session + execute_code
lifecycle. nono has no in-sandbox tool channel, so a non-empty
tool_allowlist is refused at session creation
rather than silently ignored, and max_cpu / max_memory_mb are delegated
to the OS. See the
design doc for details.
Production readiness.
nono-pyis PyPI-classified Alpha (upstream always-further/nono). Kernel enforcement (Landlock / Seatbelt) is structurally stronger than in-process guards, but the project is still maturing — use for defense-in-depth, dev, and CI first; run your own security review before treating it as a production hard boundary. On Windows or kernels without Landlock, useDockerSandboxProvideror another backend.
Policy-driven configuration
All five providers consume the same agent_os.policies.PolicyDocument.
Sandbox resource caps, network allowlists, tool allowlists, and
filesystem mounts (sandbox_mounts) are native fields on the schema, so
policies live in YAML and load directly with PolicyDocument.from_yaml.
Provider-specific notes:
| Provider | tool_allowlist |
Platform |
|---|---|---|
DockerSandboxProvider |
host-side enforcement | Linux, macOS, Windows |
HyperLightSandboxProvider |
in-sandbox tool registration | Linux, macOS, Windows |
ACASandboxProvider |
host-side enforcement | cloud (Azure) |
MxcSandboxProvider |
host-side enforcement | Linux, macOS, Windows |
NonoSandboxProvider |
refused if non-empty — use a tool_name rule instead |
Linux, macOS only |
See examples/quickstart/nono_sandbox_test.py and
examples/quickstart/policies/nono_research_agent.yaml for a full
governed nono walkthrough (policy gate → AST scan → kernel isolation).
name: research-agent
version: "2"
defaults:
action: allow
max_cpu: 1.0
max_memory_mb: 2048
timeout_seconds: 90
network_default: deny
network_allowlist:
- api.openai.com
- "*.github.com"
tool_allowlist:
- fetch_arxiv
sandbox_mounts:
input_dir: /data/agent-input # mounted read-only
output_dir: /data/agent-output # mounted read-write
rules:
- name: deny-shell-out
condition: { field: code, operator: contains, value: subprocess }
action: deny
priority: 100
message: "shell-out blocked by research-agent policy"
from agent_os.policies import PolicyDocument
policy = PolicyDocument.from_yaml("policies/aca_research_agent.yaml")
handle = await provider.create_session_async("agent-1", policy=policy)
Hardened sandbox image (minimal-PATH)
docker/Dockerfile.sandbox is an opt-in hardened variant of the default
python:3.11-slim base. It pins PATH to a single explicit directory
(/usr/local/sandbox-bin) containing only the binaries sandboxed code is
allowed to invoke, and strips the execute bit off well-known network and
infra CLIs (curl, wget, ssh, git, az, aws, gcloud, kubectl,
terraform, helm, ansible, apt, dpkg, …) as a second-layer guarantee
in case a caller goes through an absolute path.
This closes the gap that issue #2662
identifies: without a pinned PATH, a tool can shell out to az account list
inside the sandbox and the attempt is not blocked or logged by AGT even though
the network-egress policy would later refuse the call.
Logging denial shim (#2662 option 2)
The pinned PATH and execute-bit stripping prevent denied commands, but a bare
"command not found" / EACCES is silent — and for compliance, detecting the
attempt matters as much as preventing it. The image therefore routes the denied
network/infra CLIs (curl, az, kubectl, terraform, …) to a small Python
logging shim (docker/agt-deny-shim.py), installed both at each binary's real
path (so absolute-path calls are caught) and under its name in the pinned PATH
dir (so by-name calls are caught). Any attempt:
- writes a structured
command_deniedJSON record to stderr (captured inSandboxResult.stderr), e.g.{"argv":["account","list"],"binary":"az",...}; - optionally appends the same record to
$AGT_DENIED_LOGwhen that path is set and writable; - exits
126, so the real command never runs.
The shim is Python (not shell) because the image strips the execute bit off
every shell; python3 is an allowed interpreter. Shells, interpreters, and
encoders stay execute-bit-stripped — disabled but not logged.
Behavior change vs. the bare minimal-PATH image. Routing a denied binary
through the shim makes it executable again (the shim itself runs), so an
absolute-path call now exits 126 with a logged record instead of raising
EACCES/PermissionError. The denial signal is the non-zero exit plus the
command_denied record, not an OS-level permission error. (No in-tree caller
relies on the PermissionError form; sandboxed code is still denied either
way.)
Customizing the routed set. The DENIED_LOGGED_BIN_NAMES build-arg
replaces the default set rather than extending it — pass the full list you
want logged, not just additions, or the image will silently under-restrict.
The allow-list wins: a name present in both ALLOWED_BIN_NAMES and
DENIED_LOGGED_BIN_NAMES is left allowed (not shimmed), so do not list the same
binary in both.
# Build with the default allow-list (python3, cat, echo, ls, sleep).
docker build \
-f agent-sandbox/docker/Dockerfile.sandbox \
-t agt-sandbox/python-minimal-path:3.11 \
agent-sandbox/docker
# Build with a custom allow-list — add only what the sandboxed workload
# actually needs. The full allow-list IS the new PATH; any binary not listed
# here is unreachable.
docker build \
--build-arg ALLOWED_BIN_NAMES="python3 cat echo ls sleep grep sort uniq" \
-f agent-sandbox/docker/Dockerfile.sandbox \
-t agt-sandbox/python-minimal-path:3.11 \
agent-sandbox/docker
Wire the image into DockerSandboxProvider via the existing image argument:
provider = DockerSandboxProvider(image="agt-sandbox/python-minimal-path:3.11")
For security-sensitive deployments, require the hardened image so the
provider fails instead of silently falling back to python:3.11-slim when
the local image is unavailable:
provider = DockerSandboxProvider(require_hardened_image=True)
Build the image before creating the provider. require_hardened_image=True
cannot be combined with a custom image=.
To extend the allow-list permanently (rather than at docker build time),
edit the ARG ALLOWED_BIN_NAMES= line in Dockerfile.sandbox and rebuild.
The tests/test_docker_sandbox.py::TestMinimalPathSandboxImage smoke tests
assert that the default allow-list cannot accidentally regress to include
network or infra CLIs.
License
MIT
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agt_sandbox-5.0.0.tar.gz.
File metadata
- Download URL: agt_sandbox-5.0.0.tar.gz
- Upload date:
- Size: 146.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
RestSharp/106.13.0.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
da3292fbd1a6479ee3decd59a78cf70e2c80ea7fefacd872d918c7d33662628b
|
|
| MD5 |
6dcc5160c76a0aac9d267e2390f24377
|
|
| BLAKE2b-256 |
c9c91267bffafdaade98a5eea2bf39d315e542183bc8c23f5d66c1e514d83759
|
File details
Details for the file agt_sandbox-5.0.0-py3-none-any.whl.
File metadata
- Download URL: agt_sandbox-5.0.0-py3-none-any.whl
- Upload date:
- Size: 88.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
RestSharp/106.13.0.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cbcede3d21938d7a159ec29133fe7ca66a3d98c9a326bc0a59b260c15269556b
|
|
| MD5 |
ac4534b9e28a59ae60e39b61a6a77c51
|
|
| BLAKE2b-256 |
5d2577e87ddb0e2b2db264c1db5856f0b8ce6c351ef9c55bf8c564fc0ba56b14
|