---
slug: 22-sandbox
title: Sandboxing untrusted code
description: Run code you don't trust — LLM-generated, a user's plugin, a public playground — safely in Synsema, with the sandbox block and the host capability ceiling.
example_ids: [sandbox]
---

# Sandboxing untrusted code

The rest of the security model (`require`, per-task scoping) assumes **you wrote the code**. This page is the opposite: running code you **don't** trust — an LLM's output, a user's plugin, a public playground. Two tools: one from inside your program, one from outside.

## 1. The `sandbox` block — isolate a part of your program

Wrap the piece that handles untrusted input. Inside, **all** capabilities are stripped — no net/file/db/secret/exec, only pure computation + `print`. A `require` inside is a no-op (it can't re-grant to escape).

```synsema
let payload be fetch("https://api.external.com/data")   -- untrusted input
sandbox
    let result be transform(payload)   -- if `transform` is exploited, it can touch NOTHING
```

It's also an expression:

```synsema
let clean be sandbox validate(untrusted_input)   -- isolated, returns a value
```

Use it when part of **your** code must process something dangerous but must not reach the outside.

```synsema
-- Doc example: the `sandbox` block — isolate a piece of code with NO capabilities.
-- As an expression it computes and returns; net/file/db/secret/exec are stripped inside.
-- Plus run_program: Synsema running Synsema in a child process under a ceiling ∩ the parent's.
intent: "doc example: sandbox block + run_program"
require sandbox_run

task risky_pure(n)
    give n * n

-- sandbox as an EXPRESSION: isolated, returns the value (run shows it)
print("sandbox result: " + text(sandbox risky_pure(7)))    -- sandbox result: 49

test "sandbox runs pure computation and returns the value"
    assert_eq(sandbox risky_pure(7), 49)
    assert_eq(sandbox (40 + 2), 42)

-- run_program: the child's result — and its audit — come back as a VALUE, not a log to parse.
test "run_program runs a child under a ceiling; its result is a value"
    let r be run_program("print(2 + 3)", {"ceiling": "stdout", "profile": "pure", "timeout": 20})
    assert(r["ok"])
    assert_eq(r["output"], ["5"])
    assert_eq(r["exit"], 0)

test "the child can never exceed the parent: exec it wasn't lent is denied, with a structured audit"
    -- the child asks for exec; the parent didn't lend it, so run() is denied and the child fails.
    let r be run_program("run(\"echo\", [\"x\"])", {"ceiling": "stdout", "profile": "native", "timeout": 20})
    assert_eq(r["ok"], false)
    assert(some(r["audit"], (e) => contains(e["capability"], "exec") and not e["granted"]))
```

## 2. The host ceiling — `--sandbox` / `--cap-set`

When you run a **whole program you didn't write**, the person running `synsema` sets a ceiling the code can't exceed, no matter what it declares:

```sh
synsema run  --sandbox program.syn                       # stdout + time only
synsema run  --cap-set "stdout,db=:memory:" program.syn  # a tailored ceiling
synsema test --cap-set "stdout,time,random,secret" tests.syn
```

- **`--sandbox`** = the minimum useful ceiling (`stdout` + `time`). **`--cap-set none`** = nothing at all, not even `stdout`.
- **`--cap-set "<list>"`** = you decide exactly how far (`name` or `name=scope`).
- The rule: `caps ⊆ require ∩ ceiling` — the code never rises above it. Auto-grants (`llm` too) are filtered, and it applies to the program's **preamble** (the statements before `serve on`), spawned **agents**, and `parallel_map` workers.
- **`stdout` is a real capability under a ceiling** (v0.6.14+): a `--cap-set` without `stdout` denies output at the first `print`/`show`/`log`. `--sandbox` includes it. Without any ceiling, output stays free.
- **Scope `file`/`db`** or you give too much: `file=scratch_*`, `db=:memory:`. A `render` of a **disk** template reads a file, so it needs `file.read` too (bundled templates and nested `include`/`layout` don't).
- `conform` honors these same flags (v0.6.14+) — `synsema conform --cap-set "…" app.syn` dumps `{ok, out, err}` with the denials in `err`.
- **The error names who can fix it.** A call the program never declared fails with *missing capability — add `require …`*; a call the program **did** declare but the ceiling blocks fails with *declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling*. An agent that repairs its own code from the first message never loops on the second (adding the `require` it already has). The audit trail carries the same distinction (`reason`) plus `origin: "program" | "runtime"` — see [WASM](72-wasm) for the fields, and `--audit json` in [Observability](63-observability).

## The three layers together

| Layer | Who restricts | For |
|---|---|---|
| `require cap("scope")` | the code (declares what it needs) | code you **trust** |
| `sandbox` block | the code (isolates a part of itself) | code you **trust** |
| `--sandbox` / `--cap-set` | the **host** (from outside) | code you **DON'T** trust |

They compose — the most restrictive wins.

## 3. `run_program` — Synsema running Synsema under a ceiling

The pattern above — write a file, `exec` the `synsema` binary, parse stdout — is what `run_program`
replaces (engine v0.6.14+). It runs another Synsema program **in a child process of the same
binary**, under a ceiling that is the intersection with the parent's, and returns its result — and
its audit — as a value. No `exec`, no `synsema` on the `PATH`, no stderr parsing:

```synsema
require sandbox_run                         -- deny-by-default, no scope
require net("registry.npmjs.org")           -- what you'll LEND, you must hold

let r be run_program(code, {
    "ceiling": "stdout,net=registry.npmjs.org",   -- --cap-set syntax, or "sandbox", or "none"
    "profile": "pure",                            -- "pure" (default) or "native"
    "env":     {"TOOL": "package"},               -- REPLACES the child's environment
    "timeout": 30,                                -- seconds (default 30); on timeout the tree is killed
    "cwd":     "/path/to/work"                    -- default: the parent's cwd
})
-- r = {"ok": bool, "output": [lines], "errors": [text], "audit": [entries], "exit": n|nothing,
--      "timed_out": bool, "llm_tokens": n}
```

**The child can never exceed the parent.** Its effective ceiling is `opts.ceiling ∩` what the
parent can actually grant — asking for more is not an error, it's trimmed, and the parent's audit
records it (`reason: "above parent ceiling"`). So `net=*` under a parent that only holds
`net("registry.npmjs.org")` collapses to nothing. What the parent doesn't `require`, it can't lend.

- **`env` replaces** the child's environment entirely (the parent's LLM keys and `.env` secrets are
  gone unless you pass them). A `secret` value in `env` is refused — `reveal()` it explicitly.
- **`profile`** never rises above the parent (a `pure` parent forces a `pure` child).
- **`timeout`** (mandatory, default 30 s) kills the child's whole process tree; the parent's own
  cancellation (a `serve` request timeout, `agent_stop`) does too.
- **Recursion** is allowed if the child's ceiling includes `sandbox_run`; depth is capped
  (`SYNSEMA_RUN_PROGRAM_MAX_DEPTH`, default 4).
- Under `--profile pure` the child is available (it spawns the engine under a ceiling — not
  arbitrary `exec`); under the wasm profile it isn't (no child processes).

This is the safe runner for a playground, an MCP `run_synsema` tool, or an agent executing code it
generated: the ceiling is enforced by the child process itself, and the audit is data.

## 4. The pure profile — a second wall

`--cap-set` is one wall: the ceiling. `--profile pure` is a second, independent one — the same wall
the WebAssembly build has always had, now on the native binary:

```sh
synsema run --profile pure --cap-set "stdout,net=api.example.com" program.syn
```

Under `pure`, every OS-facing builtin **does not exist as such**: the names are still bound (never
`Undefined variable`), but they fail with the truth of the environment — `read_file: not available
in the pure profile — this run has no filesystem`, and likewise for `run`, the `ws_*`/socket family,
the databases, `cron_*` and the process hub (`select`/`proc_*`/`watch`). This holds **regardless of
the ceiling**: a bug in the interpreter can't hand out access to something that isn't registered.

What stays under `pure`: the whole language, math, JSON/CSV, charts, hashing, the pure blockchain and
web-auth surface, `fetch`/`http_*` **with `net`** (pure is "no local machine", **not** "no
network"), the LLM ops with `llm`, `secret`/`reveal`/`env`, agents/`spawn`/`parallel_map`
(in-process), `remember`/`recall` **without disk** (in-memory for the run), `run_program`, and
reading a `synsema build` bundle (a bundled asset is the program, not the filesystem). The full
"included vs not" table is on the [WASM](72-wasm) page — the two profiles are the same wall.

Two walls, not one: the ceiling, and the builtin that isn't there.

## The three layers together

Add the pure profile and both host tools compose with the code's own:

| Layer | Who restricts | For |
|---|---|---|
| `require cap("scope")` | the code (declares what it needs) | code you **trust** |
| `sandbox` block | the code (isolates a part of itself) | code you **trust** |
| `--sandbox` / `--cap-set` | the **host** (ceiling, from outside) | code you **DON'T** trust |
| `--profile pure` | the **host** (the second wall) | code you **DON'T** trust |
| `run_program(…, {ceiling, profile})` | the program (a child ⊆ itself) | code **it** doesn't trust |

The most restrictive wins.

> **Why it matters for agents:** an agent that writes and runs Synsema can execute its own code with
> `run_program` under a ceiling it **can't escape**, and read the audit back as a value — so "run the
> code the LLM generated" is safe by construction. For a **public** deploy, wrap it in an OS
> container as a further layer (defense in depth). This is how this site's playground and its MCP
> `run_synsema` tool work.
