Run code the model writes
The model writes one program, a Sandbox runs it, and the result comes back.
The model writes one program, a Sandbox runs it, and the result comes back.
In-process execution is rejected outright. RestrictedPython, exec with
trimmed builtins and AST filtering are all escapable. Process isolation is the
floor and there is no configuration that lowers it.
Wiring
from psych_runtime.sandbox.subprocess import SubprocessSandbox
runtime = psych_runtime.Runtime(
store=store, model=model, registry=registry, sandbox=SubprocessSandbox()
)Without sandbox=, there is no run_code tool. That is right: offering a tool
that can only fail is worse than not offering it.
The contract, and every clause matters
- One program per execution, no state between executions. A model wanting a value in its next program puts it in the program.
- Host bindings are ordinary Python calls in the program that route back through the normal tool path with the same Policy, egress and recording. There is no privileged back door, and the package enforces that by construction rather than convention: it has no import path to a tool registry to reach around in the first place. A binding is the same tool the model could have called directly, reached a different way.
- Failures are data. stdout, stderr and the full traceback come back as part of the result, never raised so they kill the turn. A model shown only "it failed" rewrites the program from scratch and usually reproduces the mistake. A model shown the traceback fixes the line.
Why a program instead of more tools
A model that can write a program can loop, branch and combine results without a round trip per step. The cost is that a program is arbitrary code, which is exactly why process isolation is not optional.
Two backends
SubprocessSandbox(
python_bin=None, # defaults to sys.executable, resolved once
default_limits=None,
env_allowlist=None,
run_as=..., # drops to "nobody" when this process is root
allow_same_uid=False,
require_network_denial=False,
)
ContainerSandbox(
runtime=None, # docker or podman, auto-detected
image="python:3.12-slim", # you pull or build it; this never pulls implicitly
container_python_bin="/usr/local/bin/python3",
default_limits=None,
env_allowlist=None,
run_as=...,
extra_run_args=(),
)Both speak the same framed host-binding wire protocol and share the same
child-side bootstrap verbatim. A shared contract suite proves the two agree on
what a Sandbox does, so switching backends does not change behaviour.
Limits
psych_runtime.SandboxLimits(
cpu_seconds=10.0, # CPU consumed, not wall clock
address_space_bytes=512 * 1024 * 1024,
file_size_bytes=10 * 1024 * 1024,
process_count=64,
wall_seconds=30.0,
)cpu_secondsis CPU actually consumed. A program blocked waiting on a host binding's reply burns no CPU budget while it waits.address_space_bytesexceeded surfaces to the program as an ordinaryMemoryError, not a killed process, because CPython checks malloc's return value rather than trusting it.process_countis a per-user limit on Linux, threads counted, across everything running as that user. That matters for concurrent runs; see the subprocess adapter's docstring.
SandboxResult.limit_hit names which cap ended an execution, or is None on a
clean completion or a plain program error no limit caused.
The environment is built from nothing
Neither adapter starts from os.environ minus a blocklist. Both build the
child's environment from env_allowlist plus the handful of variables they set
themselves (HOME, TMPDIR, PATH, PYTHONDONTWRITEBYTECODE). A credential
nobody explicitly allowed never reaches the child, no matter what gets added to
this process's environment later.
Network
Off by default. The two backends differ in what they can honestly promise:
- Subprocess attempts denial and reports whether it held, in
SandboxResult.network_denied.Trueonly when the execution actively verified it has no route.Falsewhen network was granted, or when denial was requested and the host could not establish it. That field is the adapter's own self-check, never an assumption.require_network_denial=Truerefuses to run rather than proceeding unverified. - Container uses
--network=none, a kernel-level guarantee that does not depend on this process's privilege.
Granted network access is not a bound HTTP client handed to the program. Anything the program is meant to fetch goes through a binding that itself uses the egress seam.
Results
result.stdout, result.stderr
result.value # what the top-level code evaluated to, JSON-shaped
result.failure # a SandboxFailure, or None
result.duration_seconds
result.limit_hit
result.network_denied
result.stdout_truncated, result.stderr_truncatedvalue is None both when the program returned nothing and when it genuinely
returned None. The two are not distinguished, because JSON does not
distinguish them either.
Gotchas
run_codeis a reserved tool name. A Spec cannot claim it.- The container backend cannot be tested without a container runtime. Its tests skip where there is none and run in CI.
imageis never pulled implicitly. Build or pull it in your own startup, so a missing image is a configuration error you learn about then rather than on a customer's first request.- Bindings go through Policy. A program calling a binding for a destructive tool suspends for approval exactly as a direct call would.
Work with other agents over A2A
Psych speaks A2A v1.0 (April 2026, Linux Foundation). It ships the protocol and the mapping, and no server, because A2A is a transport and Psych owns none. The routes live in...
Load instructions on demand with skills
Two things share the word "skill" here, and they are unrelated: