Configure the sandbox¶
reyn's sandbox layer isolates subprocess execution at the operator level.
The operator sets the backend and policy in reyn.yaml; workflows do not control
their own containment. Sandbox is orthogonal to permissions — see
Sandbox and permissions.
Choose a backend¶
# reyn.yaml
sandbox:
backend: auto # auto | seatbelt | landlock | noop
on_unsupported: warn # warn | error | ignore
backend: auto (the default) picks the best available backend for the current
platform:
| Platform | Condition | Backend |
|---|---|---|
| macOS | sandbox-exec available |
Seatbelt (SBPL deny-default) |
| Linux | kernel ≥ 5.13, sandbox-linux package installed |
Landlock + seccomp-BPF (both required) |
| Other | — | Noop (audit-only, no enforcement) |
A backend from this table is used only if it passes an enforcement self-test on your machine — see Reyn checks that your sandbox really sandboxes below.
on_unsupported controls what happens when no usable backend is available — either because the one you forced is not present on this platform, or because it is present but does not actually enforce:
| Value | Behaviour |
|---|---|
warn (default) |
Log a warning and fall back to Noop |
error |
Raise an error — use this when enforcement is a hard requirement |
ignore |
Silently fall back to Noop |
Reyn checks that your sandbox really sandboxes¶
When Reyn picks a backend, it first proves the backend works on your machine. It launches short subprocesses through that backend and tries two things the policy forbids: writing a file outside the writable paths, and spawning a process with subprocess: false set. Both must be refused. If either goes through, the backend is not enforcing what it claims, and Reyn treats it exactly as if it were not installed — applying your on_unsupported setting.
This matters because "the sandbox is installed" and "the sandbox works" are different things. A backend can be present and importable while enforcing nothing at all — right OS, package imports fine, and yet every restriction silently absent. Checking only for presence cannot tell those apart. So Reyn checks the thing you actually care about: whether a forbidden action gets refused.
The two checks are separate on purpose, because they can fail independently — different mechanisms enforce them, and on Linux one can be dead while the other works. But the protection does not decompose the same way, which is why Reyn requires both rather than keeping whichever one passes.
On Linux, path rules come from Landlock and the syscall gate from seccomp-BPF. Without the syscall gate, Landlock's write boundary is real but not airtight: it governs ordinary writes, and Landlock has no chmod right at all, so with seccomp absent a sandboxed process can still truncate a file or chmod a directory outside allow_write_paths — no layer stops it. Measured on Linux 6.8 with Landlock enforcing and the syscall filter absent: open() on a file outside allow_write_paths was refused, while os.truncate() on that same file succeeded and emptied it. The syscall filter is what refuses those calls, by not listing them.
So "writes are enforced, spawning is not" is not a coherent state to ship, and a write-only check would have called that host sandboxed.
What you should expect to see:
- Normally, nothing. A working sandbox passes silently. The check costs tens of milliseconds, once, and only when a run actually uses the sandbox.
- If your sandbox is not enforcing, a warning at startup naming what was attempted and what happened — instead of silently unsandboxed runs.
- With
on_unsupported: error, Reyn refuses to run rather than execute AI-generated code unsandboxed. This setting now works against a broken sandbox, not just a missing one.
If you see the warning, your AI code has been running without isolation. The message names the backend and the failure so you can fix it or fail closed deliberately.
Scope. The check verifies the filesystem write boundary and the process-spawn gate. It does not exercise the network gate or deny_read_paths, so a passing check means two restrictions were proven — a good signal, not a guarantee of every restriction listed below. The spawn check doubles as evidence that the Linux syscall filter loaded at all, which is what keeps the truncate/chmod hole above closed.
Set the agent-level sandbox policy¶
sandbox.policy lets the operator declare a deterministic, operator-controlled
sandbox policy, in a config vocabulary decoupled from the internal enforcement
fields it maps to (#3823). When set, it applies to all sandboxed_exec ops
and to the SandboxLayer of the permission intersection for the
network/subprocess/env axes — a workflow or the LLM cannot widen it.
allow_write_paths (and the read/write deny-lists) do NOT participate in that
intersection: they are values an operator cannot know in advance (the op
declares what directory it needs), so the kernel backend consumes them
directly rather than through the permission ∩ (#3901).
sandbox:
backend: auto
mode: compat # compat | strict — the DEFAULT for any key left unset below
policy:
network: false
allow_write_paths:
- "{{workspace}}/output"
deny_read_paths:
- "~/.ssh"
- "~/.aws"
timeout_seconds: 120
When sandbox.policy is absent (the default), there is no agent-level
restriction: op-level fields govern, and the SandboxLayer is unrestricted.
Unknown keys under policy fail loudly at config load — a typo never
silently resolves to "nothing to deny".
Policy fields¶
Every field except allow_write_paths defaults to full compat under
mode: compat (the default; owner ruling, #3901/#3823): the sandbox's job
is bounding what happens behind a permitted action, not re-deciding what the
launching shell could already do. Setting mode: strict flips every axis
except allow_write_paths (and read, which has no mode-based default at all)
to its closed default — an explicit key under policy always wins over mode.
| Field | Type | Default | Meaning |
|---|---|---|---|
network |
bool | true (compat); false under mode: strict |
Allow outbound network. The primary exfiltration gate — a config-allowed host is still denied under network: false. Set network: false explicitly to close it. |
subprocess |
bool | true (compat) — positive framing, true = allowed; false under mode: strict |
Whether the process may spawn children. Set subprocess: false to deny it. |
allow_write_paths |
list of paths | [] |
Paths the process may write (tight guard) — the one field that stays closed by default regardless of mode (an operator-unknowable value, #3901). Write implies read — a path listed here is also re-opened for reading even if deny_read_paths would deny it, so grant specific directories rather than ~. ~ is expanded. |
deny_read_paths |
list of paths | [] (compat) |
Sensitive paths to deny from the broad read surface (defense-in-depth, opt-in). Enforced only on backends that support deny-after-allow (Seatbelt); not enforceable on Landlock. Before #3901 this defaulted to 7 OS-level credential paths — set it explicitly to get that protection back. No mode-based default (no allow_read_paths concept, #1199) — strict cannot narrow reads any tighter than compat. Denies only the READ axis; see deny_write_paths for the write axis. |
deny_write_paths |
list of paths | [] |
The write axis's own deny-list (#3901), mirroring deny_read_paths. Denies only the WRITE axis — before #3901 a deny_read_paths entry also (undocumentedly) denied writes on Seatbelt; that coupling is gone, so list a path in both fields if you want it protected on both axes. |
allow_env_names |
list of strings | null |
null (deny-list-only); [] under mode: strict |
SWITCHES the env axis to allow-list semantics when set to a list — only those names pass through (still intersected with deny_env_names, deny always wins). |
deny_env_names |
list of strings | [] (compat) |
Env vars WITHHELD from the process. Empty (the default) means the whole environment passes through, same trust level as the launching shell. |
timeout_seconds |
int | 120 |
Foreground exec wall-clock DEFAULT (process is killed on expiry) — applied when the LLM's exec call omits its own timeout. #3903① raised this from the prior 60. |
max_timeout_seconds |
int | 600 |
Foreground exec's LLM-extensible CEILING — the LLM may request up to this via its own timeout, never past it; a request above it is a typed error, not a silent clamp. |
background_timeout_seconds |
int | 3600 |
Background exec's OWN default (#3903 a-2) — applied instead of timeout_seconds when the exec runs inside an ephemeral session (spawn_ephemeral_session) and the LLM's call omits its own timeout. |
background_max_timeout_seconds |
int | null |
null (no cap) |
Background exec's OWN ceiling — null/unset means unbounded; set an int to cap it. Above the cap: a warning is logged and the effective value is clamped down to it (matching #4174 T0's warn-not-fail posture), never a silent unbounded pass-through. |
🔴 "Background" here means ephemeral session, not "nobody is waiting." spawn_ephemeral_session's own exec gets this pair; a persistent spawned session's exec (still fire-and-forget, still nobody waiting on it) does NOT — it still gets the foreground pair above, a known gap tracked in #4193, not this release. Conversely, an ephemeral session driven attached (run_pipeline_attached — someone IS waiting) still gets the background pair, since ephemeral-ness is the actual signal read, not "was foreground/background chosen for this call." If your workload's timeout-relevant distinction is really "am I being waited on," read this table as an approximation of that, not an exact match.
subprocess: false is the cheapest, most predictable hardening available for a workload that never needs to spawn anything. It is a single boolean, and its effect is total and immediate: child-process spawning is denied outright, with no partial states to reason about later. If your workload genuinely needs to exec (a build step, a CLI wrapper), this setting isn't for you — you're bound instead by the sandbox boundary plus the audit trail every exec leaves (sandboxed_exec_started/_completed/_cancelled record the argv — see Reference: events).
Scoping model¶
reyn uses a broad-read, tight-write, network-open-by-default model:
- Reads are broad. The process can read most of the filesystem. System-path enumeration for dylib loading works without enumeration in policy.
- Network is open by default.
networkdefaults totrue(owner decision, 2026-06-05; reaffirmed as full compat across every non-allow_write_pathsaxis by#3901owner ruling B) — a sandboxed process can reach the network unless you setnetwork: falseexplicitly. This follows reyn's standing UX-over-security posture: security mechanisms here are opt-in, not opt-out — see Protect credentials from sandboxed commands for what this means for a command that can read a secret. - Writes are tight. Only paths in
allow_write_pathsare writable — the one axis that stays closed by default regardless ofmode(an operator-unknowable value,#3901). deny_read_paths/deny_write_pathsare defense-in-depth, opt-in. Carve out sensitive locations from the broad read/write surface where the backend can express a deny-after-allow rule; empty (nothing carved out) by default.mode: strictflips network/subprocess/env to their closed defaults in one setting, for an operator who wants the pre-compat posture back without writing every key explicitly — see reyn.yaml §sandbox.mode.
Per-backend behavior¶
Seatbelt (macOS)¶
Uses sandbox-exec with an SBPL deny-default profile. Strongest containment
on macOS.
| Field | Enforcement |
|---|---|
allow_write_paths |
Enforced |
network |
Enforced. A loopback-only network-bind (localhost:*) is always allowed regardless of network, mirroring Landlock's socket/bind exception above (#3060) — network-outbound/network-inbound stay gated on network. |
deny_read_paths |
Enforced — SBPL deny-after-allow |
deny_write_paths |
Enforced — SBPL deny-after-allow, independent of deny_read_paths (#3901: each denies only its own axis) |
subprocess |
Enforced — subprocess: false denies process-fork; the target's own exec still works via process-exec* |
timeout_seconds |
Enforced |
Landlock (Linux)¶
Uses the Linux Landlock LSM with path-beneath allowlist rules.
| Field | Enforcement |
|---|---|
allow_write_paths |
Enforced — path-beneath write rules |
network |
Enforced, unconditionally (#3030 fixed). Landlock itself never restricts network on any kernel: the pinned landlock package exposes no network-rule API, so the deny is carried entirely by a seccomp-BPF default-deny allowlist — every syscall not named (including connect/sendmsg/accept/listen when network: false, and unconditionally io_uring's io_uring_setup/io_uring_enter, which a syscall-name denylist cannot express) is refused. This filter used to be skipped ENTIRELY whenever subprocess was true (allowed) — the stdio MCP default — which silently dropped the network gate along with it; it now loads unconditionally, so network: false is enforced regardless of subprocess. Two exceptions, always allowed regardless of network (#3060): (1) socket/bind — neither one alone transmits or receives a byte, and a benign import-time IPv6-support probe in a common HTTP-client dependency (binds to ::1 on port 0 and never connects) used to be refused as collateral damage; (2) sendto/recvfrom when their address argument is NULL — the connected AF_UNIX socketpair CPython's asyncio event loop uses to wake itself (send/recv lower to sendto/recvfrom with a NULL address), whose wholesale denial left every stdio MCP server's loop unable to pump, so the server served 0 bytes. Dialing an actual peer still requires connect (denied), and the addressed form of sendto (sendto(fd, …, &sockaddr, …) — real UDP egress) has a non-NULL address and stays denied by that same condition. |
deny_read_paths |
Not enforced — Landlock is allowlist-only and cannot carve a subpath out of an allowed parent. The network gate (see the network row) is the compensating exfiltration control, and — since #3030 — applies regardless of subprocess. Do not rely on this platform to contain a process that can read a secret; network denial only stops it leaving. |
deny_write_paths |
Not enforced — same allowlist-only limitation as deny_read_paths above. |
subprocess |
Enforced — seccomp-BPF refuses fork/clone when subprocess: false. Landlock is not selected unless the self-test witnesses this deny on your host, so this is a checked claim rather than a hope that pyseccomp is installed and loading |
timeout_seconds |
Enforced |
Noop¶
No containment enforced. Policy fields are recorded in the audit log but have no effect. Use only in trusted environments where enforcement is unavailable.
When Reyn warns that an axis is not enforced — and when it stays quiet¶
If you configure a policy axis and the selected backend does not enforce it,
Reyn says so at dispatch time: a sandbox_axis_unenforced audit event plus a
WARNING log line naming the axes, the backend, and the reason. The policy is
still written to the audit log; it simply was not applied for those axes.
The check asks "does this backend enforce what you configured" — every
axis, every backend, no exception (#4039).
Earlier, this only fired for deny_read_paths/deny_write_paths on a
backend specifically incapable of expressing a deny-list (Landlock alone) —
a backend that simply enforced NOTHING for an axis (Noop, Docker) passed the
check in silence, because the check never asked that broader question. Each
backend now DECLARES what it enforces, over the full field set
(allow_write_paths / deny_write_paths / deny_read_paths / network /
subprocess / env_deny_names / allow_env_names), and the warning fires
for any axis you configured that the backend's own declaration says it does
not enforce — not just the deny-list pair.
Docker (--env-backend=docker, below) is the sharpest instance this
generalization was built for. It is a sandbox backend — the same object
serves as both the environment and the sandbox backend for a container
agent — and its run() honors only policy.timeout_seconds and
policy.max_output_bytes. Configure allow_write_paths, network,
subprocess, or either env field under it and Reyn now warns for each one
you set: the write/network/subprocess/env fields all declare
DOES_NOT_ENFORCE, so a configured axis on any of them fires the check.
Isolation still comes from the container boundary itself — the image and
the mount set — not from these policy fields; the warning tells you that
directly now, instead of leaving it to this doc alone.
Noop is the same shape except for env: it enforces env_deny_names /
allow_env_names (the one policy mechanism it actually applies — see the
Noop row above) but nothing else, so a configured write/network/subprocess
restriction under Noop also now warns.
What to rely on instead: the per-backend tables above state, field by field, what each backend actually enforces. Read the table for the backend you are running; the warning now covers the same ground those tables do, so it is a much closer answer to "was my policy applied?" than before — though it only fires for axes you actually SET (an unset, default-permissive axis has nothing to warn about even on a backend that would not have enforced a restriction on it).
Two other mechanisms are easy to mistake for this one, and neither widens it:
- The startup self-test (see Reyn checks that your sandbox really sandboxes) proves the write boundary and the process-spawn gate on your host. It runs at backend selection; this warning runs at op dispatch, once the policy's individual axes are known.
- Container (mount) mode below is a different kind of isolation from the three profile-based backends tabled above (Seatbelt, Landlock, Noop) — the container boundary, not a policy applied to a host process. It is still a sandbox backend as far as this warning is concerned, which is exactly why the warning covers it too — see What container mode itself enforces below for what actually applies instead.
Run in a container (mount mode)¶
For the strongest isolation — or to run workflows against a consistent Linux environment regardless of the host OS — use the Docker backend:
# Launch a new container (mount mode)
reyn run my_skill --env-backend=docker
# Use a specific image
reyn run my_skill --env-backend=docker --image my-registry/my-image:latest
# Add extra bind mounts
reyn run my_skill --env-backend=docker \
--mount /data/inputs:/data/inputs:ro \
--mount /data/outputs:/data/outputs:rw
# Keep the container after the run (for inspection)
reyn run my_skill --env-backend=docker --keep-container
# Attach to an already-running container
reyn run my_skill --env-backend=docker --container my-container --repo-dir /workspace
In mount mode, the workspace root is automatically bind-mounted at /workspace
inside the container. The sandbox backend used inside the container is determined
by reyn.yaml sandbox.backend as usual (typically landlock on Linux).
What container mode itself enforces¶
The section above already establishes that Docker's run() honors only
policy.timeout_seconds/policy.max_output_bytes, and that
allow_write_paths/network/subprocess pass through unenforced (now
warned about if you configure them — see above). That leaves the question of
what, if anything, DOES restrict those axes when you run in a container —
the container's own launch-time isolation, independent of any sandbox.*
policy.
Measured directly (real execution against a live container, not inferred from
the launch code):
| axis | what actually happens | driven by |
|---|---|---|
| write | root filesystem is read-only; /tmp (tmpfs) and the workspace bind mount are writable |
fixed at container launch (--read-only, --tmpfs /tmp) — not sandbox.* policy |
| network | outbound connections fail | fixed at container launch (--network none, unless overridden) — not sandbox.* policy |
| subprocess | not restricted — a process inside the container can spawn further child processes freely | nothing; subprocess: false in your policy has no effect once you are inside a container |
| env | the container sees only the image's own environment (its /etc/profile / shell activation) — your host's environment variables are not forwarded in, and env_deny_names has nothing to filter because there is nothing to filter |
nothing; not a policy mechanism, just how docker exec works |
The write and network rows come from the container's fixed launch flags
(--cap-drop ALL, non-root user, read-only root + tmpfs, --network none by
default) — they hold regardless of what your reyn.yaml sandbox: config says,
because they are set once when the container starts, not per operation. The subprocess and
env rows are the opposite kind of fact: there is no mechanism restricting
them at all, at any layer, inside the container — subprocess: false and
allow_env_names only take effect for the sandbox backends listed above, not
for container mode.
Default image¶
When --image is omitted, reyn uses a bundled base image built for the current
platform. To use a custom image, pass --image or set the default in reyn.yaml
(see reyn.yaml reference).
devcontainer.json¶
If the workspace ships a devcontainer.json (.devcontainer/devcontainer.json
or .devcontainer.json), reyn reads a minimal subset to seed the launch:
image, postCreateCommand, mounts, and remoteUser. An explicit --image
always overrides the devcontainer.
- Image-based (
image: ...) — launched directly. - Build-based (
dockerFile/build) — reyn builds the Dockerfile on demand (docker build) and launches the result. The built image is tagged by content hash, so it is rebuilt only when the Dockerfile / build args / target change.build.argsandbuild.contextare honored. - Compose-based (
dockerComposeFile) — not supported (the launcher is single-container); reyn warns and falls back to the default image.
Build runs the workspace Dockerfile on your host
Building a build-based devcontainer runs that Dockerfile's RUN steps on
your host Docker daemon at build time — these are not confined by
reyn's runtime sandbox (the network-off / non-root / read-only-rootfs flags
apply to the running container, not to docker build). This is the same
trust model as VS Code's "Reopen in Container": only use build-based
devcontainers from workspaces you trust. reyn logs the build for visibility;
--env-backend=docker is the opt-in.
See also¶
- Concepts: Sandbox and permissions — why sandbox and permissions are orthogonal
- Concepts: Sandbox — backend field reference and scoping model details
- Reference:
reyn.yaml— fullsandbox:config schema - How-to: Manage permissions — declare and approve workflow-level capability permissions