Synsemadocsv0.6.xENES

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"))
sandbox.syn
-- 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: fine

2. 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

Las tres capas juntas §

CapaQuién restringePara
require cap("scope")el código (declara lo que necesita)código que confiás
bloque sandboxel código (aísla una parte de sí mismo)código que confiás
--sandbox / --cap-setel 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.

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

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.

se rechaza (run_program: filename is a label, not a path (no folders): "sub/tool.syn").

cancelación del padre (un timeout de request bajo serve, agent_stop) también.

tope (SYNSEMA_RUN_PROGRAM_MAX_DEPTH, default 4).

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.

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:

CapaQuién restringePara
require cap("scope")el código (declara lo que necesita)código que confiás
bloque sandboxel código (aísla una parte de sí mismo)código que confiás
--sandbox / --cap-setel host (techo, desde afuera)código que NO confiás
--profile pureel 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 con run_program bajo 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 MCP run_synsema.