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).
-- 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 hereDeclará 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 §
- Host:
net("api.x.com"),net(".x.com")(subdominios),net("")/require netpelado = cualquiera. Un host solo cubre todos los puertos; un puerto que escribís es parte del permiso (motor v0.6.29+):net("localhost:8545")cubre solo el puerto 8545, yhttp://localhost:8546/se deniega. IPv6 con puerto esnet("[::1]:8545"). Una URL sirve como permiso y se guarda como su host (y puerto):require net("https://user:pw@api.x.com/v1?k=1")esnet("api.x.com")— credenciales, ruta y query nunca llegan a un mensaje ni a la auditoría. Un comodín sólo llega a direcciones públicas (motor v0.6.46+):require net,net("")ynet(".x.com")— también en un techo del host y unsandbox under— ya no llegan a loopback, redes privadas, link-local (el endpoint de metadata de la nube) ni los rangos de documentación; nombrá un host privado, su dirección o su CIDR (net("db.internal"),net("10.0.0.0/8"),net(env("CONTROL_URL"))). Rangos, mensajes y qué cubre: Cliente HTTP. - Ruta:
file("/data/*");fileconcede lectura+escritura,file.read/file.writepara mínimo privilegio.~/expande al home en scopes y rutas (engine v0.6.20+:file.read("~/.config/app/")). Un scope de ruta es un glob por segmento — las reglas están abajo. - Prefijo de nombre:
secret("APP_")cubreAPP_KEY,APP_DB, … (soloal final). - Un secret ligado a hosts (motor v0.6.46+):
require secret("K") for net("api.example.com")deja queKviaje en la URL, la query o el body, sólo a esos hosts y sólo por https/wss; el techo del host liga con--cap-set secret=K@api.example.com(varios:K@a.example.com|b.example.com). Ver Secretos.
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
- *
y?matchean dentro de un segmento de la ruta* — nunca cruzan una/../data/son los archivos de./data; un archivo en./data/sub/queda afuera. cruza carpetas, y./data/cubre también./datamisma (así quelist_dir("./data")funciona con eso).- Ningún comodín matchea
.., y ninguno salta de una ruta relativa a una absoluta:file.read("")es el directorio de trabajo y todo lo que hay abajo, nuncaC:/Windows/win.inini/etc/passwd. Un..en la ruta se normaliza primero, así queread_file("./data/../../etc/passwd")cae fuera de./data/y se deniega. - El disco entero es la capacidad sin scope:
require file.read.require file.read("*")no es el disco entero; son los archivos del directorio de trabajo. cwd()ylist_dir(".")necesitanfile.read("."),"./"o""—"./*"nombra los archivos, no la carpeta.- La raíz de un disco (
C:/**) cubre el disco. En Windows un scope escrito/dataes/dataen el disco del directorio de trabajo. Las rutas se comparan sin distinguir mayúsculas en Windows y macOS — salvo en una carpeta que sí las distingue (las que crea WSL), dondeDATAydatason lugares distintos.
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:
- Un link que es parte de la ruta concedida funciona: el proyecto al que llegás por un link, o
./dataque es en sí un link. - Un symlink o junction debajo de la grant que lleva afuera se rechaza, con su propio mensaje:
Capability not granted: file_read("data/ext/s.txt") — by name the path is inside the granted scope, but a link (symlink or junction) on the way leads outside it. Synsema checks where a path really leads: grant the place the link points to, or remove the link. Lo mismo vale parause "./ext/o.syn"a través de un link, así que el código aprobado por hash es el código que corre. - Un link roto se juzga donde apunta (escribir a través de
data/log.txt -> ~/.ssh/authorized_keysse rechaza antes de que exista el destino); una ruta que no se puede resolver se rechaza. grepno sigue links a carpetas;list_diryfile_infomarcan los links conis_link(Archivos e I/O).- Un link que se cambia entre el chequeo y la apertura ya no lleva la operación afuera del permiso (motor v0.6.46+, Linux y Windows): el archivo se abre sin seguir links y se juzga de nuevo donde está de verdad — qué cubre y qué no está en Archivos e I/O. Sigue sin atraparse: un hard link (no se ve desde 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
- El scope es la unidad, cualquier string:
"USD","EUR","ETH","creditos"— nombre exacto o prefijo confinal (spend("PFX_")), comosecret.require spendpelado (cualquier unidad) funciona pero avisa. - Jamás auto-otorgada — ni siquiera bajo
run(comosign/wallet/memory). Denegada dentro desandbox; una task despachada concall_tooltiene que declararla ella misma. - Ledger, fail-loud: todo intento (concedido o denegado) se agrega a
spend.logen el directorio de audit ($SYNSEMA_AUDIT_DIRo~/.synsema/audit) con el monto decimal canónico, la unidad, el motivo entre comillas,file:liney el programa. Si la entrada no se puede escribir, el gasto falla — sin auditoría no hay gasto. - Techo del host:
SYNSEMA_SPEND_CEILING="USD:500,ETH:0.1"(una variable, paresunidad:montoseparados por comas, resuelta environ del proceso >.env). Excederlo es un error duro catchable (try/recover) — después de él, no sigas con la llamada de pago externa. El acumulador es por proceso y monotónico; la política temporal (por día, por cliente) es de tu programa, leyendospend.log. - Los montos van por el camino decimal exacto — los centavos jamás acumulan error binario de float.
spenddevuelve el total acumulado nuevo de la unidad;spend_total(unidad)lo lee (0 si no se usó).
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:
| Capa | Quién restringe | Usar cuando |
|---|---|---|
require cap("scope") | el código declara lo que necesita | confiás en el código |
bloque sandbox | el código aísla una parte de sí mismo | confiás en el código |
--sandbox / --cap-set | el host impone un techo desde afuera | no confiás en el código |
--sandbox vs --cap-set:
--sandbox— el techo mínimo útil: solostdout+time(cómputo +print). Para "solo corré esto y mostrame la salida".--cap-set "<lista>"— un techo a medida:nameoname=scopeseparados por coma. Cuando el código legítimamente necesita algo (un archivo scratch, una DB en memoria) pero no debe obtener más.--cap-set nonees un techo vacío — nada, ni siquierastdout(bajo un techo,stdoutes una capability real:--cap-setsin él niega la salida).
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 deniegaexecynetreal — probá un snippet conrequire 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.