Synsemadocsv0.6.xENES

Capabilities & Security

Information-flow labels

Capabilities answer may this program touch the network at all? Labels answer may this value leave? You mark a value as belonging to a principal, and the engine carries that mark through every operation — arithmetic, text, field reads, json_encode, hashes, and the branches taken because of it — and refuses to let it reach a public sink unless you say, in writing and on the record, why it may be published.

You want this when the operator runs the code but must not read the data: a confidential deployment (TEE/enclave), a multi-tenant service that must not cross tenants, anything where "we are careful" is not an acceptable answer.

Turning it on§

Off by default. synsema run app.syn behaves exactly as it always has, down to the step counter.

synsema run  --labels app.syn
synsema test --labels app.test.syn
synsema serve --labels app.syn
synsema serve --attested app.syn    # labels are ALWAYS on here

With labels off, private(…) raises the moment it is called — private: labels are off; run with --labels or serve --attested. Note called, not loaded: synsema check passes, and a private(…) inside a branch that never runs never fires. If the program must refuse to start without labels, call one at the top.

Inside a guest adapter (Vela) labels are always on too — see Vela.

The four builtins§

let balance be private(1200, "app")        -- this belongs to the principal "app"
let doubled be balance * 2                 -- private(app) — every operation propagates
print(label_of(doubled))                   -- [private(app)]  ← redacted, see below
print(is_private(doubled))                 -- private(app)  ← redacted too
let code be declassify("insufficient", "the outcome code is public on-chain")
print(code)                                -- insufficient

Narrowing, in one example:

let joint be private(private(500, "app"), "bank")   -- private to both
let only_bank be declassify(joint, "the bank settles it", ["bank"])
print(declassify(label_of(only_bank), "probe"))   -- ["bank"]

Compare the list, not its text: since engine v0.6.29 a text inside a list prints quoted, so text(label_of(only_bank)) is ["bank"], not [bank] — a test that compared against the old spelling now fails.

These names, plus print, are protected: a program cannot bind them to something callable. Redefining one would silently un-label its own sources and mislead the audit listing.

Called by their name, nowhere else (engine v0.6.46+, breaking)§

declassify and private can only be called by their name. Reading either one as a value is a load error — the program does not start, not even its first line:

let d be declassify                -- an alias
let xs be [declassify]             -- a list entry
let m be {"k": declassify}         -- a map entry
f(declassify, x)                   -- an argument
task g()
    give private                   -- a returned value
call(declassify, {"value": x})     -- also through call()
let m be {private: 1}              -- a BARE key evaluates the name, so this is one too
Runtime error: app.syn:1:10: 'declassify' is read as a value here (an alias, a list or map entry, an argument): it can only be called by its name, `declassify(…)`, so that every call is in the review (`synsema code check`)

Before v0.6.46 each of those forms published for real while synsema code check --json listed none of them, so the review missed the call. Now there is no declassify the inventory cannot see, and its declassify_static_only field is gone (a tool that read it must stop). Each entry is {file, line, column, reason, to, constant}, with to = null for the two-argument form and the target list for the narrowing form. A program that reads declassify as a value gives "ok": false, the load error in errors and an empty declassify list.

labels.syn
-- Doc example: information-flow labels. Runs under `synsema test --labels` ONLY —
-- with labels off `private(...)` is a load error, which is the point: a program that
-- needs the second wall says so, instead of silently running without it.
intent: "doc example: information-flow labels"
-- v0.6.46: whose private data this program may send to a model the HOST allows
-- (`--cap-set "llm=customers@<provider host>"`). It also covers calls with public data.
require llm("customers")

let BALANCE be private(1200, "app")

task doubled()
    -- Every operation carries the union of its operands' labels.
    give BALANCE * 2

task marking_a_list_copies_it()
    -- `private` over a container gives a PRIVATE COPY: the public alias keeps
    -- seeing the public original, so marking cannot retroactively hide what
    -- someone else already holds.
    let public_rows be [1, 2, 3]
    let secret_rows be private(public_rows, "app")
    give public_rows[0]

task parse_untrusted(payload)
    -- An error CAUSED by private data is not catchable (whether an operation
    -- failed is exactly the bit labels exist to hide), so `try`/`recover` is not
    -- the answer for input an enclave receives from anyone. The total variant is:
    -- no error, so no bit.
    let d be json_decode(payload, nothing)
    when d == nothing
        give "malformed"
    give "ok"

task published()
    -- The only way out is on the record: the reason is mandatory and public, and
    -- `synsema code check --json` lists every declassify before anything runs.
    give declassify(BALANCE > 0, "whether the account is open is public")

test "labels propagate through arithmetic"
    assert(is_private(doubled()))
    assert_eq(text(declassify(doubled(), "doc example")), "2400")

test "the answer about a private value is as private as the value"
    -- `is_private`/`label_of` are NOT public oracles: one bit per question would
    -- be a channel of its own.
    assert(is_private(is_private(BALANCE)))
    assert(is_private(label_of(BALANCE)))

test "marking a container copies it"
    assert_eq(marking_a_list_copies_it(), 1)

test "untrusted input is parsed without exceptions"
    assert_eq(parse_untrusted("{\"a\": 1}"), "ok")
    assert_eq(parse_untrusted("not json at all"), "malformed")

test "declassify is the only way out, and it is on the record"
    assert_eq(published(), true)

test "text() does not sanitise: it is still the value, and still private"
    -- The redaction happens at the SINK, not in the conversion. Passing a private value through
    -- `text()` does not make it safe to hand around.
    assert_eq(declassify(is_private(text(BALANCE)), "doc example"), true)
    assert_eq(declassify(text(BALANCE), "doc example"), "1200")

test "a private value inside a concatenation takes the WHOLE string with it"
    -- At a public sink the entire line is replaced by `private(app)` — the prefix goes too.
    let line be "balance: " + text(BALANCE)
    assert_eq(declassify(is_private(line), "doc example"), true)
    assert_eq(declassify(line, "doc example"), "balance: 1200")

test "the third argument of declassify narrows WITHIN the value's own principals"
    let joint be private(private(500, "app"), "bank")
    let only_bank be declassify(joint, "the bank settles it", ["bank"])
    -- label_of gives the principals as a list of texts (itself private, so declassify to compare)
    assert_eq(declassify(label_of(only_bank), "doc example"), ["bank"])
    -- and `text` of that list quotes each principal, like any text inside a list
    assert_eq(declassify(text(label_of(only_bank)), "doc example"), "[\"bank\"]")

-- v0.6.46: a map key spelled like a protected name is written quoted. A bare key evaluates the
-- name, and reading `private` or `declassify` as a value is a load error.
test "a key named like a protected builtin is quoted"
    let m be {"private": 1, "declassify": 2}
    assert_eq(m["private"] + m["declassify"], 3)

-- v0.6.46: `sandbox under` can delegate `declassify` for some principals to one block.
test "sandbox under delegates declassify for the principals it names"
    let r be 0
    sandbox under {"declassify": "app"}
        set r to declassify(BALANCE, "the balance is shown to its owner")
    assert_eq(r, 1200)

-- v0.6.46: what receipt() publishes after private data does not depend on it.
test "receipt(): steps is nothing, declassified[].from is the declared set"
    let pub be declassify(private(7, "bank"), "probe")
    let r be receipt().credentialSubject
    assert_eq(r.steps, nothing)
    let last be r.declassified[length(r.declassified) - 1]
    assert_eq(last.reason, "probe")
    assert_eq(last.from, ["app", "bank"])   -- every principal the program declares, not the value's own
    assert_eq(last.to, [])

test "a declassify inside a private branch is left out of the receipt"
    let before be length(receipt().credentialSubject.declassified)
    let shown be private(0, "app")
    when BALANCE > 1000
        set shown to declassify(BALANCE, "inside a private branch")
    assert_eq(length(receipt().credentialSubject.declassified), before)

-- v0.6.46: `require llm("P")` also covers the calls with public data (it used to break them).
test "require llm(principal) still allows calls with public data"
    when not llm_available()
        assert(starts_with(reason about "the weather", "[reasoning about"))

-- v0.6.55: a mask keeps the label and never branches; an array is private as a whole.
let SCORES be private(array([1, 5, 2, 8]), "app")

task index_one()
    give 1

test "masks over a private array give private results, without a cut (engine v0.6.55+)"
    let m be SCORES > 2
    assert(is_private(sum(m)))
    assert_eq(declassify(sum(m), "doc example"), 2)
    assert_eq(to_list(declassify(where(SCORES, m), "doc example")), [5, 8])
    assert_eq(to_list(declassify(apply(SCORES, (v) => v * 10), "doc example")), [10, 50, 20, 80])

test "a private array takes private values; an index that runs code cannot write inside a list (engine v0.6.55+)"
    let q be private(zeros(3), "app")
    set q[0] to private(5, "app")
    assert_eq(to_list(declassify(q, "doc example")), [5, 0, 0])
    let L be [zeros(3)]
    let said be ""
    try
        set L[0][index_one()] to 2
    recover e
        set said to e
    assert(contains(said, "an index that runs code writes into an array inside a list or map"))
    let i be index_one()
    set L[0][i] to 2
    assert_eq(to_list(L[0]), [0, 2, 0])

What a sink is§

Every operation carries the union of its operands' labels. A public sink is anywhere a value leaves the program's own memory: the HTTP response and every stream, the on-chain result of a guest, a file, an outbound request, another interpreter (parallel_map, run_program, cron, the bus, the swarm). A private value at a sink is a label_violation — the request fails, and the message names the path the value took, never the value.

Two that surprise people:

Three that touch capabilities and the audit log (engine v0.6.45+):

require secret("API_KEY")
require reveal("API_KEY")
let key be secret("API_KEY", "demo")
let balance be private(1200, "app")
let big be declassify(balance > 1000, "whether the balance is over 1000 is public")
when big                                    -- a public branch: reveal may run here
    let plain be reveal(key)

declassify is a capability: the program declares it, the host bounds it (engine v0.6.46+, v0.6.49+)§

declassify is the one place private data leaves. Without a ceiling nothing changes: the runtime grants it, like unsafe in your own Rust crate — explicit, with its mandatory reason, and listed in full by synsema code check --json. Under a ceiling (engine v0.6.49+) it works like require net: the program publishes only the principals it declares with require declassify("<principal>"), and only those the host's ceiling allows too. Nothing is granted from the environment.

How the program runs (with --labels)What declassify may remove
no ceiling (plain run, test, serve)any principal, with or without a require
--cap-set … without a declassify itemnothing
--cap-set "stdout,declassify=customers"customers, when the program declares require declassify("customers")
--cap-set "stdout,declassify=cust*"what the program declares among the principals matching the glob (require declassify("customers"), or ("cust*"))
--cap-set "stdout,declassify" or declassify=*what the program declares (a bare require declassify too)
--sandbox, --deterministicnothing (they never grant it)
inside a sandbox blocknothing
inside sandbox under {"declassify": "customers"}customers (globs work), when the program declares it and within the host ceiling
@synsema/wasm with ceiling: "stdout,declassify=customers"the same as --cap-set
synsema run --labels --cap-set "stdout,declassify=customers" app.syn
require declassify("customers")                 -- under a ceiling, what the program publishes
let c be private(3, "customers")
sandbox under {"declassify": "customers"}       -- delegate it to this block only
    print(declassify(c, "the count is public"))
app.syn:3:17: label_violation: declassify is not granted for what this value is private to (customers). With a ceiling the program declares what it publishes (`require declassify("<principal>")` in the program's preamble, or in the body of the agent that runs this code: an agent declares its own, also for its `parallel_map`) and the host's ceiling has to allow it too (`--cap-set declassify=<principal>`); inside a `sandbox` only a `sandbox under` that delegates `declassify` allows it

What receipt() publishes after private data (engine v0.6.46+)§

With --labels, receipt() no longer carries what depended on a private value. Each was a leak with the program counter public and no label_violation:

read_line, flush and the order of the output (engine v0.6.46+)§

Private data to a model, tools a person wrote (engine v0.6.46+)§

Two ways private data can now do useful work without the program publishing it:

Arrays (engine v0.6.55+)§

Masks keep the label and never branch. A comparison over a private array gives a private mask, and sum(m), where(a, m), some(m), if_else(m, x, y) and apply give private results — no cut, because nothing ran under a private condition. That is why the mask is the native path for private data.

require declassify("x")
let p be private(array([1, 5, 2, 8]), "x")
let m be p > 2
print(declassify(sum(m), "x"))         -- 2
print(declassify(where(p, m), "x"))    -- [5, 8]

Errors that cannot be caught§

An error born under a private branch, or caused by private data, is not catchable: try/recover re-propagates it and assert_error does not absorb it. Whether an operation failed is exactly the bit labels exist to hide — xs[private_index], 1 / (secret - i) — and a recover that swallowed it would let the loop before it leave the secret in a public variable.

So how do you validate untrusted input, which an enclave receives from anyone? Not by raising at all. The operations that parse external input have a total form that returns a fallback:

let d be json_decode(payload, nothing)              -- no error, so no bit
let n be number(field, nothing)
let clear be aes_gcm_decrypt(k, nonce, ct, aad, nothing)

Also decimal, float, toml_parse, abi_decode, bech32_decode, rlp_decode. The fallback is evaluated eagerly (an effect inside it fires on the happy path too) and it never swallows a label violation — that would be a try/recover in disguise.

What a cut shows, and to whom (engine v0.6.47+)§

A cut by the flow checker — any label_violation — used to tell the program where it fell: the builtin, the argument, the line. That was a channel of its own, because a private value can choose which of N sites fails, and in an agent loop the error goes back to the model. Now who runs the program decides what it sees:

How it runs (with labels)What the program and its output showWhere the detail goes
synsema run/test without a ceiling (native or the wasip1 binary)the full error, with file, line and the fix — it is your own runstderr
--cap-set …, --sandbox, --deterministic, --trusteda fixed error, the same for every site, with no locationthe sealed receipt, with --auditor
serve --labels, serve --attesteda 500 with the fixed error; the server's stderr line has the same textthe sealed receipt, with --auditor
WebAssembly, labels: true (run and handle)errors has the fixed erroroperator_detail in the result (not the log hook)
the Vela guest{"error": "label_violation"}nowhere outside the enclave: the Executor's log gets one fixed line

In every case the output printed before the cut is replaced by one line:

private(labels): the run was stopped by the flow checker; its output is withheld because the NUMBER of lines before the stop depends on private data

and the fixed error is:

Runtime error: label_violation: the flow checker stopped the run (private to customers); what failed, where and how to fix it go to the operator, not to the program

The set in parentheses is the program's declared set, a constant of the program, never the label of the value that failed. Measured on v0.6.47 with the same program: synsema run --labels cut.syn prints cut.syn:4:10: label_violation: print called under private control flow (pc = [customers]) …, and synsema run --labels --cap-set stdout cut.syn prints the fixed error above. To debug a cut, run it yourself without a ceiling, read operator_detail in WebAssembly, or open the receipt as the auditor (Observability). The messages quoted on other pages (fetch(argument 0) is private to …, declassify is not granted …, reason would send data private to …) are that detail.

What leaves the process§

A redacted error says private(app) and nothing else — no file:line:column. If the secret chooses which of N sites fails, the location is log₂(N) bits. The principals named are the ones the program declares, never the ones of that particular value: naming the value's own was a channel, since in a multi-tenant deployment the principal is the tenant.

The auditor still learns why (engine v0.6.50+). An error whose text was redacted because it touched private data — when c.name == "Ana" then 1 / 0 gives Runtime error: b.syn:3:16: private(customers) without a ceiling — puts its real text in the sealed receipt, which only the auditor opens, with or without a ceiling: synsema receipt open shows an entry {"sealed": true, "entry": {"kind": "label_violation", "detail": "b.syn:3:16: Division by zero"}}. With a ceiling the program still sees the same fixed text, and the operator detail (a log) stays redacted, with its location. See Observability.

A label violation also ends the whole synsema test --labels run, with one outcome naming the violation, rather than a ✗ on that block with the suite carrying on — eight blocks each probing one bit and the column of ✓/✗ spells the byte. An ordinary failure is still a per-block verdict.

Limits, stated§

Next§

Labels keep the data in. Attestation is the other half: proving to a remote party which code is holding it.