Sandboxed code¶
agent_adapter.sandbox lets a model write code and run it somewhere it can do no harm.
You pick where: a locked-down Docker container, a hosted sandbox (E2B, Modal, Daytona or
your own service) or, for development only, a local process. The adapter adds input
checks, hard limits on time and output, typed errors, trace events and a registry tool
that always asks a person before it runs anything.
flowchart LR
M["model: run_code(code)"] --> E["Executor<br/>hooks · budgets"]
E --> A{"approval port<br/>confirm=True, always"}
A -- denied or none --> X["ApprovalDenied<br/>nothing runs"]
A -- approved once --> G["GuardedSandbox<br/>check code, files, env, limits"]
G --> B["backend<br/>Docker · Callable · Local (dev)"]
B --> R["cut output and files<br/>to the limits"]
R --> V["sandbox.started / finished<br/>sizes and exit codes"]
R --> M
| File | Does |
|---|---|
sandbox/port.py |
SandboxPort, SandboxResult, SandboxFile |
sandbox/_checks.py |
the shared checks, Limits, SandboxRequest |
sandbox/_base.py |
GuardedSandbox: checks, limits re-applied, events, run_sync |
sandbox/docker.py |
DockerSandbox, docker_status |
sandbox/callable.py |
CallableSandbox: any code-execution service |
sandbox/local.py |
LocalSubprocessSandbox: DEVELOPMENT ONLY |
sandbox/_bootstrap.py |
the runner inside the sandbox: writes files, runs, reports JSON |
sandbox/tool.py |
code_tool: a sandbox as a registry tool |
Code enters through run() or the tool. It leaves only to the backend. Results leave to
the caller. Events carry sizes, counts and exit codes; the code and output are added only
with capture_content=True.
The tool¶
def hosted_service(request: SandboxRequest) -> SandboxResult:
"""Stands in for a hosted sandbox (E2B, Modal, Docker); it never runs code on this machine."""
answers = {CODE: "25.0\n"}
return SandboxResult(exit_code=0, stdout=answers.get(request.code, ""))
sandbox = CallableSandbox(hosted_service, name="hosted")
registry = Registry()
registry.add(code_tool(sandbox, timeout_s=20, max_output_bytes=10_000)) # always confirm=True
code_tool(sandbox, *, name="run_code", languages=("python",), timeout_s=30,
max_output_bytes=64_000, network=False, env=None, files=None, on_result=None):
confirm=Trueis fixed. There is no option to turn it off: with no approval port the executor denies the call, and each approval is used once, by the call it answers.- The model chooses only
code(andlanguagewhen you offer more than one). Limits, network, env and input files are yours. - It returns
ok,exit_code,timed_out,stdout,stderr, the*_truncatedflags and the names and sizes of files the code wrote.on_result(result, ctx)gets the fullSandboxResult, for example to keep a chart the code drew. Files with unsafe or repeated names are dropped (files_truncated=True), but names still come from the code: never join one to a host path without checking it again. - A timeout is a result (
timed_out=True,exit_code=-1), not an error, so the model can try again with faster code.
def reviewer(request: ApprovalRequest) -> bool:
"""EXAMPLE ONLY: approves everything. In production a person reads the code and decides."""
print("approve?", request.tool, repr(request.arguments["code"]))
return True
sink = EventLog()
executor = Executor(registry, approval=CallbackApproval(reviewer), sink=sink)
model = CallableModel(scripted_llm, provider="host", model="shop-v1")
question = (Message(role="user", content="What does my basket cost in total?"),)
result = await run_tool_loop(model, registry, question, executor=executor)
print(result.final.text)
[finished] = sink.of_type("sandbox.finished")
print(finished.data["exit_code"], finished.data["stdout_bytes"], "code" in finished.data)
The reviewer sees the exact code in request.arguments["code"]. The example's reviewer
approves everything: that is for the example only. "Always confirms" is only as strong as
the approval port, so never pair code_tool with AutoApprove. In production, use
DeferredApproval so a person decides in the viewer, a chat or over HTTP.
Docker¶
sandbox = DockerSandbox(
"python:3.12-slim", # pull it first: docker pull python:3.12-slim
cpus=1.0,
memory_mb=256,
pids=64,
network=False, # --network none; the default
)
Each run starts a fresh container with docker run --rm: --network none,
--read-only, --cap-drop ALL, no-new-privileges, a non-root user (65534:65534;
root is refused), --pids-limit, --memory with no swap, --cpus, --ipc none, and
noexec tmpfs folders for /work and /tmp. Code, files and env go in on stdin, never on
the command line. On a timeout the container is force-removed. The image is never pulled
at run time; pull it first. runtime="runsc" adds gVisor when it is installed.
docker_status() says whether Docker can run Linux containers here; so does
agent-adapter doctor. network=True uses Docker's bridge network: egress is open,
including cloud metadata addresses and services on the host. If you need it, run on a
custom Docker network with egress rules instead.
Hosted sandboxes¶
CallableSandbox(fn) wraps fn(request) -> SandboxResult (or a mapping of its fields).
Two short recipes, passed the real SDK in your code:
def e2b_runner(Sandbox: Any) -> Any:
"""`fn(request)` for `CallableSandbox`, over E2B's code-interpreter `Sandbox`."""
def run(request: SandboxRequest) -> SandboxResult:
box = Sandbox.create(
timeout=int(request.timeout_s) + 30,
envs=dict(request.env),
allow_internet_access=request.network,
)
try:
for name, data in request.files.items():
box.files.write(name, data)
done = box.run_code(request.code, timeout=request.timeout_s)
error = done.error
stderr = "".join(done.logs.stderr) + (error.traceback if error else "")
return SandboxResult(
exit_code=1 if error else 0, stdout="".join(done.logs.stdout), stderr=stderr
)
finally:
box.kill()
return run
def modal_runner(modal: Any, app: Any, image: Any) -> Any:
"""`fn(request)` for `CallableSandbox`, over a Modal `Sandbox`; code only, no input files."""
def run(request: SandboxRequest) -> SandboxResult:
if request.files:
raise ToolUsageError("this Modal recipe takes no input files")
box = modal.Sandbox.create(
"python",
"-c",
request.code,
app=app,
image=image,
timeout=int(request.timeout_s),
env=dict(request.env),
block_network=not request.network,
)
box.wait(raise_on_termination=False)
code = box.returncode
return SandboxResult(
exit_code=-1 if code is None else code,
timed_out=code is None,
stdout=box.stdout.read(),
stderr=box.stderr.read(),
)
return run
e2b = CallableSandbox(e2b_runner(FakeE2B), name="e2b")
result = await e2b.run("print(2 * 3)", files={"data.csv": "a,b"})
print("e2b", result.stdout.strip(), result.exit_code)
Development only: the local process¶
LocalSubprocessSandbox(allow_unsafe_local=True) runs Python as a plain child process in
a temporary folder, with a time limit, output caps and a minimal environment. It refuses
to start without the flag and logs a WARNING on every run, and the tool's description
tells the model and the reviewer that nothing is isolated. On Windows, processes the code
starts in the background keep running after it exits. Never give it untrusted code.
Security¶
| Backend | Isolates | Does not isolate |
|---|---|---|
DockerSandbox |
network (off; network=True is open egress), host files, root filesystem (read-only), capabilities, process count, memory, CPU, other processes |
the host kernel (containers share it; add runtime="runsc" or a VM for hostile code); a daemon you expose to it |
CallableSandbox |
whatever your service isolates | the adapter cannot check it: your service must honour request.network and request.timeout_s |
LocalSubprocessSandbox |
nothing but the environment variables | files, network, processes, memory: it runs as you |
Whatever the backend, the adapter enforces:
- approval before every run of
code_tool; - checked inputs: code size, safe relative file names, plain env names;
- produced file names checked the same way (no
.., absolute, drive or device names); - output and files cut to the limits, even if a backend ignores them (fail closed);
- a backstop timeout of
timeout_s + grace_s; - no code or output in logs, and in events only with
capture_content=True.
Output is untrusted: the model wrote the code, so treat stdout like any model text.
Errors¶
| Error | When |
|---|---|
ToolUsageError |
bad code, language, file names or env |
ConfigError |
bad limits or settings, network asked of a sandbox that does not allow it, the local sandbox without allow_unsafe_local=True |
IntegrationError |
the backend failed: Docker could not start, no readable result |
ApprovalDenied |
the run was not approved |