Psych Runtime
Guides

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_seconds is CPU actually consumed. A program blocked waiting on a host binding's reply burns no CPU budget while it waits.
  • address_space_bytes exceeded surfaces to the program as an ordinary MemoryError, not a killed process, because CPython checks malloc's return value rather than trusting it.
  • process_count is 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. True only when the execution actively verified it has no route. False when 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=True refuses 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_truncated

value 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_code is 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.
  • image is 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.

On this page