Synsemadocsv0.6.xENES

Capacidades y seguridad

Etiquetas de flujo de información

Las capacidades responden ¿este programa puede tocar la red?. Las etiquetas responden ¿puede salir este valor? Marcás un valor como perteneciente a un principal, y el motor arrastra esa marca por cada operación — aritmética, texto, lectura de campos, json_encode, hashes, y las ramas que se tomaron por su causa — y se niega a dejarla llegar a un sumidero público hasta que digas, por escrito y en el registro, por qué puede publicarse.

Esto lo querés cuando el operador corre el código pero no debe leer el dato: un despliegue confidencial (TEE/enclave), un servicio multi-inquilino que no debe cruzar inquilinos, cualquier cosa donde "somos cuidadosos" no sea una respuesta aceptable.

Cómo se enciende§

Apagado por defecto. synsema run app.syn se comporta exactamente como siempre, hasta en el contador de pasos.

synsema run  --labels app.syn
synsema test --labels app.test.syn
synsema serve --labels app.syn
synsema serve --attested app.syn    # acá las etiquetas están SIEMPRE encendidas

Con las etiquetas apagadas, private(…) falla en el momento en que se lo llama — private: labels are off; run with --labels or serve --attested. Ojo: llamado, no cargado: synsema check pasa, y un private(…) dentro de una rama que nunca corre nunca dispara. Si el programa tiene que negarse a arrancar sin etiquetas, llamá una arriba de todo.

Dentro de un adaptador guest (Vela) también están siempre encendidas — ver Vela.

Los cuatro 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

Acotar, en un ejemplo:

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"]

Compará la lista, no su texto: desde el motor v0.6.29 un texto dentro de una lista se imprime entre comillas, así que text(label_of(only_bank)) es ["bank"], no [bank] — un test que comparaba contra la forma vieja ahora falla.

Estos nombres, más print, están protegidos: un programa no puede ligarlos a algo invocable. Redefinir uno desetiquetaría en silencio sus propias fuentes y engañaría al listado de auditoría.

Se llaman por su nombre, en ningún otro lado (motor v0.6.46+, breaking)§

declassify y private sólo se pueden llamar por su nombre. Leer cualquiera de los dos como valor es un error de carga — el programa no arranca, ni siquiera su primera línea:

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

Antes de v0.6.46 cada una de esas formas publicaba de verdad mientras synsema code check --json no listaba ninguna, así que la revisión no veía la llamada. Ahora no hay declassify que el inventario no vea, y su campo declassify_static_only desapareció (una herramienta que lo leía tiene que dejar de hacerlo). Cada entrada es {file, line, column, reason, to, constant}, con to = null para la forma de dos argumentos y la lista destino para la forma que estrecha. Un programa que lee declassify como valor da "ok": false, el error de carga en errors y una lista declassify vacía.

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

Qué es un sumidero§

Cada operación lleva la unión de las etiquetas de sus operandos. Un sumidero público es cualquier lugar donde un valor sale de la memoria del propio programa: la respuesta HTTP y todo stream, el resultado on-chain de un guest, un archivo, una petición saliente, otro intérprete (parallel_map, run_program, cron, el bus, el enjambre). Un valor privado en un sumidero es un label_violation — el request falla, y el mensaje nombra el camino que recorrió el valor, jamás el valor.

Dos que sorprenden:

Tres que tocan capacidades y el log de auditoría (motor 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 es una capacidad: el programa la declara, el host la acota (motor v0.6.46+, v0.6.49+)§

declassify es el único lugar por donde sale un dato privado. Sin techo no cambia nada: el runtime la concede, como unsafe en tu propio crate de Rust — explícita, con su razón obligatoria, y listada entera por synsema code check --json. Bajo un techo (motor v0.6.49+) funciona como require net: el programa publica sólo los principales que declara con require declassify("<principal>"), y sólo los que el techo del host también permite. Nada se concede del ambiente.

Cómo corre el programa (con --labels)Qué puede sacar declassify
sin techo (run, test, serve a secas)cualquier principal, con o sin require
--cap-set … sin un ítem declassifynada
--cap-set "stdout,declassify=customers"customers, cuando el programa declara require declassify("customers")
--cap-set "stdout,declassify=cust*"lo que el programa declara entre los principales que matchean el glob (require declassify("customers"), o ("cust*"))
--cap-set "stdout,declassify" o declassify=*lo que el programa declara (también un require declassify desnudo)
--sandbox, --deterministicnada (nunca la conceden)
adentro de un bloque sandboxnada
adentro de sandbox under {"declassify": "customers"}customers (los globs andan), cuando el programa lo declara y dentro del techo del host
@synsema/wasm con ceiling: "stdout,declassify=customers"lo mismo que --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

Qué publica receipt() después de un dato privado (motor v0.6.46+)§

Con --labels, receipt() ya no lleva lo que dependió de un valor privado. Cada uno era una fuga con el contador de programa público y sin label_violation:

read_line, flush y el orden de la salida (motor v0.6.46+)§

Datos privados a un modelo, herramientas que escribió una persona (motor v0.6.46+)§

Dos formas en que un dato privado ahora puede hacer trabajo útil sin que el programa lo publique:

Arrays (motor v0.6.55+)§

Las máscaras conservan la etiqueta y nunca ramifican. Una comparación sobre un array privado da una máscara privada, y sum(m), where(a, m), some(m), if_else(m, x, y) y apply dan resultados privados — sin corte, porque nada corrió bajo una condición privada. Por eso la máscara es el camino nativo para datos privados.

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]

Errores que no se pueden atrapar§

Un error nacido bajo una rama privada, o causado por un dato privado, no es atrapable: try/recover lo re-propaga y assert_error no lo absorbe. Que una operación haya fallado es exactamente el bit que las etiquetas existen para esconder — xs[indice_privado], 1 / (secreto - i) — y un recover que se lo tragara dejaría que el bucle anterior deje el secreto en una variable pública.

¿Entonces cómo validás input no confiable, que es lo que un enclave recibe de cualquiera? No fallando. Las operaciones que parsean entrada externa tienen una forma total que devuelve un reemplazo:

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)

También decimal, float, toml_parse, abi_decode, bech32_decode, rlp_decode. El reemplazo se evalúa siempre (un efecto adentro dispara también en el camino feliz) y nunca se traga una violación de etiquetas — eso sería un try/recover encubierto.

Qué muestra un corte, y a quién (motor v0.6.47+)§

Un corte del chequeo de flujo — cualquier label_violation — le decía al programa dónde cayó: el builtin, el argumento, la línea. Eso era un canal en sí mismo, porque un valor privado puede elegir cuál de N sitios falla, y en un loop de agente el error vuelve al modelo. Ahora lo que ve el programa lo decide quien lo corre:

Cómo corre (con etiquetas)Qué muestran el programa y su salidaAdónde va el detalle
synsema run/test sin techo (nativo o el binario wasip1)el error completo, con archivo, línea y el arreglo — es tu propia corridastderr
--cap-set …, --sandbox, --deterministic, --trustedun error fijo, el mismo para cualquier sitio, sin ubicaciónel recibo sellado, con --auditor
serve --labels, serve --attestedun 500 con el error fijo; la línea de stderr del servidor trae el mismo textoel recibo sellado, con --auditor
WebAssembly, labels: true (run y handle)errors trae el error fijooperator_detail en el resultado (no el hook log)
el guest de Vela{"error": "label_violation"}a ningún lado fuera del enclave: el log del Executor recibe una línea fija

En todos los casos, la salida que se imprimió antes del corte se reemplaza por una sola línea:

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

y el error fijo es:

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

El conjunto entre paréntesis es el conjunto declarado del programa, una constante del programa, nunca la etiqueta del valor que falló. Medido en v0.6.47 con el mismo programa: synsema run --labels cut.syn imprime cut.syn:4:10: label_violation: print called under private control flow (pc = [customers]) …, y synsema run --labels --cap-set stdout cut.syn imprime el error fijo de arriba. Para depurar un corte, corrélo vos sin techo, leé operator_detail en WebAssembly o abrí el recibo como auditor (Observabilidad). Los mensajes que citan otras páginas (fetch(argument 0) is private to …, declassify is not granted …, reason would send data private to …) son ese detalle.

Qué sale del proceso§

Un error redactado dice private(app) y nada más — ni archivo:línea:columna. Si el secreto elige cuál de N sitios falla, la ubicación vale log₂(N) bits. Los principales que nombra son los que el programa declara, jamás los de ese valor en particular: nombrar los propios era un canal, porque en un despliegue multi-inquilino el principal es el inquilino.

El auditor igual se entera de por qué (motor v0.6.50+). Un error cuyo texto se redactó porque tocó datos privados — when c.name == "Ana" y después 1 / 0 da Runtime error: b.syn:3:16: private(customers) sin techo — pone su texto real en el recibo sellado, que sólo abre el auditor, con o sin techo: synsema receipt open muestra una entrada {"sealed": true, "entry": {"kind": "label_violation", "detail": "b.syn:3:16: Division by zero"}}. Con techo el programa sigue viendo el mismo texto fijo, y el detalle del operador (un log) sigue redactado, con su ubicación. Mirá Observabilidad.

Una violación de etiquetas además termina toda la corrida de synsema test --labels, con un solo veredicto que la nombra, en vez de un ✗ en ese bloque y la suite siguiendo — ocho bloques sondeando un bit cada uno y la columna de ✓/✗ deletrea el byte. Una falla ordinaria sigue siendo veredicto por bloque.

Límites, dichos§

Sigue§

Las etiquetas mantienen el dato adentro. La attestation es la otra mitad: probarle a un tercero remoto qué código lo está sosteniendo.