Guardrails para agentes: probé la blocklist típica y pasan 16 de 20
La primera semana que le das a un agente acceso a tu terminal te sientes invencible.
Le pides que instale una dependencia, ejecute los tests, cree una rama y arregle un bug. Y lo hace, mientras tú haces otra cosa.
Después llega la pregunta incómoda: ¿qué pasa exactamente si se equivoca?
Casi todo el mundo responde igual. Ha metido en el System Prompt una frase del tipo "por favor, nunca ejecutes comandos destructivos", y con eso duerme tranquilo. Eso no es una barrera: un modelo es probabilístico y esa frase compite con todo lo demás que hay en el contexto. Que el LLM no puede ser su propia barrera de seguridad ya lo desarrollé en guardrails y tácticas defensivas contra inyección de prompts, así que aquí lo doy por sabido.
Este post va del siguiente paso, el que casi nadie audita: el que sí escribió código de defensa y cree que con eso está cubierto.
Porque hay un guardrail concreto que escribimos todos, que parece serio, que da mucha tranquilidad y que no aguanta ni una reordenación de flags. Vamos a romperlo con un test que puedes ejecutar tú.
El guardrail que todos escribimos
Es este, con pequeñas variaciones. Una lista de patrones peligrosos y un interceptor delante del ejecutor de herramientas:
const FORBIDDEN_PATTERNS = [
/rm\s+(-rf|-fr|\*)/i,
/git\s+push\s+.*(--force|-f)/i,
/git\s+reset\s+--hard/i,
/drop\s+table/i,
/chmod\s+777/i,
/curl\s+.*\|\s*(bash|sh)/i,
];
const blocked = (cmd: string) => FORBIDDEN_PATTERNS.some(p => p.test(cmd));
Tiene buena pinta. Cubre el rm -rf de los titulares, el force push, el DROP TABLE y el clásico curl | bash.
Ahora vamos a medirlo.
El test: 16 de 20 comandos destructivos pasan
Le pasé a ese filtro 20 comandos que ningún agente debería poder ejecutar sobre tu máquina. Este es el resultado completo:
| Comando | ¿Lo detiene? |
|---|---|
rm -rf / |
Bloqueado |
rm -r -f / |
Pasa |
rm -Rf ~/proyecto |
Bloqueado |
rm --recursive --force / |
Pasa |
rm -f -r . |
Pasa |
find . -delete |
Pasa |
find . -exec rm {} + |
Pasa |
git clean -fdx |
Pasa |
> package.json |
Pasa |
cat /dev/null > .env |
Pasa |
dd if=/dev/zero of=/dev/sda |
Pasa |
git reset --hard HEAD~5 |
Bloqueado |
DROP TABLE users |
Bloqueado |
drop/**/table users |
Pasa |
TRUNCATE TABLE users |
Pasa |
DELETE FROM users |
Pasa |
mv proyecto /dev/null |
Pasa |
$(echo rm) -rf / |
Pasa |
npm run deploy:prod |
Pasa |
chmod -R 777 / |
Pasa |
Cuatro bloqueados, dieciséis dentro. Y fíjate en la segunda fila, porque resume el problema entero:
rm -rf / está bloqueado. rm -r -f / pasa.
Es el mismo comando. Borra exactamente lo mismo. Lo único que cambia es que los flags van separados, y el modelo no necesita saber que existe un filtro para escribirlo así: es una forma perfectamente normal de escribir ese comando.
La última fila es igual de reveladora. El patrón /chmod\s+777/ espera que el 777 venga justo detrás de chmod, así que chmod -R 777 / —que es peor, porque es recursivo— no lo toca.
Aquí tienes el test entero para que lo corras contra tu propia lista antes de seguir leyendo:
const destructivos = [
"rm -rf /", "rm -r -f /", "rm -Rf ~/proyecto", "rm --recursive --force /",
"rm -f -r .", "find . -delete", "find . -exec rm {} +", "git clean -fdx",
"> package.json", "cat /dev/null > .env", "dd if=/dev/zero of=/dev/sda",
"git reset --hard HEAD~5", "DROP TABLE users", "drop/**/table users",
"TRUNCATE TABLE users", "DELETE FROM users", "mv proyecto /dev/null",
"$(echo rm) -rf /", "npm run deploy:prod", "chmod -R 777 /",
];
const pasan = destructivos.filter(c => !blocked(c));
console.log(`${pasan.length}/${destructivos.length} pasan el filtro`);
console.log(pasan);
Y hay un segundo efecto, menos grave pero muy revelador: el patrón del force push bloquea git push --force-with-lease, que es precisamente la variante segura. Una blocklist no solo deja pasar lo peligroso; también prohíbe cosas correctas, y eso es lo que te acaba empujando a desactivarla.
El fallo no son los patrones. Es la arquitectura
La tentación, al ver esa tabla, es añadir patrones. Meter -r -f, meter find, meter TRUNCATE.
No sirve. Puedes pasarte una tarde ampliando la lista y mañana el agente encontrará la forma número veintidós, porque una shell tiene infinitas maneras de expresar la misma destrucción: flags separados, flags largos, alias, sustitución de comandos, redirecciones, herramientas distintas que hacen lo mismo.
Esto tiene nombre desde hace décadas en seguridad: enumerating badness, enumerar lo malo. Y siempre pierde, porque el conjunto de lo peligroso es infinito y el de lo permitido es finito.
La inversión es la solución completa:
BLOCKLIST ALLOWLIST
───────── ─────────
permite por defecto deniega por defecto
enumera lo malo enumera lo bueno
conjunto infinito conjunto finito
falla abierta falla cerrada
Con una allowlist, el comando número veintidós que no habías previsto no se ejecuta, porque no está en la lista. Ese es el único diseño en el que un olvido tuyo no se convierte en un incidente.
El chequeo de rutas también se cae
El mismo middleware suele traer una validación de rutas parecida a esta:
if (path.startsWith("/") || path.includes("..")) return BLOQUEADO;
Falla en las dos direcciones. Estas rutas pasan:
C:\Windows\System32— una ruta absoluta de Windows no empieza por/.~/.ssh/id_rsa— la expande la shell después de tu comprobación.
Y a la vez bloquea src/../lib/x.ts, que es una ruta legítima dentro del proyecto.
El arreglo es no razonar sobre el texto de la ruta, sino resolverla y comprobar dónde acaba:
import path from "node:path";
const ROOT = path.resolve(process.env.AGENT_WORKSPACE!);
export function dentroDelWorkspace(candidata: string): boolean {
const destino = path.resolve(ROOT, candidata);
return destino === ROOT || destino.startsWith(ROOT + path.sep);
}
Con eso, ../../etc/passwd y /etc/passwd quedan fuera —los dos resuelven a un destino que no cuelga de ROOT— mientras que src/../lib/x.ts entra sin problema. La comprobación deja de depender de cómo esté escrita la ruta.
Un aviso: si tu agente puede crear enlaces simbólicos, resuélvelos también (fs.realpath) antes de comparar. Un symlink dentro del workspace apuntando fuera se salta la comprobación de arriba.
Las 4 capas que sí sostienen la capa de ejecución
En orden, de más a menos importante.
1. Allowlist de comandos, denegar por defecto
Define qué puede ejecutar el agente, no qué no puede. Empieza por lo que de verdad necesita a diario —tests, linter, build, git status, git diff— y ve añadiendo cuando algo se bloquee de forma legítima.
La forma práctica de arrancar: registra durante una semana todo lo que el agente intenta ejecutar sin bloquear nada, y monta la allowlist a partir de esa lista real. Casi siempre son menos de treinta comandos.
Si usas un agente CLI, esto normalmente ya existe en su configuración: reglas de permiso allow / deny / ask y modos de permisos. Revisa el tuyo con una pregunta concreta: ¿hay alguna regla comodín tipo Bash(*) que anule a todas las demás? Si la hay, tu allowlist es decorativa.
2. Confinamiento por ruta resuelta
El agente trabaja dentro de un directorio y solo dentro de él. Con la función de arriba, y aplicada a todas las herramientas que tocan disco: leer, escribir, mover y borrar. Confinar solo la escritura deja abierta la exfiltración de .env y de tus claves.
3. Puerta humana para lo irreversible
Lo que no se puede deshacer no se automatiza: git push, migraciones, escrituras en base de datos de producción, despliegues, borrados. El criterio no es "peligroso" sino "¿puedo revertirlo en un minuto?". Es el mismo principio de mínimo privilegio que desarrollé al hablar de inyección indirecta de prompts en agentes, aplicado aquí a la shell.
Montar esa puerta bien tiene su propia arquitectura —clasificar las tools por riesgo, persistir el estado mientras se espera y no ejecutar dos veces al reanudar—, y la desarrollo en arquitectura human in the loop en TypeScript.
4. Aislamiento: que el radio del fallo sea pequeño
Las tres capas anteriores fallan alguna vez. La cuarta decide cuánto duele.
Dale a cada tarea su propia rama y su propio directorio de trabajo, y un contenedor cuando la tarea toque dependencias o servicios. Si algo sale mal, borras el directorio y no has perdido nada. Cómo montar el aislamiento fuerte con contenedores lo detallé en Docker sandboxing para ejecutar código de IA.
El middleware corregido
Juntando las piezas, el interceptor queda así:
import path from "node:path";
const COMANDOS_PERMITIDOS = new Set([
"npm", "pnpm", "bun", "node", "tsc", "eslint", "prettier", "vitest", "jest",
]);
const SUBCOMANDOS_GIT = new Set(["status", "diff", "log", "add", "commit", "branch", "checkout"]);
const IRREVERSIBLES = new Set(["push", "reset", "clean", "rebase"]);
const ROOT = path.resolve(process.env.AGENT_WORKSPACE!);
type Decision =
| { tipo: "ejecutar" }
| { tipo: "preguntar"; motivo: string }
| { tipo: "denegar"; motivo: string };
export function decidir(argv: string[], rutas: string[] = []): Decision {
for (const r of rutas) {
const destino = path.resolve(ROOT, r);
if (destino !== ROOT && !destino.startsWith(ROOT + path.sep)) {
return { tipo: "denegar", motivo: `La ruta "${r}" queda fuera del workspace.` };
}
}
const [binario, sub] = argv;
if (binario === "git") {
if (IRREVERSIBLES.has(sub)) return { tipo: "preguntar", motivo: `git ${sub} no es reversible.` };
if (SUBCOMANDOS_GIT.has(sub)) return { tipo: "ejecutar" };
return { tipo: "denegar", motivo: `git ${sub} no está en la allowlist.` };
}
if (COMANDOS_PERMITIDOS.has(binario)) return { tipo: "ejecutar" };
return { tipo: "denegar", motivo: `"${binario}" no está en la allowlist.` };
}
Tres detalles que hacen que esto funcione y la versión anterior no:
Recibe argv, no un string. Nada de analizar una línea de shell con expresiones regulares. Si construyes el comando como array de argumentos y lo ejecutas sin shell (execFile en lugar de exec), desaparecen de golpe la sustitución de comandos, las redirecciones y el encadenado con ; o &&. La mitad de las evasiones de la tabla de arriba dejan de existir.
Devuelve tres estados, no un booleano. ejecutar, preguntar y denegar. Sin el estado intermedio acabas ampliando la allowlist con cosas irreversibles solo para no tener que confirmar cada vez.
Deniega por defecto. El return final es una denegación. Lo que no previste no se ejecuta.
Y cuando bloquees, devuélvele al agente el motivo en texto, no una excepción: el modelo lo lee y busca otra vía en lugar de dejar la tarea a medias.
Para los parámetros estructurados que llegan a una herramienta o a la base de datos, la validación de schema con Zod es la pieza que cierra el círculo, y los patrones de contrato están en el curso de Zod para TypeScript. El criterio general de dónde poner las validaciones —y dónde no— lo tienes en programación defensiva en TypeScript.
El guardrail más barato: acotar antes de empezar
Todo lo anterior actúa cuando el agente ya está trabajando. Es más barato reducir lo que puede intentar.
Cuando escribes un spec.md que fija qué archivos entran en la tarea y qué queda fuera, el agente deja de tener motivos para acercarse al resto del repositorio. No sustituye a los guardrails —una especificación no es un control de seguridad— pero baja mucho la frecuencia con la que se activan.
La metodología completa está en el libro de Spec-Driven Development, y el flujo práctico con agentes CLI en el curso Construye con IA: de la idea al producto con Claude Code.
Checklist para esta semana
- Corre el test de arriba contra tu propia blocklist. Diez minutos. Si pasa más de la mitad, ya sabes en qué punto estás.
- Busca el comodín. Abre la configuración de permisos de tu agente y comprueba si hay una regla que permita todo. Suele estar puesta desde el primer día y olvidada.
- Ejecuta sin shell. Cambia
execporexecFileconargv. Es el cambio con mejor relación esfuerzo/resultado de toda la lista. - Confina por ruta resuelta, en lectura y en escritura.
En Dominicode Labs revisamos arquitecturas agénticas reales y compartimos las configuraciones de permisos que aguantan en producción.
La autonomía de verdad no es darle libertad total al modelo. Es construirle un sitio donde equivocarse salga barato.
Preguntas frecuentes
¿Por qué una lista de comandos prohibidos no basta para proteger a un agente?
Porque enumera un conjunto infinito. Una shell puede expresar la misma acción destructiva de muchas formas —flags separados, flags largos, otra herramienta que hace lo mismo, sustitución de comandos— y tu lista solo cubre las que se te ocurrieron. En la prueba de este post, dieciséis de veinte comandos destructivos atraviesan una blocklist de aspecto razonable, incluido rm -r -f /, que es el mismo comando del ejemplo con los flags separados.
¿Cómo confino a un agente a la carpeta del proyecto?
Resolviendo cada ruta con path.resolve() contra la raíz del workspace y comprobando que el resultado sigue colgando de esa raíz. No compruebes el texto de la ruta: startsWith("/") no detecta rutas absolutas de Windows ni el ~ que expande la shell, y includes("..") bloquea rutas internas legítimas. Si el agente puede crear symlinks, resuélvelos con fs.realpath antes de comparar.
¿Una allowlist no me va a estar frenando todo el rato?
Los primeros días sí, y es la señal de que funciona. La forma de reducirlo es construirla con datos: registra una semana de comandos reales del agente y parte de ahí. Suelen ser menos de treinta. Y ten un estado intermedio de "preguntar" para lo irreversible: sin él acabarás metiendo en la allowlist cosas que no deberían estar solo para dejar de confirmar.
¿Necesito Docker para esto o me basta con una rama aislada?
Depende de qué pueda romper la tarea. Una rama con su propio directorio de trabajo protege tu código y hace que tirar el trabajo cueste un segundo, pero comparte tu máquina, tus variables de entorno y tu red. Si la tarea instala dependencias, ejecuta código que no has leído o toca servicios, necesitas el aislamiento del contenedor.
¿Dónde pongo el guardrail: en la herramienta o en el agente?
En la herramienta, siempre. Un control que vive en el prompt, en el nombre de la tool o en su descripción es una sugerencia que el modelo puede ignorar. El guardrail tiene que estar en el código que ejecuta la acción, de forma que ni siquiera un agente que decida saltárselo pueda hacerlo. Si el control se puede desactivar escribiendo texto, no es un control.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
