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

Compañero operativo de El contratista con la llave maestra. 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

Track CLIENTE — workstation1 · Instalarai-jail + bwrap2 · .ai-jail--dry-run3 · --bootstrapallow/deny/ask4 · lockdown+ git sin pushTrack CLUSTER — RKE2 + Cilium/Tetragon1 · BaselinesecCtx+NetPol2 · RuntimeClasskata microVM3 · ObservarTetragon · Post4 · EnforceTetragon · Sigkilladopta primero (observar), bloquea después (enforce)

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.

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

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.

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

El fichero generado:

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

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:

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:

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:

ai-jail --bootstrap

Lo que produce, en resumen:

AgenteFicheroPolítica base
Claude Code~/.claude/settings.jsonallow: 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.tomlapproval_policy = "on-request"
OpenCode~/.config/opencode/opencode.jsonpermisos 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.

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:

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:

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:

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:

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:

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.

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:

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 allowlistNotDAddr invierte el match: dispara para todo lo que no sea esas redes—. La segunda: mata cualquier intento de abrir una ruta de secretos.

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
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 amenazaCliente (workstation)Cluster (RKE2)
$HOME / raíz escribible$HOME como tmpfs efímero (bwrap)readOnlyRootFilesystem: true + emptyDir
Egress arbitrarioblocklist curl/wget · --unshare-netNetworkPolicy default-deny + Tetragon NotDAddrSigkill
Lectura de secretos~/.ssh/~/.aws/~/.gnupg no montadossecretos fuera del pod + Tetragon security_file_openSigkill
Escape del kernelLandlock (2ª barrera VFS)runtimeClassName: kata (microVM, kernel propio)
Sin escape hatchproceso dentro de bwrap, sin salidasin privileged, drop ALL, allowPrivilegeEscalation:false
Daño al códigogit remoto sin pushgit 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

Referencias