Synsemadocsv0.6.xENES

Capacidades y seguridad

Capacidades e intent

Synsema es deny-by-default. Nada toca la red, el filesystem, la base de datos, los secretos ni la shell salvo que declares la capacidad con require. Si la olvidás, la operación no corre — el intérprete la rechaza, aunque el código la pida.

Esto es lo que hace seguro por construcción correr código no confiado (la salida de un LLM, un playground de docs).

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

Declará lo que necesitás §

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

Un fetch a cualquier host que no declaraste se bloquea. Un read_file fuera del scope se bloquea — el scope de ruta es fiel: un escape con .. se normaliza y se deniega, y lo mismo un link que lleva afuera.

Un require con un nombre que no existe es un error de carga (motor v0.6.46+). Antes, require nett("x") parseaba, no daba nada y no decía nada, y el programa fallaba después con un error que no nombraba el typo. Ahora synsema run y synsema check frenan en la línea: `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. Lo mismo vale para un nombre con punto (require file.raed(...) → did you mean require file.read?) y para un require dentro de una task o un agente; sin un nombre parecido, el mensaje lista los conocidos sin sugerencia. Las formas de auditoría file_read / file_write` siguen cargando.

Auto-otorgadas vs. a declarar §

Bajo run/test, solo se auto-otorgan stdout / time / llm. Todo lo demás — net, file, db, secret, exec, serve, reveal, sign, wallet, spend, memory, y random — es deny-by-default (sí, random también: es para tokens/nonces; y memory también: el estado persistente del agente escribe archivos, así que se declara — require memory("name"), ver Memoria y estado). Bajo serve, incluso esas se exigen.

Lo que nunca toca el exterior no necesita ninguna capability (motor v0.6.29+): un generador con semilla (rng(42), y después g(), random_int(g, 1, 6), shuffle, sample — el default_rng de numpy, bit a bit) y los tipos de fecha con format_time / parse_time / date_parts, que nunca leen el reloj. random gatea solo la entropía del sistema operativo (random(), random_int(lo, hi) sin generador, random_bytes, token); time gatea solo leer el reloj (now(), sleep, y los verificadores que chequean un vencimiento — cada uno acepta también el tiempo explícito). Así que todo eso corre dentro de un sandbox y bajo --deterministic.

Scopes §

synsema check decide si un require cubre una llamada con el mismo matcher del runtime (motor v0.6.30+): require file.read("./**") cubre read_file("x.csv") en el chequeo exactamente como cuando el programa corre, así que un scope que el runtime acepta nunca se reporta como faltante.

Los scopes de ruta son globs por segmento (engine v0.6.45+) §

Un scope de ruta se lee como una línea de .gitignore o un glob de la shell:

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

Antes de v0.6.45 un cruzaba / y ..: file.read("./") concedía /etc/passwd, y file.read(".csv") cualquier .csv del disco. Un programa que quería decir "esta carpeta y todo lo de adentro" ahora escribe ./dir/*.

Los links se juzgan donde de verdad llevan (engine v0.6.45+). use, los scopes de file (el require del programa, el techo del host y un techo delegado a un hijo) y grep miran dónde termina una ruta, no solo su nombre:

spend — la declaración auditada de dinero §

spend(monto, unidad, motivo) declara un gasto externo antes de que tu programa haga la llamada de pago real (la API de un PSP, un exchange, un builder de transacciones). No mueve dinero por sí misma — escribe PRIMERO la entrada forense del ledger y aplica el techo del host:

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

Capacidades por task §

Una task puede declarar su propio require más acotado — solo alcanza lo que declara, aunque el programa sea más amplio:

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

sandbox §

Un bloque sandbox despoja todas las capacidades del cuerpo no confiado que contiene — un require adentro es un no-op.

sandbox under <caps> conserva algunas: el cuerpo corre bajo un techo delegado — lo que el map lista queda (todavía gateado por el require del programa y por el techo del host), lo que no lista se deniega, y un require adentro sigue siendo no-op. El map es un literal {capacidad: scope | [scopes] | nothing} o el map que devolvió captoken_verify — entonces el bloque corre bajo ese token, exactamente como lo hace una request de serve (Identidad de agentes):

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

Los bloques anidados se apilan (cada nivel tiene que cubrir el uso). Las capacidades locales al proceso stdout, stdin, time, random no se pueden delegar — ni por bloque ni por token — así que listarlas es un error; las gobierna el techo del host. Un uso fuera del techo del bloque falla con un Capability not granted: X — outside the ceiling of the enclosing sandbox under block … atrapable.

Techo del host — --sandbox / --cap-set (ejecutar código que NO confiás) §

require, el scope por-task y sandbox asumen que vos escribiste el código. Cuando no fue así — correr un programa generado por un LLM, el plugin de un usuario, o un playground público — el host (quien corre synsema) impone un techo que el código no puede exceder, declare lo que declare:

synsema run  --sandbox program.syn                       # techo = stdout + time solamente
synsema run  --cap-set "stdout,db=:memory:" program.syn  # un techo a medida
synsema test --cap-set "stdout,time,random,secret" tests.syn

El error nombra a quién puede arreglarlo — y dice que es un permiso, no un bug: una llamada que el programa nunca declaró dice 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); una que el programa sí declaró pero el techo bloquea dice declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling (así un agente que repara su propio código nunca entra en loop); una que el programa declaró pero el captoken del caller no lleva dice denied by the delegated ceiling of token <id> — el caller tiene que presentar un token que la lleve (Identidad de agentes). synsema check avisa las no declaradas antes de correr, con el require exacto a agregar — incluido lo que necesitan las tasks de un módulo importado: un módulo nunca declara en su top-level, declara el archivo que lo importa. Detalles y los campos del audit: Sandboxing, WASM.

Tres capas — quién restringe, y cuándo:

CapaQuién restringeUsar cuando
require cap("scope")el código declara lo que necesitaconfiás en el código
bloque sandboxel código aísla una parte de sí mismoconfiás en el código
--sandbox / --cap-setel host impone un techo desde afuerano confiás en el código

--sandbox vs --cap-set:

Dos controles más del host se componen con el techo (engine v0.6.14+): --profile pure — un segundo muro independiente donde cada builtin de filesystem/exec/socket/db/cron simplemente no está, sin importar el techo; y --audit json|<path>|fd:N — una línea JSON por chequeo de capability, para ver exactamente qué tocó un programa (ver Observabilidad). conform también respeta todo esto. Y desde adentro de un programa, require sandbox_run le permite correr otro programa Synsema con run_program(source, {ceiling, profile, env, timeout}) bajo un techo que es la intersección con el propio — el hijo nunca puede exceder al padre; una ruta de ese techo viaja absoluta, anclada donde la escribió el padre, y env no puede poner SYNSEMA_* ni las variables que eligen rutas del host (engine v0.6.45+). Modelo completo: Sandboxing.

La regla: caps_efectivas ⊆ require ∩ techo. Un require net("") bajo --cap-set "net=api.mock" obtiene nada — el código nunca sube por encima del techo. Solo resta*; los auto-grants (stdout/time/llm) también se filtran, y se propaga a los agentes spawneados y a los workers de parallel_map.

Acotá tu file/db o das de más: un --cap-set "…,file" pelado deja al código leer cualquier ruta absoluta — usá file=scratch_* (un prefijo) o db=:memory: para que solo toque lo que querés.

El playground de este sitio usa exactamente esto: cada Run/Test corre con un techo que permite cómputo, secret, SQL en memoria y archivos scratch, pero deniega exec y net real — probá un snippet con require exec(...) y tocá Run. Para un deploy público, combinalo con un contenedor de SO (defensa en profundidad).

intent §

intent: "Read customer data and generate reports"

intent es descriptivo (cualquier idioma) y se congela al arrancar — una inyección de prompt no puede ampliarlo. La seguridad viene de las capacidades, nunca de la prosa. Mirá Secretos para credenciales.

La segunda pared §

Las capacidades responden ¿este programa puede tocar la red?. No responden ¿puede salir este valor? — un programa con require net puede mandar cualquier cosa que tenga. Cuando el operador no debe leer el dato (un enclave, un servicio multi-inquilino), encendé las etiquetas de flujo: private(v, "dueño") marca un valor, el motor arrastra la marca por cada operación y por cada rama tomada por su causa, y todo sumidero público la rechaza hasta que un declassify(v, "por qué") deje el motivo en el registro. La pregunta gemela del lado del tercero — ¿qué código tiene mi dato? — es la attestation, gateada por una capacidad attest deny-by-default que ningún techo empaquetado otorga.