Skip to content

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. network defaults to true (owner decision, 2026-06-05; reaffirmed as full compat across every non-allow_write_paths axis by #3901 owner ruling B) — a sandboxed process can reach the network unless you set network: false explicitly. 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_paths are writable — the one axis that stays closed by default regardless of mode (an operator-unknowable value, #3901).
  • deny_read_paths/deny_write_paths are 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: strict flips 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 Enforcedsubprocess: 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.args and build.context are 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