---
slug: 22-sandbox
title: Sandbox de código no confiable
description: Ejecutá código que no confiás — generado por un LLM, el plugin de un usuario, un playground público — de forma segura en Synsema, con el bloque sandbox y el techo del host.
example_ids: [sandbox]
---

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

```synsema
let payload be fetch("https://api.external.com/data")   -- input no confiable
sandbox
    let result be transform(payload)   -- si `transform` es explotado, NO puede tocar nada
```

También es una expresión:

```synsema
let clean be sandbox validate(untrusted_input)   -- aislado, devuelve un valor
```

Usalo cuando una parte de **tu** código debe procesar algo peligroso pero no debe llegar al exterior.

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

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

## 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:

```sh
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 siquiera `stdout`.
- **`--cap-set "<lista>"`** = vos decidís exactamente hasta dónde (`name` o `name=scope`).
- La regla: `caps ⊆ require ∩ techo` — el código nunca sube por encima. Los auto-grants (`llm` también) se filtran, y aplica al **preámbulo** del programa (las sentencias antes de `serve on`), a los **agentes** spawneados y a los workers de `parallel_map`.
- **`stdout` es una capability real bajo un techo** (v0.6.14+): un `--cap-set` sin `stdout` niega la salida en el primer `print`/`show`/`log`. `--sandbox` lo incluye. Sin ningún techo, la salida sigue libre.
- **Acotá `file`/`db`** o das de más: `file=scratch_*`, `db=:memory:`. Un `render` de un template en **disco** lee un archivo, así que también necesita `file.read` (los templates empaquetados y los `include`/`layout` anidados no).
- `conform` respeta estos mismos flags (v0.6.14+) — `synsema conform --cap-set "…" app.syn` vuelca `{ok, out, err}` con las denegaciones en `err`.
- **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 el `require` que ya tiene). El audit lleva la misma distinción (`reason`) más `origin: "program" | "runtime"` — ver [WASM](72-wasm) para los campos, y `--audit json` en [Observabilidad](63-observability).

## 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:

```synsema
require sandbox_run                         -- deny-by-default, sin scope
require net("registry.npmjs.org")           -- lo que vas a PRESTAR, lo tenés que tener

let r be run_program(code, {
    "ceiling": "stdout,net=registry.npmjs.org",   -- sintaxis de --cap-set, o "sandbox", o "none"
    "profile": "pure",                            -- "pure" (default) o "native"
    "env":     {"TOOL": "package"},               -- REEMPLAZA el entorno del hijo
    "timeout": 30,                                -- segundos (default 30); al vencer se mata el árbol
    "cwd":     "/path/to/work"                    -- default: el cwd del padre
})
-- r = {"ok": bool, "output": [lines], "errors": [text], "audit": [entries], "exit": n|nothing,
--      "timed_out": bool, "llm_tokens": 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` reemplaza** 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.
- **`profile`** nunca sube por encima del padre (un padre `pure` fuerza un hijo `pure`).
- **`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.
- **La recursión** está permitida si el techo del hijo incluye `sandbox_run`; la profundidad tiene
  tope (`SYNSEMA_RUN_PROGRAM_MAX_DEPTH`, default 4).
- Bajo `--profile pure` el hijo está disponible (spawnea el engine bajo un techo — no un `exec`
  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:

```sh
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](72-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 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`.
