---
title: "Runbook: enjaular al agente de IA — bubblewrap en el cliente, Tetragon en el cluster"
url: https://blog.lo0.es/posts/runbook-aislar-agentes-ia-bubblewrap-tetragon/
markdown: https://blog.lo0.es/posts/runbook-aislar-agentes-ia-bubblewrap-tetragon/index.md
lang: es
date: 2026-06-09
lastmod: 2026-06-12
author: "José Manuel Román"
description: "Procedimiento operativo para aislar agentes de IA en los dos dominios, con comandos que se copian y pegan. En el cliente (workstation): instalar ai-jail + bubblewrap, el fichero .ai-jail por proyecto, el --bootstrap de allowlists de permisos para Claude Code / Codex / OpenCode, el modo --lockdown y la red de seguridad con git sin push. En el cluster (RKE2): el baseline de pod (securityContext, seccomp, NetworkPolicy default-deny), el RuntimeClass Kata para pods no confiables, y las TracingPolicy de Tetragon en dos fases — primero observar (Post) para construir un baseline, luego promover a enforcement (Sigkill) sobre tcp_connect y security_file_open. Cierra con la tabla de equivalencias cliente↔cluster y un checklist de gotchas."
tags: ["seguridad","sandbox","runbook","operativo","ai-jail","bubblewrap","landlock","tetragon","ebpf","kata","runtimeclass","networkpolicy","kubernetes","rke2","on-premise"]
translations:
  en: https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/index.md
---

# Runbook: enjaular al agente de IA — bubblewrap en el cliente, Tetragon en el cluster


> Compañero **operativo** de [El contratista con la llave maestra](https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/index.md). Aquel post explica el *porqué* y el *dónde* —el modelo de amenaza, las cinco familias de aislamiento, qué dominio usa cada una—; este es el *cómo*, con comandos. Si no lo has leído, léelo antes: aquí doy por sabido qué es el radio de explosión, por qué `bwrap` corre sin root y qué vigila Tetragon. El procedimiento va en dos tracks independientes —**cliente** y **cluster**— porque, como argumenta el post hermano, el control se extrapola pero la primitiva se reescribe.

## TL;DR

Dos procedimientos reproducibles. **Cliente (workstation):** instala `ai-jail` (envuelve `bubblewrap`), genera el `.ai-jail` por proyecto, audita con `--dry-run`, fija las allowlists con `--bootstrap`, usa `--lockdown` para lo que no te fíes, y deja al agente sin permiso de `git push`. **Cluster (RKE2 con Cilium + Tetragon):** pon el baseline de pod (`securityContext` sin privilegios, `seccomp: RuntimeDefault`, `NetworkPolicy` default-deny), mete el pod del agente no confiable en una microVM con `runtimeClassName: kata`, y despliega las `TracingPolicy` de Tetragon en **dos fases** —observar con `action: Post` para levantar el baseline, luego promover a `action: Sigkill` sobre `tcp_connect` (egress) y `security_file_open` (rutas de secretos)—. La regla de oro de la fase Tetragon: **adopta primero, bloquea después**; nunca metas un `Sigkill` en producción sin haber visto antes los eventos en modo observación.

## El flujo de los dos tracks

> **Diagrama:** Dos tracks operativos: cliente (instalar, configurar, bootstrap, lockdown) y cluster (baseline, RuntimeClass, observar, enforce) — [verlo en la versión HTML](https://blog.lo0.es/posts/runbook-aislar-agentes-ia-bubblewrap-tetragon/)

---

# Track A — Cliente (workstation del desarrollador)

## A0 — Instalar ai-jail y bubblewrap

`ai-jail` envuelve el sandbox; en Linux necesita `bubblewrap` aparte, en macOS no necesita dependencia extra.

```bash
# ai-jail (macOS y Linux)
brew tap akitaonrails/tap && brew install ai-jail
# o, con cargo:
cargo install ai-jail
# o, con mise:
mise use -g ubi:akitaonrails/ai-jail

# bubblewrap en Linux (elige tu distro)
sudo pacman -S bubblewrap     # Arch
sudo apt install bubblewrap   # Debian / Ubuntu
sudo dnf install bubblewrap   # Fedora
```

Comprueba que el binario está y que `bwrap` corre sin root (no debe pedir `sudo`):

```bash
ai-jail --version
bwrap --ro-bind / / --unshare-all echo "bwrap ok sin root"
```

Si `bwrap` falla pidiendo privilegios, tu kernel tiene los *unprivileged user namespaces* deshabilitados; habilítalos (`sysctl kernel.unprivileged_userns_clone=1` en Debian/Ubuntu antiguos) antes de seguir.

## A1 — El fichero .ai-jail por proyecto

En el primer arranque dentro del proyecto, `ai-jail` crea un `.ai-jail` (TOML) **commiteable al repo**: cualquier compañero que clone hereda la misma política.

```bash
cd ~/Projects/mi-app
ai-jail claude        # crea .ai-jail y lanza Claude Code dentro del sandbox
```

El fichero generado:

```toml
# .ai-jail — configuración del sandbox (commitéalo al repo)
command = ["claude"]
rw_maps = []          # directorios extra con escritura
ro_maps = []          # directorios extra de solo lectura
```

**Antes de confiar en el sandbox, audítalo.** `--dry-run --verbose` imprime cada punto de montaje, cada flag de aislamiento y el comando `bwrap` completo, sin ejecutar nada:

```bash
ai-jail --dry-run --verbose claude
```

Lee la salida y confirma tres cosas: que `$HOME` se monta como tmpfs (no el real), que `~/.ssh`, `~/.aws` y `~/.gnupg` **no aparecen** entre los montajes, y que el único directorio con escritura es el del proyecto. Si necesitas un directorio extra:

```bash
ai-jail --rw-map ~/Projects/shared-lib claude   # extra con escritura
ai-jail --map /opt/datasets claude              # extra de solo lectura
```

Otros agentes, mismo binario:

```bash
ai-jail codex
ai-jail opencode
ai-jail bash                # shell pelado para depurar el sandbox
ai-jail -- python script.py # cualquier comando
```

## A2 — Las allowlists de permisos con --bootstrap

`--bootstrap` genera las configuraciones de permisos de cada agente, con allow/deny/ask sensatos, y hace backup antes de sobrescribir:

```bash
ai-jail --bootstrap
```

Lo que produce, en resumen:

| Agente | Fichero | Política base |
|---|---|---|
| Claude Code | `~/.claude/settings.json` | **allow**: `git status/diff/log`, `ls`, `grep`, `cargo`, `npm`, `python`, `docker compose` · **ask**: `git push`, `rm`, `docker run` · **deny**: `rm -rf`, `sudo`, `chmod 777`, `git push --force` |
| Codex | `~/.codex/config.toml` | `approval_policy = "on-request"` |
| OpenCode | `~/.config/opencode/opencode.json` | permisos de `bash`, `edit`, `write` |

La clave operativa: `git push` está en **ask**, no en **allow**, y `git push --force` en **deny**. El agente puede commitear, ramear y rebasar localmente cuanto quiera; nada de eso toca el remoto. (Si usas el `/sandbox` de Claude Code, fija además `"allowUnsandboxedCommands": false` para cerrar el *escape hatch* `dangerouslyDisableSandbox`, que de fábrica es opt-out.)

## A3 — Lockdown para lo que no te fíes

Para auditar código de terceros o correr un agente sobre un proyecto que no conoces, `--lockdown` va más allá: proyecto montado en **read-only**, GPU/Docker/display deshabilitados, `--rw-map`/`--map` ignorados, `$HOME` tmpfs puro sin dotfiles del host, red cortada con `--unshare-net` y environment limpiado con `--clearenv`.

```bash
ai-jail --lockdown bash
```

Es el sandbox más restrictivo posible sin llegar a una VM. Úsalo como defecto mental para todo lo que no sea tu propio código en tu propia máquina.

## A4 — La red de seguridad: git sin push

No es un flag, es una propiedad del entorno que cambia el cálculo de riesgo. Si el proyecto está en git con remoto, y el agente **no** tiene permiso de `push`, el peor caso —que corrompa cada fichero del proyecto— se revierte con:

```bash
git checkout .              # vuelve al último commit
# y si tocó .git (improbable): borra el dir y re-clona
```

El remoto nunca se tocó. **Sandbox para el filesystem + git para el código + push manual** es ya un nivel razonable para uso diario: `ai-jail` protege tus datos y el sistema, git protege el código, y la decisión de publicar sigue siendo tuya.

---

# Track B — Cluster (RKE2 con Cilium + Tetragon)

El agente no confiable —o la inferencia que ejecuta código generado— corre como pod. El mismo principio del cliente, otras primitivas. Asumimos un cluster genérico RKE2 con Cilium como CNI y Tetragon ya desplegado (el `DaemonSet` del agente eBPF en cada nodo).

## B0 — El baseline del pod

Antes de cualquier eBPF, lo de serie. `securityContext` sin privilegios, raíz read-only, seccomp por defecto:

```yaml
apiVersion: v1
kind: Pod
metadata:
  name: ai-agent
  namespace: agentes
  labels:
    app: ai-agent
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: agent
    image: registry.interno/ai-agent:pinned
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    volumeMounts:
    - { name: work, mountPath: /work }   # único escribible
  volumes:
  - name: work
    emptyDir: {}
```

Y el corte de egress por defecto —el gemelo cluster del `--unshare-net`—. NetworkPolicy default-deny de salida en el namespace, abriendo solo DNS y lo imprescindible:

```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-egress
  namespace: agentes
spec:
  podSelector: {}
  policyTypes: ["Egress"]
  egress:
  - to:
    - namespaceSelector:
        matchLabels: { kubernetes.io/metadata.name: kube-system }
    ports:
    - { protocol: UDP, port: 53 }
    - { protocol: TCP, port: 53 }
```

## B1 — RuntimeClass Kata: el pod no confiable en su propia microVM

Para código realmente no confiable, sácalo del kernel compartido. Con Kata desplegado existe un `RuntimeClass`:

```yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: kata
handler: kata
```

Y el pod lo pide con una línea —`runtimeClassName: kata`—, ejecutándose en su propia microVM con kernel dedicado en lugar de compartir el del nodo:

```yaml
spec:
  runtimeClassName: kata     # ← el pod corre en una microVM, no en el kernel del nodo
  # ...resto igual que B0
```

Es el gemelo cluster del aislamiento por construcción: un exploit de kernel dentro del pod no alcanza al nodo.

## B2 — Tetragon, fase observación (Post)

Ahora la capa que distingue una plataforma con visibilidad de runtime. **Primero observar, nunca matar de entrada.** Una `TracingPolicyNamespaced` —scoped al namespace y a la etiqueta del agente— que reporta (no mata) tres cosas: ejecuciones de proceso, conexiones de red y aperturas de rutas sensibles. `action: Post` solo emite el evento.

```yaml
apiVersion: cilium.io/v1alpha1
kind: TracingPolicyNamespaced
metadata:
  name: agente-observa
  namespace: agentes
spec:
  podSelector:
    matchLabels:
      app: ai-agent
  kprobes:
  # --- conexiones salientes ---
  - call: "tcp_connect"
    syscall: false
    args:
    - index: 0
      type: "sock"
    selectors:
    - matchActions:
      - action: Post
  # --- aperturas de ficheros sensibles ---
  - call: "security_file_open"
    syscall: false
    args:
    - index: 0
      type: "file"
    selectors:
    - matchArgs:
      - index: 0
        operator: "Prefix"
        values:
        - "/var/run/secrets"
        - "/work/.git/config"
      matchActions:
      - action: Post
```

(Las ejecuciones de proceso no necesitan kprobe: Tetragon emite `process_exec`/`process_exit` de forma nativa.) Despliega y observa los eventos en vivo desde el pod de Tetragon del nodo:

```bash
kubectl apply -f agente-observa.yaml
# eventos legibles, filtrando por el namespace:
kubectl exec -n kube-system ds/tetragon -c tetragon -- \
  tetra getevents -o compact --namespace agentes
```

Deja esto rodando una jornada típica del agente. Apunta a **qué destinos** conecta de verdad (tu registry interno, tu mirror de HF, tu endpoint de vLLM) y **qué rutas** abre. Eso es tu baseline: la lista de lo legítimo. Sin este paso, un `Sigkill` mata trabajo bueno y te genera un incidente de disponibilidad —justo lo que el ENS te pide evitar—.

## B3 — Tetragon, fase enforcement (Sigkill)

Con el baseline en la mano, promueve a bloqueo. Dos reglas. La primera: **mata cualquier conexión cuyo destino no esté en la allowlist** —`NotDAddr` invierte el match: dispara para todo lo que *no* sea esas redes—. La segunda: **mata cualquier intento de abrir una ruta de secretos**.

```yaml
apiVersion: cilium.io/v1alpha1
kind: TracingPolicyNamespaced
metadata:
  name: agente-enforce
  namespace: agentes
spec:
  podSelector:
    matchLabels:
      app: ai-agent
  kprobes:
  # --- egress: mata todo lo que NO sea la allowlist ---
  - call: "tcp_connect"
    syscall: false
    args:
    - index: 0
      type: "sock"
    selectors:
    - matchArgs:
      - index: 0
        operator: "NotDAddr"
        values:
        - "127.0.0.1"
        - "10.0.0.0/8"          # red interna del cluster
        - "172.16.10.20"        # registry interno (ejemplo)
      matchActions:
      - action: Sigkill
  # --- lectura de secretos: mata el proceso ---
  - call: "security_file_open"
    syscall: false
    args:
    - index: 0
      type: "file"
    selectors:
    - matchArgs:
      - index: 0
        operator: "Prefix"
        values:
        - "/var/run/secrets/kubernetes.io/serviceaccount/token"
        - "/work/.ssh"
      matchActions:
      - action: Sigkill
```

```bash
kubectl apply -f agente-enforce.yaml
```

Ahora el agente puede hacer lo que quiera dentro del pod, pero **en el instante** en que intenta conectar a un destino no permitido o leer el token de la service account, Tetragon lo mata en el kernel —antes de que el paquete salga o el `read` devuelva bytes—. Es el gemelo cluster de la blocklist de `curl` y del `~/.ssh` no montado, pero aplicado en runtime y sobre *cualquier* binario, no solo los que conoces.

> **Aviso operativo.** El enforcement con `Sigkill` requiere kernel reciente con soporte de la acción en eBPF (5.10+ es seguro). Despliega `agente-enforce` primero en un namespace de pruebas, y mantén `agente-observa` activo en paralelo: si el bloqueo dispara, el evento `Post` te dice exactamente qué lo provocó. Adopta primero, bloquea después.

## La tabla de equivalencias cliente ↔ cluster

El mismo vector, las dos primitivas. Esto es "extrapolar la tecnología" hecho explícito:

| Vector de amenaza | Cliente (workstation) | Cluster (RKE2) |
|---|---|---|
| `$HOME` / raíz escribible | `$HOME` como tmpfs efímero (`bwrap`) | `readOnlyRootFilesystem: true` + `emptyDir` |
| Egress arbitrario | blocklist `curl`/`wget` · `--unshare-net` | NetworkPolicy default-deny + Tetragon `NotDAddr`→`Sigkill` |
| Lectura de secretos | `~/.ssh`/`~/.aws`/`~/.gnupg` no montados | secretos fuera del pod + Tetragon `security_file_open`→`Sigkill` |
| Escape del kernel | Landlock (2ª barrera VFS) | `runtimeClassName: kata` (microVM, kernel propio) |
| Sin escape hatch | proceso dentro de `bwrap`, sin salida | sin `privileged`, `drop ALL`, `allowPrivilegeEscalation:false` |
| Daño al código | git remoto sin `push` → `git checkout .` | GitOps + revisión de PR, el agente no aplica a `main` |
| Visibilidad | `--dry-run --verbose` (estático, pre-run) | Tetragon `tetra getevents` (dinámico, en runtime) |

## Checklist de gotchas

- **No metas un `Sigkill` sin pasar por `Post`.** El baseline de observación no es opcional: es lo que separa "bloquear un C2" de "tirar tu propio job de fine-tuning".
- **El `.ai-jail` se commitea; los secretos no.** El TOML es política, no credenciales. Verifica que no metes rutas con datos sensibles en `rw_maps`.
- **`readOnlyRootFilesystem` rompe apps que escriben en `/tmp`.** Monta un `emptyDir` en `/tmp` además del de trabajo.
- **NetworkPolicy sin regla de DNS deja al pod ciego.** Abre el puerto 53 a `kube-system` o nada resuelve.
- **Kata no es gratis.** Añade latencia de arranque y no todo workload con dispositivos especiales (GPU passthrough) encaja; resérvalo para lo no confiable, no para todo.
- **El `/sandbox` de Claude Code no cubre MCP ni hooks** salvo que actives `sandbox-runtime`. Si tu agente usa servidores MCP, asume que corren con permisos completos hasta que lo hagas.
- **`NotDAddr` con IPs literales envejece mal.** Documenta la allowlist y revísala cuando cambie el registry o el endpoint de inferencia; considera CIDRs internos estables en vez de IPs sueltas.

## Ver también

- [Hardening y secretos del stack LLM soberano: defensa en profundidad](https://blog.lo0.es/posts/hardening-secretos-stack-llm-soberano/index.md) — el hardening del cluster completo más allá del aislamiento del agente.

- [El contratista con la llave maestra: aislar agentes de IA del workstation al cluster](https://blog.lo0.es/posts/aislar-agentes-ia-cliente-cluster/index.md) — el panorama que este runbook ejecuta: modelo de amenaza, las cinco familias de aislamiento y por qué cliente y cluster usan primitivas distintas.
- [La puerta de la cocina que el maître no miró: Cilium eBPF y DRANET](https://blog.lo0.es/posts/cilium-ebpf-dranet-numa-de-red-inferencia/index.md) — la capa eBPF de Cilium sobre la que Tetragon engancha sus kprobes; el datapath que ya tienes en el cluster.
- [Controles técnicos: ENS × ISO 42001 × EU AI Act](https://blog.lo0.es/posts/controles-tecnicos-ens-42001-eu-ai-act/index.md) — los eventos de Tetragon como evidencia técnica de `op.mon`/`op.exp`; el enforcement como medida de protección.
- [Guardrails y safety en LLM](https://blog.lo0.es/posts/guardrails-safety-llm/index.md) — la mitigación en el plano del contenido; este runbook, la del plano de la ejecución.
- [Siete fases de despliegue de una plataforma LLM on-premise](https://blog.lo0.es/posts/siete-fases-despliegue-plataforma-llm-on-premise/index.md) — dónde encaja el endurecimiento de runtime en la secuencia de despliegue (F4 identidad/políticas, F5 plataforma).

## Referencias

- ai-jail (Fabio Akita), GPL-3.0: <https://github.com/akitaonrails/ai-jail>
- bubblewrap: <https://github.com/containers/bubblewrap>
- Landlock LSM: <https://landlock.io>
- Tetragon — TracingPolicy: <https://tetragon.io/docs/concepts/tracing-policy/>
- Tetragon — enforcement (Sigkill/Override): <https://tetragon.io/docs/concepts/enforcement/>
- Kata Containers — Kubernetes RuntimeClass: <https://katacontainers.io>
- Kubernetes — Pod Security & seccomp: <https://kubernetes.io/docs/tutorials/security/seccomp/>
- Kubernetes — Network Policies: <https://kubernetes.io/docs/concepts/services-networking/network-policies/>
- Cilium: <https://cilium.io>

