Capacidades y seguridad
Sandbox de código no confiable
El resto del modelo de seguridad (require, scope por-task) asume que vos escribiste el código. Esta página es lo opuesto: ejecutar código que no confiás — la salida de un LLM, el plugin de un usuario, un playground público. Dos herramientas: una desde adentro de tu programa, otra desde afuera.
1. El bloque sandbox — aislar una parte de tu programa
§
Envolvé la parte que maneja input no confiable. Adentro, se despojan todas las capacidades — nada de net/file/db/secret/exec, solo cómputo puro + print. Un require adentro es un no-op (no puede re-otorgar para escapar).
let payload be fetch("https://api.external.com/data") -- untrusted input
sandbox
let result be transform(payload) -- if `transform` is exploited, it can touch NOTHING
También es una expresión:
let clean be sandbox validate(untrusted_input) -- isolated, returns a value
Usalo cuando una parte de tu código debe procesar algo peligroso pero no debe llegar al exterior.
sandbox under <caps> conserva algunas capacidades — un bloque de mínimo privilegio. 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, o el map que devolvió captoken_verify — entonces el bloque corre bajo ese token, exactamente como una request de serve (Identidad de agentes):
require net("api.example.com")
require db("orders")
sandbox under {"db": "orders"} -- may touch the database, not the network
let rows be sql(db, "select 1")
Los bloques anidados se apilan; stdout, stdin, time, random son locales al proceso y no se pueden delegar (listarlas es un error); un uso fuera del techo del bloque es un Capability not granted: … outside the ceiling of the enclosing sandbox under block atrapable.
Un net con comodín en un techo sólo llega a direcciones públicas (motor v0.6.46+), en un bloque sandbox under igual que en el techo del host (--cap-set net, net=) y el techo de run_program. Cada capa que tiene un comodín tiene que nombrar un host privado para que el programa llegue, y el mensaje nombra la capa: dentro de sandbox under {"net": ""} una request a 127.0.0.1 falla con `the enclosing sandbox under block reaches only public addresses for it: name it there (net("127.0.0.1:47100")); bajo --cap-set "stdout,net" con the host ceiling (--sandbox/--cap-set) reaches only public addresses for it. The program cannot fix this; the host must name it: --cap-set net=127.0.0.1:47100 . sandbox under {"net": "127.0.0.1"}` sí llega. Los rangos están en Cliente HTTP.
declassify adentro de un sandbox (motor v0.6.46+, con --labels): un sandbox a secas no la tiene, así que un declassify de un valor privado ahí adentro es un label_violation que try/recover no atrapa. sandbox under {"declassify": "customers"} la delega sólo para esos principales (los globs andan, "cust*"), siempre dentro del techo del host — ver Etiquetas de flujo de información.
let c be private(3, "customers")
sandbox under {"declassify": "customers"}
print(declassify(c, "the count is public"))
-- Doc example: the `sandbox` block — isolate a piece of code with NO capabilities.
-- As an expression it computes and returns; net/file/db/secret/exec are stripped inside.
-- Plus run_program: Synsema running Synsema in a child process under a ceiling ∩ the parent's.
intent: "doc example: sandbox block + run_program"
require sandbox_run
require env("TOOL") -- what the parent LENDS to a child, it must hold itself
require file.read(".")
require file.read("..") -- lent to the child that runs one directory up
task risky_pure(n)
give n * n
-- sandbox as an EXPRESSION: isolated, returns the value (run shows it)
print("sandbox result: " + text(sandbox risky_pure(7))) -- sandbox result: 49
test "sandbox runs pure computation and returns the value"
assert_eq(sandbox risky_pure(7), 49)
assert_eq(sandbox (40 + 2), 42)
-- run_program: the child's result — and its audit — come back as a VALUE, not a log to parse.
test "run_program runs a child under a ceiling; its result is a value"
let r be run_program("print(2 + 3)", {"ceiling": "stdout", "profile": "pure", "timeout": 20})
assert(r["ok"])
assert_eq(r["output"], ["5"])
assert_eq(r["exit"], 0)
test "the child can never exceed the parent: exec it wasn't lent is denied, with a structured audit"
-- the child asks for exec; the parent didn't lend it, so run() is denied and the child fails.
let r be run_program("run(\"echo\", [\"x\"])", {"ceiling": "stdout", "profile": "native", "timeout": 20})
assert_eq(r["ok"], false)
assert(some(r["audit"], (e) => contains(e["capability"], "exec") and not e["granted"]))
test "run_program options: env replaces the child's environment, timeout kills it, cwd moves it"
let r be run_program("require env(\"TOOL\")\nprint(env(\"TOOL\"))",
{"ceiling": "stdout,env=TOOL", "env": {"TOOL": "package"}, "timeout": 20})
assert_eq(r["output"], ["package"])
assert_eq(r["errors"], [])
assert_eq(r["timed_out"], false)
assert(r["steps"] > 0) -- what the child spent, in steps
assert_eq(r["llm_tokens"], 0)
let slow be run_program("while true\n let x be 1", {"ceiling": "stdout", "timeout": 1})
assert_eq(slow["timed_out"], true)
assert_eq(slow["exit"], nothing)
-- A path in the ceiling is anchored where the PARENT wrote it (it travels absolute): "." is the
-- parent's folder, ".." the one above. The child below runs one directory up and reads its cwd.
let child_code be "require file.read(\".\")\nprint(cwd())"
let moved be run_program(child_code,
{"ceiling": "stdout,file.read=..", "profile": "native", "timeout": 20, "cwd": ".."})
assert(moved["ok"])
assert_ne(moved["output"][0], cwd()) -- the child ran one directory up
let stuck be run_program(child_code,
{"ceiling": "stdout,file.read=.", "profile": "native", "timeout": 20, "cwd": ".."})
assert_eq(stuck["ok"], false) -- "." is still the parent's folder, not the child's
assert(contains(stuck["errors"][0], "above the host ceiling"))
test "run_program refuses an env that steers the child's engine, and a filename with folders (v0.6.45+)"
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "env": {"SYNSEMA_AUDIT": "x.log"}}))
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "env": {"HOME": "/tmp/elsewhere"}}))
assert_error(() => run_program("print(1)", {"ceiling": "stdout", "filename": "sub/tool.syn"}))
let r be run_program("print(1)", {"ceiling": "stdout", "filename": "tool.syn", "timeout": 20})
assert_eq(r["output"], ["1"]) -- a plain name is a label: fine2. El techo del host — --sandbox / --cap-set
§
Cuando corrés un programa entero que no escribiste, quien corre synsema impone un techo que el código no puede exceder, declare lo que declare:
synsema run --sandbox program.syn # solo stdout + time
synsema run --cap-set "stdout,db=:memory:" program.syn # un techo a medida
synsema test --cap-set "stdout,time,random,secret" tests.syn
--sandbox= el techo mínimo útil (stdout+time).--cap-set none= nada en absoluto, ni siquierastdout.--deterministic(engine v0.6.20+,run/test/build) =--profile puremás un techo de solostdout— sintime, sinrandom: dos corridas del mismo programa dan la misma salida (evals reproducibles,steps()como costo). Es un alias, así que no se combina con--sandbox/--cap-set/--profile native(exit 2);buildlo hornea en el binario. Lo que sigue disponible porque nunca lee el reloj ni el sistema operativo (motor v0.6.29+): los generadores con semilla (rng(seed),g(),random_int(g, lo, hi),shuffle,sample,choice), los tipos de fecha yformat_time/parse_time/date_parts.now(),sleepyrandom()sin generador fallan conCapability not granted.--cap-set "<lista>"= vos decidís exactamente hasta dónde (nameoname=scope).- La regla:
caps ⊆ require ∩ techo— el código nunca sube por encima. Los auto-grants (llmtambién) se filtran, y aplica al preámbulo del programa (las sentencias antes deserve on), a los agentes spawneados y a los workers deparallel_map. stdoutes una capability real bajo un techo (v0.6.14+): un--cap-setsinstdoutniega la salida en el primerprint/show/log.--sandboxlo incluye. Sin ningún techo, la salida sigue libre.declassifylo acota el techo (motor v0.6.46+, con--labels; breaking): bajo--cap-setsólo se pueden publicar los principales de un ítemdeclassify=<principal>(declassify=customers, un glob, odeclassify/declassify=*para cualquiera), y--sandboxy--deterministicnunca la conceden. Un programa que desclasificaba bajo ellos ahora se frena ahí: agregá el ítem a un--cap-setexplícito. Detalles en Etiquetas de flujo de información.- Acotá
file/dbo das de más:file=scratch_,db=:memory:. Unrenderde un template en disco lee un archivo, así que también necesitafile.read, y lo mismo cada{ include }/{ layout }que trae del disco (engine v0.6.45+; los templates empaquetados no). Unen el scope de un techo se queda en una carpeta, como en unrequire(engine v0.6.45+):file=scratch_son los archivosscratch_…del directorio de trabajo,file=./work/*un árbol entero. conformrespeta estos mismos flags (v0.6.14+) —synsema conform --cap-set "…" app.synvuelca{ok, out, err}con las denegaciones enerr.- El error nombra a quién puede arreglarlo. Una llamada que el programa nunca declaró falla con missing capability — add
require …; una que el programa sí declaró pero el techo bloquea falla con declared but above the host ceiling — the program cannot fix this; the host must widen the ceiling. Un agente que repara su propio código a partir del primer mensaje nunca entra en loop con el segundo (agregando elrequireque ya tiene). El audit lleva la misma distinción (reason) másorigin: "program" | "runtime"— ver WASM para los campos, y--audit jsonen Observabilidad.
Las tres capas juntas §
| Capa | Quién restringe | Para |
|---|---|---|
require cap("scope") | el código (declara lo que necesita) | código que confiás |
bloque sandbox | el código (aísla una parte de sí mismo) | código que confiás |
--sandbox / --cap-set | el host (desde afuera) | código que NO confiás |
Se componen — gana la más restrictiva.
3. run_program — Synsema corriendo Synsema bajo un techo
§
El patrón de arriba — escribir un archivo, exec del binario synsema, parsear stdout — es lo que run_program reemplaza (engine v0.6.14+). Corre otro programa Synsema en un proceso hijo del mismo binario, bajo un techo que es la intersección con el del padre, y devuelve su resultado — y su audit — como un valor. Sin exec, sin synsema en el PATH, sin parsear stderr:
require sandbox_run -- deny-by-default, no scope
require net("registry.npmjs.org") -- what you'll LEND, you must hold
let r be run_program(code, {
"ceiling": "stdout,net=registry.npmjs.org", -- --cap-set syntax, or "sandbox", or "none"
"profile": "pure", -- "pure" (default) or "native"
"env": {"TOOL": "package"}, -- REPLACES the child's environment
"timeout": 30, -- seconds (default 30); on timeout the tree is killed
"cwd": "/path/to/work" -- default: the parent's cwd
})
-- r = {"ok": bool, "output": [lines], "errors": [text], "audit": [entries], "exit": n|nothing,
-- "timed_out": bool, "llm_tokens": n, "steps": n}
El hijo nunca puede exceder al padre. Su techo efectivo es opts.ceiling ∩ lo que el padre puede realmente otorgar — pedir más no es un error, se recorta, y el audit del padre lo registra (reason: "above parent ceiling"). Así net=* bajo un padre que solo tiene net("registry.npmjs.org") colapsa a nada. Lo que el padre no hace require, no lo puede prestar.
envreemplaza por completo el entorno del hijo (las keys LLM del padre y los secretos del
.env desaparecen salvo que se los pases). Un valor secret en env se rechaza — reveal()-lo explícitamente. No puede poner las variables que configuran el motor del hijo (engine v0.6.45+): SYNSEMA_ (run_program: env cannot set SYNSEMA_AUDIT (SYNSEMA_ variables configure the engine, not the program)) ni las que usa para elegir rutas del host — HOME, USERPROFILE, TMP/TEMP/TMPDIR, APPDATA, XDG_, OLLAMA_MODELS, HF_ (… it chooses where the child's engine reads or writes on the host).
- Las rutas de
ceilingviajan absolutas, ancladas donde las escribió el padre (engine
v0.6.45+): un file.read=./data/** en el techo es el ./data del padre, sea cual sea el cwd que reciba el hijo. Un scope con una coma, vacío o con espacios en los bordes se rechaza (… has a comma, which cannot travel to the child): el techo viaja como una lista, y {"file.read": "x,exec"} le habría dado exec al hijo.
filename(opcional) es una etiqueta para los mensajes del hijo, no una ruta: una con carpetas
se rechaza (run_program: filename is a label, not a path (no folders): "sub/tool.syn").
profilenunca sube por encima del padre (un padrepurefuerza un hijopure).timeout(obligatorio, default 30 s) mata todo el árbol de procesos del hijo; la propia
cancelación del padre (un timeout de request bajo serve, agent_stop) también.
cwdfija el directorio de trabajo del hijo (default: el del padre); las rutas relativas del hijo se resuelven desde ahí — pero no su techo, que sigue anclado en el padre (arriba). El hijo guarda su estado y sus archivos temporales donde el padre guarda los suyos — la carpeta de la base dememorydel padre, absoluta — nunca en esecwdni al lado del módulo que llama.memory: un hijo"native"cuyo techo concedememory=<nombre>comparte el<nombre>.dbdel padre — también desde un tick decrono un handler deserve(engine v0.6.45+). Cada proceso escribe solo las entradas que cambió, los ids nuevos del hijo llevan una marca (mem_<n>_p<proceso>) para no chocar nunca con los del padre, y cuando el hijo vuelve el padre recarga: unrecalldespués derun_programve lo que el hijo recordó (verificado:["mem_2_p13032 from child", "mem_1 from parent"]). Un hijo"pure"(el default) y el hijo de un padre cuya memoria no persiste (stdin,--profile pure) tienen su memoria en memoria durante esa corrida y nunca tocan el disco.stepses lo que gastó el hijo (el mismo contador questeps()), yllm_tokenssus tokens de modelo; ante un timeoutexitesnothingytimed_outestrue.- Lo que devolvió el hijo es un input del padre (motor v0.6.29+): entra en el
lineage()del padre y en su recibo, con un compromiso salado de los argumentos y el sha256 del resultado — ver Identidad de agentes. - La recursión está permitida si el techo del hijo incluye
sandbox_run; la profundidad tiene
tope (SYNSEMA_RUN_PROGRAM_MAX_DEPTH, default 4).
ceilingtambién acepta un captoken.run_program(code, {"ceiling": captoken_verify(t, key)})
corre al hijo bajo la autoridad de ese token — la tercera forma en que un token es el techo, junto a una request de serve y a sandbox under (Identidad de agentes). El hijo conserva stdout y time (locales al proceso, no son del token para darlos) salvo que el token lleve deterministic: true; un map {capacidad: scopes} pelado funciona igual, y la intersección con el padre sigue aplicando.
- Bajo
--profile pureel hijo está disponible (spawnea el engine bajo un techo — no unexec
arbitrario); bajo el perfil wasm no lo está (sin procesos hijo).
Este es el runner seguro para un playground, una tool MCP run_synsema, o un agente que ejecuta código que generó: el techo lo impone el propio proceso hijo, y el audit es dato.
4. El perfil pure — un segundo muro §
--cap-set es un muro: el techo. --profile pure es un segundo, independiente — el mismo muro que el build de WebAssembly siempre tuvo, ahora en el binario nativo:
synsema run --profile pure --cap-set "stdout,net=api.example.com" program.syn
Bajo pure, cada builtin que mira al SO no existe como tal: los nombres siguen ligados (nunca Undefined variable), pero fallan con la verdad del entorno — read_file: not available in the pure profile — this run has no filesystem, e igual para run, la familia ws_/socket, las bases de datos, cron_ y el hub de procesos (select/proc_*/watch). Esto vale sin importar el techo: un bug en el intérprete no puede repartir acceso a algo que no está registrado.
Lo que queda bajo pure: el lenguaje entero, la matemática, JSON/CSV, charts, hashing, el lado puro de blockchain y web-auth, fetch/http_* con net (pure es "sin máquina local", no "sin red"), las ops de LLM con llm, secret/reveal/env, agentes/spawn/parallel_map (in-process), remember/recall sin disco (en memoria para la corrida), run_program, y leer un bundle de synsema build (un asset empaquetado es el programa, no el filesystem). La tabla completa de "incluido vs no" está en la página WASM — los dos perfiles son el mismo muro.
Dos muros, no uno: el techo, y el builtin que no está.
Las tres capas juntas §
Sumá el perfil pure y las dos herramientas del host se componen con las del propio código:
| Capa | Quién restringe | Para |
|---|---|---|
require cap("scope") | el código (declara lo que necesita) | código que confiás |
bloque sandbox | el código (aísla una parte de sí mismo) | código que confiás |
--sandbox / --cap-set | el host (techo, desde afuera) | código que NO confiás |
--profile pure | el host (el segundo muro) | código que NO confiás |
run_program(…, {ceiling, profile}) | el programa (un hijo ⊆ sí mismo) | código que él no confía |
Gana la más restrictiva.
Por qué importa para agentes: un agente que escribe y corre Synsema puede ejecutar su propio código conrun_programbajo un techo que no puede escapar, y leer el audit de vuelta como un valor — así "correr el código que generó el LLM" es seguro por construcción. Para un deploy público, envolvelo en un contenedor de SO como capa adicional (defensa en profundidad). Así funcionan el playground de este sitio y su tool MCPrun_synsema.