Seccomp-bpf para Agentes Autónomos
La primera vez que un agente nuestro borró algo que no debía fue un martes a las 21:47. Recuerdo la hora porque la alerta de disco lleno entró justo cuando estaba apagando la pantalla. El agente, en mitad de una cadena de razonamiento sobre un CTF de escalada, había decidido que la mejor forma de "limpiar artefactos temporales" era ejecutar find / -name "*.tmp" -delete. No tocó nada crítico porque el contenedor estaba aislado, pero sí dejó inservible la imagen base que usábamos para los runners de fuzzing. Tuvimos que reconstruirla a la mañana siguiente.
Aquella noche escribí en el cuaderno una frase que sigue pegada al monitor: "un LLM no entiende lo que significa irreversible". A partir de ahí nació el modelo de sandboxing que hoy usa Gwaihir CLI, nuestro motor de ejecución. Y la pieza que más nos costó afinar fue seccomp-bpf.
Por qué seccomp-bpf y no otra cosa
La pregunta razonable es: si Docker, gVisor, Firecracker y Kata existen, ¿por qué meternos a escribir filtros BPF a mano? La respuesta corta es que ninguno encajaba en el flujo de un agente que dispara herramientas ofensivas a ráfagas.
Docker en modo runc comparte kernel con el host, y su perfil seccomp por defecto deja pasar más de 300 syscalls. Es razonable para un servicio web, no para algo que decide en tiempo de ejecución llamar a ptrace o kexec_load. Firecracker (Agache et al., 2020) nos daba una microVM por agente, pero el coste de arrancar una VM por cada invocación de nmap -sS rompía nuestro presupuesto de latencia: 125 ms vs 4 ms de un fork+exec con seccomp aplicado.
gVisor era el candidato fuerte. Su Sentry intercepta syscalls en espacio de usuario y reduce muchísimo la superficie del kernel. Pero el estudio de Young et al. en HotCloud 19 ya midió lo que después confirmamos en nuestros bancos: aperturas de archivos 216 veces más lentas, syscalls 2,2 veces más lentas. Cuando un agente lanza ffuf con 200 hilos contra un endpoint, esa penalización se nota.
Seccomp-bpf, en cambio, es casi gratis. El filtro se compila a BPF, vive en el descriptor del proceso, y el coste por syscall es del orden de decenas de nanosegundos. Y lo más importante: lo controlamos nosotros, regla a regla. Como bien dice la documentación del kernel, "no es un sandbox por sí mismo, es una herramienta para que los desarrolladores de sandboxes la usen"[1]. Eso es exactamente lo que necesitábamos.
Perfiles mínimos por herramienta
El modelo es por herramienta, no por agente. Cuando Gwaihir decide ejecutar nmap, no carga el perfil de sqlmap. Cada binario que el agente puede invocar tiene su propio fichero .scprofile que describe el conjunto mínimo de syscalls observado en una traza con strace -c ampliada con varias pasadas de uso real.
El proceso de construcción es aburrido y por eso funciona. Lanzamos la herramienta contra un objetivo de laboratorio mientras corre perf trace, recogemos la lista de syscalls, la cruzamos con la documentación, y descartamos todo lo que sea claramente peligroso aunque aparezca (por ejemplo, nmap jamás debería necesitar unshare o mount). Lo que queda es la baseline.
Para nmap, en un escaneo TCP SYN típico, la lista cabe en una cuartilla. Para sqlmap es más larga porque el intérprete Python pide más cosas. Para ffuf, escrito en Go, hay sorpresas: el runtime de Go llama a rt_sigaction y mmap en cantidades industriales, y olvidarse de uno cuelga el proceso sin diagnóstico claro.
El protocolo de negociación con Sentinel
Aquí es donde la cosa se pone interesante para los agentes. Un perfil estático no basta. Si el LLM decide a media ejecución que necesita resolver DNS para enriquecer un hallazgo, va a llamar a socket(AF_NETLINK, ...), que no está en el perfil de nmap. Sin más mecanismo, el proceso recibe SIGSYS y muere.
Lo que hicimos fue meter un canal de notificación de seccomp (SECCOMP_RET_USER_NOTIF, disponible desde el kernel 5.0[2]) entre el proceso hijo y un supervisor que llamamos Sentinel. Cuando el filtro encuentra una syscall marcada como "negociable", suspende el proceso y manda la petición a Sentinel. Sentinel evalúa contra una política declarativa, opcionalmente consulta al modelo con un prompt corto del tipo "¿este connect() a 10.10.0.0/8 está justificado por la tarea actual?", y devuelve continuar o abortar.
Es un patrón parecido al de AgentBound (Securing AI Agent Execution, 2025), pero al nivel del kernel en vez de en MCP. La diferencia importante es que el agente no puede saltarse a Sentinel: el filtro BPF se carga con NO_NEW_PRIVS antes del execve, así que ni siquiera un binario malicioso puede deshacerlo.
Un filtro real
Este es un extracto simplificado del perfil que usamos para nmap, escrito con libseccomp en Rust (el runtime de Gwaihir pesa 13,2 MB y está hecho con una mezcla de Rust para la lógica y Zig para los hot paths de tracing).
use libseccomp::*;
fn build_nmap_profile() -> Result {
// Acción por defecto: SIGSYS y traza al supervisor.
let mut ctx = ScmpFilterContext::new(ScmpAction::Trap)?;
// I/O básico
for sc in &["read", "write", "close", "fstat", "lseek",
"openat", "pread64", "pwrite64"] {
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
}
// Memoria
for sc in &["mmap", "munmap", "mprotect", "brk", "madvise"] {
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name(sc)?)?;
}
// Red: solo lo que nmap necesita para SYN scan
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("socket")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("setsockopt")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("sendto")?)?;
ctx.add_rule(ScmpAction::Allow, ScmpSyscall::from_name("recvfrom")?)?;
// Negociables: el agente puede pedirlas vía Sentinel.
ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("connect")?)?;
ctx.add_rule(ScmpAction::Notify, ScmpSyscall::from_name("execve")?)?;
// Prohibidas explícitamente.
for sc in &["ptrace", "kexec_load", "init_module",
"delete_module", "reboot", "mount", "unshare"] {
ctx.add_rule(ScmpAction::KillProcess, ScmpSyscall::from_name(sc)?)?;
}
ctx.set_filter_attr(ScmpFilterAttr::CtlNnp, 1)?;
Ok(ctx)
}
Tres detalles que importan. Primero, la acción por defecto es SCMP_ACT_TRAP, no SCMP_ACT_KILL. El motivo es diagnóstico: queremos que el handler de SIGSYS capture la syscall y la registre antes de que el proceso muera. Segundo, las syscalls negociables usan SCMP_ACT_NOTIFY, no ALLOW; eso es lo que abre el canal con Sentinel. Tercero, las prohibidas tienen KILL_PROCESS explícito aunque ya estuvieran cubiertas por el default, porque queremos que esa decisión sea legible en una auditoría futura.
Trade-offs que descubrimos a golpes
El primero es portabilidad. Los números de syscall cambian entre arquitecturas (x86_64, aarch64, riscv64) y libseccomp lo abstrae bastante bien, pero hay edge cases. socketcall en 32 bits multiplexado, arch_prctl que solo existe en x86, statx que apareció en kernels recientes. Mantenemos una matriz de pruebas por arquitectura.
El segundo es debugging. Cuando un perfil mata un proceso por un syscall que no estaba en la lista, el mensaje que ves es Bad system call (core dumped). Sin más. Tuvimos que escribir un wrapper que parsea el siginfo_t de SIGSYS y reporta el número de syscall, lo cruza con la tabla, y muestra algo legible. La primera vez que vimos "statx blocked by profile nmap.scprofile, suggest adding to baseline" en lugar de un core dump, casi lloramos de alivio.
El tercero son los falsos positivos. Cuando una libc actualiza y empieza a usar clone3 en vez de clone, todos tus perfiles se rompen a la vez. Lo aprendimos cuando glibc 2.34 entró en una de las imágenes base. Ahora tenemos un job semanal que regenera baselines y nos avisa si la diff es sospechosamente grande.
El cuarto, y el que más nos sorprendió, es el overhead de los filtros muy grandes. BPF se ejecuta en cada syscall, y un filtro con cientos de reglas en cascada se nota. Medimos un 3-4% de penalización en cargas intensivas en syscalls con un perfil de 180 reglas, frente a un 0,8% con uno de 40. Reordenar las reglas para que las syscalls frecuentes se evalúen primero recuperó la mayor parte del tiempo.
Lo que nos hubiera ahorrado tiempo saber antes
Tres cosas. La primera: empezad por SCMP_ACT_LOG en lugar de TRAP durante el desarrollo del perfil. Logueas todo, ejecutas la herramienta contra cargas reales un día entero, y luego construyes la lista. Nosotros hicimos el camino al revés y perdimos semanas persiguiendo crashes.
La segunda: la notificación de usuario (USER_NOTIF) es lo que convierte seccomp en algo útil para agentes. Sin ella, un perfil estático es un corsé. Con ella, tienes política dinámica sin sacrificar la separación de privilegios.
La tercera: medid el coste real desde el día uno. Los benchmarks publicados son útiles pero vuestras cargas son raras. Un agente que llama 50 veces por segundo a una herramienta corta tiene un perfil de coste muy distinto al de un servidor de larga duración.
El filtro no protege contra todo. Protege contra lo estúpido. Y resulta que lo estúpido es el 90% de lo que un LLM hace mal cuando lo dejas suelto.
Hoy Gwaihir ejecuta exploits propuestos por modelos sin que ninguno de nosotros mire la pantalla esperando una catástrofe. No porque confiemos en el modelo, sino porque confiamos en el filtro. Y eso, después del martes 21:47, es un cambio de vida.
Referencias
- Linux Kernel Documentation (2024). Seccomp BPF (SECure COMPuting with filters).
- Drysdale, M. y Corbet, J. (2024). seccomp(2) - Linux manual page, man7.org.
- Young, E. et al. (2019). The True Cost of Containing: A gVisor Case Study. USENIX HotCloud 19.
- Agache, A. et al. (2020). Firecracker: Lightweight Virtualization for Serverless Applications. USENIX NSDI 20.
- seccomp/libseccomp (2024). libseccomp: Enhanced Linux seccomp library.