Capabilities & Security
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).
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:
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.
-- 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:
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 evenstdout.--cap-set "<list>"= you decide exactly how far (nameorname=scope).- The rule:
caps ⊆ require ∩ ceiling— the code never rises above it. Auto-grants (llmtoo) are filtered, and it applies to the program's preamble (the statements beforeserve on), spawned agents, andparallel_mapworkers. stdoutis a real capability under a ceiling (v0.6.14+): a--cap-setwithoutstdoutdenies output at the firstprint/show/log.--sandboxincludes it. Without any ceiling, output stays free.- Scope
file/dbor you give too much:file=scratch_*,db=:memory:. Arenderof a disk template reads a file, so it needsfile.readtoo (bundled templates and nestedinclude/layoutdon't). conformhonors these same flags (v0.6.14+) —synsema conform --cap-set "…" app.syndumps{ok, out, err}with the denials inerr.- 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 therequireit already has). The audit trail carries the same distinction (reason) plusorigin: "program" | "runtime"— see WASM for the fields, and--audit jsonin 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:
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.
envreplaces the child's environment entirely (the parent's LLM keys and.envsecrets are
gone unless you pass them). A secret value in env is refused — reveal() it explicitly.
profilenever rises above the parent (apureparent forces apurechild).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 purethe 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:
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 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 withrun_programunder 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 MCPrun_synsematool work.