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.
sandbox under <caps> keeps some capabilities — a least-privilege block. The body runs under a delegated ceiling: what the map lists stays (still gated by the program's require and by the host ceiling), what it doesn't list is denied, require inside is still a no-op. The map is a literal, or the map returned by captoken_verify — then the block runs under that token, exactly as a serve request does (Agent identity):
require net("api.example.com")
require db("orders")
sandbox under {"db": "orders"} -- may touch the database, not the network
let rows be sql(db, "select 1")
Nested blocks stack; stdout, stdin, time, random are process-local and cannot be delegated (listing them is an error); a use outside the block's ceiling is a catchable Capability not granted: … outside the ceiling of the enclosing sandbox under block.
A wildcard net in a ceiling reaches only public addresses (engine v0.6.46+), in a sandbox under block as in the host ceiling (--cap-set net, net=) and the ceiling of run_program. Every layer that has a wildcard has to name a private host for the program to reach it, and the message names the layer: inside sandbox under {"net": ""} a request to 127.0.0.1 fails with `the enclosing sandbox under block reaches only public addresses for it: name it there (net("127.0.0.1:47100")); under --cap-set "stdout,net" with the host ceiling (--sandbox/--cap-set) reaches only public addresses for it. The program cannot fix this; the host must name it: --cap-set net=127.0.0.1:47100 . sandbox under {"net": "127.0.0.1"}` reaches it. The ranges are in HTTP client.
declassify inside a sandbox (engine v0.6.46+, with --labels): a plain sandbox has none, so a declassify of a private value in it is a label_violation that try/recover cannot catch. sandbox under {"declassify": "customers"} delegates it for those principals only (globs work, "cust*"), still within the host ceiling — see Information-flow labels.
let c be private(3, "customers")
sandbox under {"declassify": "customers"}
print(declassify(c, "the count is public"))
-- 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
require env("TOOL") -- what the parent LENDS to a child, it must hold itself
require file.read(".")
require file.read("..") -- lent to the child that runs one directory up
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"]))
test "run_program options: env replaces the child's environment, timeout kills it, cwd moves it"
let r be run_program("require env(\"TOOL\")\nprint(env(\"TOOL\"))",
{"ceiling": "stdout,env=TOOL", "env": {"TOOL": "package"}, "timeout": 20})
assert_eq(r["output"], ["package"])
assert_eq(r["errors"], [])
assert_eq(r["timed_out"], false)
assert(r["steps"] > 0) -- what the child spent, in steps
assert_eq(r["llm_tokens"], 0)
let slow be run_program("while true\n let x be 1", {"ceiling": "stdout", "timeout": 1})
assert_eq(slow["timed_out"], true)
assert_eq(slow["exit"], nothing)
-- A path in the ceiling is anchored where the PARENT wrote it (it travels absolute): "." is the
-- parent's folder, ".." the one above. The child below runs one directory up and reads its cwd.
let child_code be "require file.read(\".\")\nprint(cwd())"
let moved be run_program(child_code,
{"ceiling": "stdout,file.read=..", "profile": "native", "timeout": 20, "cwd": ".."})
assert(moved["ok"])
assert_ne(moved["output"][0], cwd()) -- the child ran one directory up
let stuck be run_program(child_code,
{"ceiling": "stdout,file.read=.", "profile": "native", "timeout": 20, "cwd": ".."})
assert_eq(stuck["ok"], false) -- "." is still the parent's folder, not the child's
assert(contains(stuck["errors"][0], "above the host ceiling"))
test "run_program refuses an env that steers the child's engine, and a filename with folders (v0.6.45+)"
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "env": {"SYNSEMA_AUDIT": "x.log"}}))
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "env": {"HOME": "/tmp/elsewhere"}}))
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "filename": "sub/tool.syn"}))
let r be run_program("print(1)", {"ceiling": "stdout", "filename": "tool.syn", "timeout": 20})
assert_eq(r["output"], ["1"]) -- a plain name is a label: fine2. 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.--deterministic(engine v0.6.20+,run/test/build) =--profile pureplus a ceiling ofstdoutonly — notime, norandom: two runs of the same program give the same output (reproducible evals,steps()as the cost). It is an alias, so it cannot be combined with--sandbox/--cap-set/--profile native(exit 2);buildbakes it into the binary. What stays available because it never reads the clock or the OS (engine v0.6.29+): seeded generators (rng(seed),g(),random_int(g, lo, hi),shuffle,sample,choice), the date types andformat_time/parse_time/date_parts.now(),sleepandrandom()without a generator fail withCapability not granted.--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.declassifyis bounded by the ceiling (engine v0.6.46+, with--labels; breaking): under--cap-setonly the principals of adeclassify=<principal>item can be published (declassify=customers, a glob, ordeclassify/declassify=*for any), and--sandboxand--deterministicnever grant it. A program that declassified under them now stops there: add the item to an explicit--cap-set. Details in Information-flow labels.- Scope
file/dbor you give too much:file=scratch_,db=:memory:. Arenderof a disk template reads a file, so it needsfile.readtoo, and so does every{ include }/{ layout }it pulls from disk (engine v0.6.45+; bundled templates don't). Ain a ceiling's scope stays in one folder, like in arequire(engine v0.6.45+):file=scratch_is thescratch_…files of the working directory,file=./work/*a whole tree. 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, "steps": 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. It cannot set the variables that configure the child's engine (engine v0.6.45+): SYNSEMA_ (run_program: env cannot set SYNSEMA_AUDIT (SYNSEMA_ variables configure the engine, not the program)) nor the ones it uses to pick host paths — HOME, USERPROFILE, TMP/TEMP/TMPDIR, APPDATA, XDG_, OLLAMA_MODELS, HF_ (… it chooses where the child's engine reads or writes on the host).
ceilingpaths travel absolute, anchored where the parent wrote them (engine v0.6.45+): a
file.read=./data/** in the ceiling is the parent's ./data, whatever cwd the child gets. A scope with a comma, empty, or with spaces at its ends is refused (… has a comma, which cannot travel to the child): the ceiling travels as a list, and {"file.read": "x,exec"} would have given the child exec.
filename(optional) is a label for the child's messages, not a path: one with folders is
refused (run_program: filename is a label, not a path (no folders): "sub/tool.syn").
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.
cwdsets the child's working directory (default: the parent's); relative paths inside the child resolve from there — but not its ceiling, which stays anchored at the parent (above). The child keeps its state and temporary files where the parent keeps its own — the folder of the parent'smemorydatabase, absolute — never in thatcwdnor next to the calling module.memory: a"native"child whose ceiling grantsmemory=<name>shares the parent's<name>.db— also from acrontick or aservehandler (engine v0.6.45+). Each process writes only the entries it changed, the child's new ids carry a mark (mem_<n>_p<process>) so they never collide with the parent's, and when the child returns the parent reloads: arecallafterrun_programsees what the child remembered (verified:["mem_2_p13032 from child", "mem_1 from parent"]). A"pure"child (the default) and the child of a parent whose memory does not persist (stdin,--profile pure) keep their memory in memory for that run and never touch the disk.stepsis what the child spent (the same counter assteps()), andllm_tokensits model tokens; on a timeoutexitisnothingandtimed_outistrue.- What the child returned is an input of the parent (engine v0.6.29+): it goes into the parent's
lineage()and its receipt, with a salted commitment to the arguments and the sha256 of the result — see Agent identity. - Recursion is allowed if the child's ceiling includes
sandbox_run; depth is capped
(SYNSEMA_RUN_PROGRAM_MAX_DEPTH, default 4).
ceilingalso takes a captoken.run_program(code, {"ceiling": captoken_verify(t, key)})runs
the child under that token's authority — the third way a token is the ceiling, beside a serve request and sandbox under (Agent identity). The child keeps stdout and time (process-local, not the token's to give) unless the token carries deterministic: true; a plain {capability: scopes} map works the same way, and the parent's intersection still applies.
- 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.