Synsemadocsv0.6.xENES

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"))
sandbox.syn
-- 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: fine

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

The three layers together§

LayerWho restrictsFor
require cap("scope")the code (declares what it needs)code you trust
sandbox blockthe code (isolates a part of itself)code you trust
--sandbox / --cap-setthe 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.

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).

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.

refused (run_program: filename is a label, not a path (no folders): "sub/tool.syn").

cancellation (a serve request timeout, agent_stop) does too.

(SYNSEMA_RUN_PROGRAM_MAX_DEPTH, default 4).

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.

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:

LayerWho restrictsFor
require cap("scope")the code (declares what it needs)code you trust
sandbox blockthe code (isolates a part of itself)code you trust
--sandbox / --cap-setthe host (ceiling, from outside)code you DON'T trust
--profile purethe 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.