Synsemadocsv0.6.xENES

Capabilities & Security

Capabilities & intent

Synsema is deny-by-default. Nothing touches the network, filesystem, database, secrets, or shell unless you declare the capability with require. Forget it and the operation cannot run — the interpreter refuses, even if the code asks.

This is what makes running untrusted code (an LLM's output, a doc playground) safe by construction.

capabilities.syn
-- Doc example: deny-by-default capabilities + faithful scope.

-- Uses `secret` because it proves the model with no network/disk side effects.
intent: "doc example: capabilities and intent"
require secret("APP_*")          -- name-prefix scope: covers APP_KEY, APP_DB, ... only

require net("http://localhost:8545/")   -- a URL is stored as its host; an EXPLICIT port is part of the grant (v0.6.29+)


task read_app_key()
    -- APP_KEY is under the declared APP_* scope → allowed (still redacted, as always)

    give text(secret("APP_KEY", "demo")) == "secret(APP_KEY)"

task where_am_i()
    -- cwd() reveals the host's directory layout: it needs file.read(".") — not declared here → denied (engine v0.6.20+)
    give cwd()

task read_unscoped()
    -- DB_PASSWORD is NOT under APP_* → denied at the capability check (before any use)

    give secret("DB_PASSWORD")

print("APP_KEY is in scope → " + text(read_app_key()))

test "a capability you declared (in scope) is allowed"
    assert(read_app_key())

test "anything outside the declared scope is denied (deny-by-default)"
    assert_error(read_unscoped)
    assert_error(where_am_i)

task key_under_wrong_block()
    -- the program DID declare secret("APP_*"), but this block only lists `net`: denied
    sandbox under {"net": "x.com"}
        give read_app_key()

test "sandbox under: a least-privilege block keeps only what it lists"
    -- what the block lists stays (still gated by the program's own require)…

    sandbox under {"secret": "APP_*"}
        assert(read_app_key())
    -- …what it doesn't list is denied even though the program declared it

    assert_error(key_under_wrong_block)

task other_port()
    -- the grant is localhost:8545; port 8546 is another scope → denied before any socket opens

    give http_get("http://localhost:8546/")

test "a port written in a net grant is part of the grant"
    assert_error(other_port)

task stamp()
    give format_time(1750000000, "%Y-%m-%d")

task clock()
    give now()

task seeded_die()
    give random_int(rng(42), 1, 6)

task os_die()
    give random_int(1, 6)

test "what never reads the clock or the OS needs no capability (v0.6.29+)"
    -- `sandbox` strips every capability, so what still runs inside it is pure
    assert_eq(sandbox stamp(), "2025-06-15")                -- formatting a time is not reading the clock

    assert_error(() => sandbox clock())                     -- now() is

    assert_eq(sandbox rng(42)(), 0.7739560485559633)        -- a seeded generator: numpy's default_rng(42)

    assert_eq(sandbox seeded_die(), seeded_die())           -- same seed, same numbers

    assert_error(os_die)                                    -- OS entropy is `random`, never declared here

Declare what you need §

require net("api.example.com")    -- one host

require file.read("/data/**")     -- read-only: /data and everything below it

require db("./store.db")          -- this database

require secret("STRIPE_KEY")      -- this secret

A fetch to any host you didn't declare is blocked. A read_file outside the scope is blocked — the path scope is faithful: a .. escape normalizes and is denied, and so does a link that leads out of it.

A require with a name that does not exist is a load error (engine v0.6.46+). Before, require nett("x") parsed, granted nothing and said nothing, and the program failed later with an error that did not name the typo. Now synsema run and synsema check stop at the line: `unknown capability nett in require — did you mean require net?; a name that does not exist grants nothing. Known: net, file, file.read, file.write, exec, env, time, random, stdout, stdin, llm, judge, db, serve, secret, reveal, sign, wallet, spend, memory, sandbox_run, attest, declassify. The same holds for a dotted name (require file.raed(...) → did you mean require file.read?) and for a require inside a task or an agent; with no close name, the message lists the known ones without a suggestion. The audit forms file_read / file_write` still load.

Auto-granted vs. must-declare §

Under run/test, only stdout / time / llm are auto-granted. Everything else — net, file, db, secret, exec, serve, reveal, sign, wallet, spend, memory, and random — is deny-by-default (yes, random too: it's for tokens/nonces; and memory too: persistent agent state writes files, so it must be declared — require memory("name"), see Memory & state). Under serve, even those are required.

What never touches the outside needs no capability at all (engine v0.6.29+): a seeded generator (rng(42), then g(), random_int(g, 1, 6), shuffle, sample — numpy's default_rng, bit for bit) and the date types with format_time / parse_time / date_parts, which never read the clock. random gates only OS entropy (random(), random_int(lo, hi) without a generator, random_bytes, token); time gates only reading the clock (now(), sleep, and the verifiers that check an expiry — each also takes the time explicitly). So all of these run inside a sandbox and under --deterministic.

Scopes §

synsema check decides whether a require covers a call with the runtime's own matcher (engine v0.6.30+): require file.read("./**") covers read_file("x.csv") in the checker exactly as it does when the program runs, so a scope the runtime accepts is never reported as missing.

Path scopes are globs by segment (engine v0.6.45+) §

A path scope reads like a .gitignore line or a shell glob:

require file.read("./data/*")       -- the files directly in ./data (not ./data itself, not ./data/sub/x)

require file.read("./logs/**")      -- ./logs, its files and every subfolder, at any depth

require file.read("*.csv")          -- the .csv files of the working directory, nowhere else

require file.read("./in/day-??.json")   -- `?` is one character, inside one segment
require file.write("./out/**")      -- where the program writes

Before v0.6.45 a crossed / and ..: file.read("./") granted /etc/passwd, and file.read(".csv") any .csv on the disk. A program that meant "this folder and everything inside" now writes ./dir/*.

Links are judged where they really lead (engine v0.6.45+). use, the file scopes (the program's require, the host ceiling and a ceiling delegated to a child) and grep look at where a path ends up, not only at its name:

spend — the audited money declaration §

spend(amount, unit, reason) declares an external spend before your program makes the actual payment call (a PSP API, an exchange, a transaction builder). It does not move money itself — it writes the forensic ledger entry FIRST and enforces the host's ceiling:

require spend("USD")                        -- deny-by-default ALWAYS, scoped by unit

let total be spend(50, "USD", "refund order 42")   -- → the unit's accumulated total

print(spend_total("USD"))                   -- introspection, no capability needed

Per-task capabilities §

A task can declare its own narrower require — it can only reach what it declares, even if the program is broader:

task fetch_orders()
    require net("api.shop.com")
    give fetch("https://api.shop.com/orders")    -- can ONLY reach api.shop.com

sandbox §

A sandbox block strips all capabilities for the untrusted body inside it — a require within is a no-op.

sandbox under <caps> keeps some: the body runs under a delegated ceiling — what the map lists stays (still gated by the program's own require and by the host ceiling), what it doesn't list is denied, and require inside is still a no-op. The map is a literal {capability: scope | [scopes] | nothing} 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"}          -- this step may touch the database, not the network

    let rows be sql(db, "select 1")

Nested blocks stack (every level must cover the use). The process-local capabilities stdout, stdin, time, random cannot be delegated — not by a block, not by a token — so listing them is an error; the host ceiling governs them. A use outside the block's ceiling fails with a catchable Capability not granted: X — outside the ceiling of the enclosing sandbox under block ….

Host ceiling — --sandbox / --cap-set (running code you don't trust) §

require, per-task scoping and sandbox all assume you wrote the code. When you didn't — running an LLM-generated program, a user's plugin, or a public playground — the host (whoever runs synsema) imposes a ceiling the code cannot exceed, no matter what it declares:

synsema run  --sandbox program.syn                       # ceiling = 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 error names who can fix it — and says it is a permission, not a bug: a call the program never declared says Capability not granted: X — this is a permission, not a bug: add require X to the program's preamble (or to the importing file, when this code runs in a module); a call the program did declare but the ceiling blocks says declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling (so an agent repairing its own code never loops); a call the program declared but the caller's captoken does not carry says denied by the delegated ceiling of token <id> — the caller must present a token that carries it (Agent identity). synsema check reports the undeclared ones before you run, with the exact require to add — including what an imported module's tasks need: a module never declares at its top level, the file that imports it does. Details and the audit fields: Sandboxing, WASM.

Three layers — who restricts, and when:

LayerWho restrictsUse when
require cap("scope")the code declares what it needsyou trust the code
sandbox blockthe code isolates part of itselfyou trust the code
--sandbox / --cap-setthe host imposes a ceiling from outsideyou don't trust the code

--sandbox vs --cap-set:

Two more host controls compose with the ceiling (engine v0.6.14+): --profile pure — a second, independent wall where every filesystem/exec/socket/db/cron builtin simply isn't there, regardless of the ceiling; and --audit json|<path>|fd:N — a JSON line per capability check, for seeing exactly what a program touched (see Observability). conform honors all of these too. And from inside a program, require sandbox_run lets it run another Synsema program with run_program(source, {ceiling, profile, env, timeout}) under a ceiling that is the intersection with its own — the child can never exceed the parent; a path in that ceiling travels absolute, anchored where the parent wrote it, and env cannot set SYNSEMA_* or the variables that choose host paths (engine v0.6.45+). Full model: Sandboxing.

The rule: caps_effective ⊆ require ∩ ceiling. A require net("") under --cap-set "net=api.mock" gets nothing — the code can never rise above the ceiling. It only ever removes*; auto-grants (stdout/time/llm) are filtered too, and it propagates to spawned agents and parallel_map workers.

Scope your file/db or you give too much: a bare --cap-set "…,file" lets the code read any absolute path — use file=scratch_* (a prefix) or db=:memory: so it only touches what you mean.

This site's playground uses exactly this: every Run/Test runs with a ceiling that allows compute, secret, in-memory SQL and scratch files, but denies exec and real net — try a snippet with require exec(...) and press Run. For a public deploy, combine it with an OS container (defense in depth).

intent §

intent: "Read customer data and generate reports"

intent is descriptive (any language) and frozen at startup — a prompt injection cannot widen it. Security comes from capabilities, never from the prose. See Secrets for credentials.

The second wall §

Capabilities answer may this program touch the network at all?. They do not answer may this value leave? — a program with require net may send anything it holds. When the operator must not read the data (an enclave, a multi-tenant service), turn on information-flow labels: private(v, "owner") marks a value, the engine carries the mark through every operation and every branch taken because of it, and each public sink refuses it until declassify(v, "why") puts the reason on the record. The matching question for the remote party — which code is holding my data? — is attestation, gated by a deny-by-default attest capability that no packaged ceiling grants.