# Seguridad

El Hub ejecuta scripts que escribió un modelo, con sus derechos, dentro de sus aplicaciones. Esta página dice qué limita el Hub, qué riesgos quedan y qué sale de su máquina, y da las mediciones repetidas para ella. Es para quien decide si conecta un modelo a sus aplicaciones.

Las mediciones se hicieron en Debian 13 con el Hub 0.1.0 construido a partir de su código fuente; las de un Hub en marcha usaron un directorio personal desechable con su propio token.

> **Límite**
>
> El Hub no es un entorno aislado. Un script se ejecuta con los derechos de su usuario, dentro de una aplicación cuyo Python puede hacer todo lo que usted puede. La compuerta de scripts detiene un script torpe, no uno decidido. El puerto UNO de LibreOffice no tiene autenticación, y otro proceso de su usuario puede leer el archivo del token. Un script que entra en bucle mantiene ocupada su aplicación hasta que usted la cierra, salvo en el modo `cli`.


## Lo que sale de la máquina

Todo lo que devuelve el Hub va al modelo de lenguaje y, por tanto, a quien lo ejecuta.

| Qué | Sale de la máquina |
|---|---|
| El script que escribió el modelo | Sí: el modelo es su autor |
| `stdout`, `stderr` y `result` de un script | Sí: pueden contener el contenido de un documento |
| La traza de error | Sí: contiene rutas de la máquina |
| `state` | Sí: contiene contenido tomado de los documentos abiertos |
| La ruta de una captura | Sí: forma parte de la respuesta |
| La imagen de una captura | Solo si el modelo lee el archivo |
| El diario de auditoría, el token y la configuración | Nunca |

Un usuario que abre un documento confidencial en una aplicación conectada sabe que el modelo puede leer su contenido. En la repetición, `get_state` devolvió la etiqueta `Notes` de una capa que había creado un script, con `content_origin` en `document`; esa etiqueta se lee igual que el contenido de un documento.

El Hub solo se conecta a los puentes en `127.0.0.1`. Ejecutado bajo `strace -f -e trace=connect,bind,listen` durante una sesión que incluía un script que superó su plazo, el Hub hizo en total un `connect` por IP: a `127.0.0.1`, en el puerto del puente de Blender. `cargo test --locked --test invariants` lee el código fuente en busca de cualquier otro.

## Quién puede llegar a un puente

Un puente escucha solo en `127.0.0.1`, acepta una conexión a la vez y responde a un cliente que empieza con el token. `ss -ltn` mostró un socket en escucha para el puente de Blender, en el puerto fijado para la ejecución: `127.0.0.1:9313`. Antes de que el Hub se conectara, se le enviaron cuatro sondas:

- Un `hello` con un token incorrecto: 0 bytes de vuelta y la conexión cerrada al instante.
- Una solicitud HTTP: 0 bytes de vuelta y la conexión cerrada al instante.
- Nada enviado: 0 bytes de vuelta y la conexión cerrada tras 5,0 s.
- Un `hello` con el token correcto: 99 bytes de vuelta, la respuesta al `hello`, que nombra la aplicación y las versiones y no contiene el token. La conexión siguió abierta.

El puerto UNO propio de LibreOffice no tiene autenticación. Con `soffice` escuchando en `127.0.0.1:2102` y sin presentar ningún token, un cliente de Python con `python3-uno` leyó la versión de LibreOffice, `25.2.3.2`, abrió un documento de Calc y escribió en una celda. Ese camino esquiva el token, la compuerta de scripts y el diario de auditoría.

## Lo que el Hub limita

Los límites son claves de la sección `[hub]` de `config.toml`, en el archivo que escribe `init`; los valores de abajo son los predeterminados que enumera.

- **Tamaño del script**, `max_script_bytes`, 262 144 bytes: un script de 300 009 bytes se rechazó con `script is 300009 bytes; the limit is 262144 bytes`.
- **Salida por flujo**, `max_output_bytes`, 1 048 576 bytes: un script que imprimía 2 000 000 de caracteres devolvió un `stdout` de 1 048 576 bytes y `"truncated": true`.
- **Tiempo**, `default_timeout_s`, 30 s: en el modo `cli`, `timeout` a los 30,0 s, sin ningún proceso hijo restante, y la llamada siguiente `ok` en 31 ms. En el modo `tcp`, `timeout` a los 31,2 s (32,0 s en la segunda ejecución), y luego `busy` para la llamada siguiente.
- **Un script a la vez por aplicación**: el `busy` anterior. En el modo `tcp` la aplicación no se detuvo; ejecutó hasta el final su script de 40 segundos, y el Hub volvió a responder `ok` 40,3 s después del inicio (40,1 s en la segunda ejecución).

Solo el modo `cli` mata un script que supera su plazo. En los modos `tcp` y `uno`, el Hub no puede interrumpir un script dentro de la aplicación: responde `timeout`, rechaza los scripts nuevos con `busy` y espera.

## La compuerta de scripts

La compuerta lee el texto de un script antes de enviarlo. Un script que llamó a `os.remove` se rechazó antes de llegar a Inkscape, con la categoría, la construcción y la línea: `G-1 (file deletion) at line 3: os.remove`. Cuando una ruta no se puede comprobar a partir del texto, la compuerta deja pasar el script y añade una advertencia a `stderr`, por ejemplo `path not statically verifiable (open())`.

Un script que escribió un segundo archivo y lo ejecutó con `runpy` borró un archivo que había creado, y el Hub respondió `ok`. Ese es el límite que declara el README del repositorio: la compuerta no ve `runpy`, `pickle`, `http.server`, las funciones de guardado de las aplicaciones ni una ruta pasada a una función como parámetro. El operador levanta la compuerta con `allow_dangerous_scripts = true` en la sección `[security]`; las dos herramientas no admiten ese parámetro, y el diario registra entonces `guard: bypassed`.

Sobre los falsos positivos de la compuerta, `cargo test --locked --test guard realistic -- --nocapture` imprimió `false positives: 0 of 21 realistic scripts (0.0%)`.

## Archivos, modos y negativas a iniciar

Tras `init`, `config.toml` y `token` tenían ambos el modo 600. Una captura pedida con `get_state` era un archivo con el modo 600 en un directorio con el modo 700, bajo `XDG_RUNTIME_DIR`; seguía allí después de que el Hub terminara con el código 0.

El Hub se negó a iniciar, con el código de salida 1 y una línea de explicación, en cinco casos:

| Caso | Mensaje, sin la ruta |
|---|---|
| el archivo del token con el modo 0640 | `has mode 0640; expected 0600 (chmod 600)` |
| sin archivo del token | `does not exist` |
| `config.toml` con el modo 0644 | `configuration file refused: … has mode 0644; expected 0600 (chmod 600)` |
| el directorio de ejecución del Hub con el modo 0755 | `has mode 0755; expected 0700 (chmod 700)` |
| `XDG_RUNTIME_DIR` sin definir | `XDG_RUNTIME_DIR is not set; refusing to write volatile files elsewhere` |

El valor del token no apareció en ninguno de estos mensajes. `libremodular-launch libreoffice` rechazó un archivo de puente con el modo 0664 antes de iniciar nada, con el código de salida 2: `has mode 0664 and can be changed by the group or by others (chmod go-w)`.

## El diario de auditoría

Cada llamada escribe una línea en `audit.jsonl` en el directorio de estado, con el modo 600, `get_state` incluida. Cuatro llamadas (un script que se ejecutó, uno que rechazó la compuerta, uno que produjo un error y uno para una aplicación desactivada) escribieron cuatro líneas. Una línea contiene la fecha, la herramienta, el destino, el estado, la duración, el tamaño y el SHA-256 del script, el resultado de la compuerta y el tamaño de la salida:

```json
{
  "ts": "2026-10-21T08:56:27.564Z",
  "tool": "execute_script",
  "target_app": "inkscape",
  "status": "refused",
  "duration_ms": 0,
  "script_bytes": 60,
  "script_sha256": "3696b8a4f24f23da34ba239bbb401c56cd78fb53c2bef48ab2f7d668fd1fdbf8",
  "guard": [
    "G-1"
  ],
  "output_bytes": 524
}
```

El archivo guarda cada línea en una sola línea; aquí se partió para poder leerla.

Una cadena marcadora colocada en los scripts no se encontró en el diario, y el token tampoco. El texto del script solo se escribe si el operador establece `audit_script_text = true`.

## Las pruebas del repositorio

Estos comandos son los de la CI del repositorio. Se ejecutaron fuera del repositorio (`CARGO_TARGET_DIR`).

| Comando | Resultado |
|---|---|
| `cargo fmt --check` | código de salida 0 |
| `cargo clippy --all-targets --locked -- -D warnings` | código de salida 0 |
| `cargo test --locked` | 328 superadas, 0 fallidas, 3 ignoradas: dos necesitan un LibreOffice real, una necesita un usuario que no sea root |
| `cargo test --locked --test invariants` | 36 superadas |
| `python3 scripts/build_bridges.py --check` | código de salida 0 |
| `python3 -m unittest discover -s tests/bridges` | 189 pruebas, OK, 6 omitidas: dos necesitan root, cuatro son solo para Windows |

Para informar de una vulnerabilidad, véase [Acerca del proyecto](https://libremodular.davalan.fr/es/about/index.md#informar-de-una-vulnerabilidad).

