🛡️ sandbox-labs GitHub ↗

04 · Plugins de terceros#

En una frase, para cualquiera: cuando instalas una extensión, normalmente le das acceso a todo y confías en que se porte bien. Este caso invierte el trato: la extensión no tiene nada hasta que alguien le concede, una a una, las cosas que pidió.

Estado real: 🟡 building — hay código y 8 comprobaciones automáticas, sin levantarse bajo bwrap en CI · Carpeta: cases/04-third-party-plugins/ · Puerto: 8804


Por qué se realiza este caso#

Un plugin es código de un desconocido que corre dentro de tu programa, con tus permisos, con acceso a tu memoria y a tus ficheros. La única barrera suele ser la buena fe.

El modelo habitual es restar: se da acceso a todo y luego se quitan cosas. Ese modelo falla siempre por el mismo sitio: hay que acordarse de quitar cada permiso, y basta olvidar uno.

Lo que pasa con el modelo de restarEjemplo
Se olvida un permisoEl plugin lee variables de entorno con claves
Aparece una capacidad nuevaNadie la añadió a la lista de prohibidos
El plugin se actualizaLa versión de ayer era honesta; la de hoy no
Una dependencia del plugin es maliciosaEl plugin ni siquiera sabe lo que arrastra

La idea que enseña, y que ningún otro caso enseña#

Sumar capacidades en vez de restar permisos. El punto de partida no es «todo menos lo prohibido», es nada. Cada cosa que el plugin puede hacer existe porque alguien la concedió explícitamente y quedó registrada.

Y la concesión no es una casilla de confianza: es una traducción a controles reales. «Puede leer la carpeta entrada/» se convierte en un montaje de solo lectura de esa carpeta y nada más del sistema de ficheros.

Casos de uso reales#

Cómo funcionará#

flowchart LR
  P["🧩 Plugin"] --> M["📜 Manifiesto<br/>declara capacidades"]
  M --> V["⚖️ Validación<br/>contra el esquema"]
  V --> A["👤 Aprobación<br/>del usuario, una a una"]
  A --> C["⚙️ Compilación<br/>de la concesión"]
  C --> J
  subgraph J["🔒 Jaula con exactamente lo concedido"]
    R["▶️ El plugin se ejecuta"]
  end
  J --> E["🧾 Evidencia:<br/>intentos fuera de lo autorizado"]

El flujo, paso a paso#

sequenceDiagram
  participant P as Plugin
  participant S as Sistema
  participant U as Usuario
  participant R as Runtime
  P->>S: manifiesto con las capacidades que necesita
  S->>S: validar contra el esquema (rechazar lo imposible)
  S->>U: «este plugin pide leer entrada/ y salir a api.ejemplo.com»
  U->>S: concede unas, rechaza otras
  S->>R: compilar la concesión a montajes, red y secretos concretos
  R->>R: ejecutar con eso y nada más
  R->>S: acta con lo que intentó y no pudo

Capacidades previstas#

CapacidadA qué se traduce de verdad
read:<carpeta>Montaje de solo lectura de esa carpeta, nada más
write:outputMontaje de escritura de una única carpeta de salida
net:<host>Lista de permitidos en el proxy de salida; todo lo demás se anota y se corta
clockAcceso al reloj. Sin ella, el plugin no puede medir tiempo ni sembrar aleatoriedad
storageAlmacenamiento propio, aislado del de otros plugins
camera (simulada)Un dispositivo falso, para probar el flujo sin hardware
secret:<nombre>Un secreto con nombre, inyectado solo si el manifiesto, la política y el entorno coinciden
eventsRecibir eventos del anfitrión

Esquemas#

Manifiesto del plugin#

{
  "id": "informe-mensual",
  "version": "1.2.0",
  "capabilities": [
    { "kind": "read", "path": "entrada/" },
    { "kind": "write", "path": "salida/" },
    { "kind": "net", "host": "api.ejemplo.com", "port": 443 }
  ]
}

Concesión#

{
  "plugin": "informe-mensual@1.2.0",
  "granted": [ { "kind": "read", "path": "entrada/" } ],
  "denied":  [ { "kind": "net", "host": "api.ejemplo.com", "reason": "el usuario no lo aprobó" } ],
  "approvedBy": "usuario-local",
  "approvedAt": "2026-08-07T03:00:00Z"
}

Acta de ejecución#

{
  "plugin": "informe-mensual@1.2.0",
  "attempts": [
    { "capability": "read:entrada/", "outcome": "permitido" },
    { "capability": "read:/etc/passwd", "outcome": "no concedida", "detail": "no existe en la jaula" },
    { "capability": "net:evil.example", "outcome": "bloqueada por la lista de permitidos" }
  ]
}

Lo que hace útil el acta es la tercera línea: los intentos fuera de lo autorizado son el dato, no un efecto secundario.

Plugins de ejemplo que traerá el caso#

PluginPara qué sirve como ejemplo
CorrectoPide poco, usa lo que pide
Excesivamente permisivoPide todo «por si acaso»: enseña a leer un manifiesto con desconfianza
Que intenta leer de másDeclara una carpeta y abre otra
Que intenta salir a internetSin capacidad de red concedida
Que modifica datosEscribe donde solo debía leer
Con dependencia vulnerable simuladaEl plugin es honesto; lo que arrastra, no

Software necesario#

ComponenteVersiónPara qué
Rust1.75+Validación del manifiesto y compilación de la concesión
bubblewrap0.6+Traducir cada capacidad a montajes y namespaces
Python3.11+Los plugins de ejemplo y el servicio
Linux o WSL2kernel 5.10+Namespaces sin privilegios

Instalación#

Los mismos requisitos comunes que el resto de la familia técnica:

sudo apt install bubblewrap util-linux python3
cargo build --release
cargo run -p sandboxctl -- doctor

Procesos que se crearán#

sandboxctl service up third-party-plugins
  │
  ├─ systemd --user scope
  │   └─ bwrap                    ← montajes derivados de la concesión, no fijos
  │       └─ el plugin
  │
  ├─ proxy de salida              ← solo si se concedió alguna capacidad de red
  └─ sandboxctl service forward   ← puente TCP :8804 ↔ socket Unix

La diferencia con los demás casos: los montajes de la jaula no están escritos en la política, se calculan a partir de lo que el usuario aprobó.

Tiempo de carga estimado#

OperaciónCoste esperado
Validar un manifiesto< 5 ms
Compilar la concesión a controles< 10 ms
Arrancar la jaula del plugin5–15 ms
Ejecución del pluginlo que tarde el plugin, con techo por política

Qué hace falta para construirlo#

  1. Esquema JSON del manifiesto de capacidades, validado en cada commit.
  2. Traducción de cada capacidad a controles reales del compilador de bwrap.
  3. Flujo de aprobación en el panel de control.
  4. Acta de ejecución con los intentos fuera de lo autorizado.
  5. Los seis plugins de ejemplo.

Depende del proxy de salida con lista de permitidos, que ya está construido y es lo que hará posible la capacidad net:.

Si algo falla#

El caso ya tiene código: el núcleo en core.py y el servicio en app.py. Lo que sigue son sus fallos, la causa y la salida:

SituaciónCausaCómo se resuelve
El manifiesto se rechaza antes de instalarsePide una capacidad que no existe en el esquemaSe rechaza al validar, antes de llegar a la pantalla de aprobación: el usuario nunca ve una petición imposible. Corregir el manifiesto contra el esquema publicado
El plugin falla al hacer algo que declaróEl usuario concedió menos de lo que pidióEl plugin corre con lo concedido y el intento queda en el acta con su capacidad y su motivo. Volver a pedir esa capacidad y explicar para qué. La concesión no se amplía por conveniencia
El plugin intenta algo que nunca declaróCódigo que hace más de lo que dice su manifiestoNo hay «permiso denegado»: la capacidad no existe dentro de la jaula. El intento se registra, y ese registro es el producto del caso
La ejecución no ocurre y dice que falta un controlUna capacidad no se puede traducir a un control real en este equipoResolver la carencia del equipo —ver Cuando algo falla—. La alternativa, ejecutar con menos contención de la prometida, es peor que no ejecutar
Un plugin funcionaba y deja de funcionar tras actualizarseLa versión nueva pide capacidades nuevasEl manifiesto va versionado: un cambio de capacidades vuelve a pedir aprobación, no se hereda
El plugin arrastra una dependencia vulnerableEl plugin es honesto y lo que trae, noEs uno de los seis plugins de ejemplo previstos. Se resuelve por capacidades: la dependencia tampoco tiene más de lo concedido

Los fallos que afectan a cualquier caso —no se puede crear el sandbox, no hay cgroups, un puerto ocupado, procesos huérfanos, la compilación en Windows— están resueltos uno a uno en Cuando algo falla.

Cómo se comprueba#

node scripts/verify-cases.mjs

Llama al núcleo del caso con situaciones concretas y comprueba qué hizo con ellas, no cómo está escrito. Son 8 comprobaciones, y corren en cada commit.

cargo run -p sandboxctl -- service up third-party-plugins

Levanta el caso como producto en 127.0.0.1:8804. POST /api/run acepta el cuerpo que describen los esquemas de arriba.

Sigue en building, no en functional. El núcleo se comprueba, pero el servicio no se levanta bajo bwrap dentro de CI y el caso no emite evidencia firmada. La regla completa está en el ROADMAP.

Ver también: Catálogo completo · Estado del proyecto · Referencia de políticas