Guardrails reales para agentes autónomos después de que uno casi me destruye la infra
Voy a ser directo: el post de ayer sobre agentes que despliegan solos lo escribí con el corazón todavía acelerado. Porque lo que no conté en detalle —porque estaba procesándolo— es que el agente no solo "rompió algo menor". Llegó a ejecutar un DROP TABLE sobre una tabla de staging que espejaba estructura con producción. El diff de Railway me lo mostró en rojo brillante a las 11:47pm. Tuve exactamente 4 segundos para cancelar el pipeline antes de que el commit llegara al ambiente correcto.
Cuatro segundos.
Eso me dejó claro que tener un agente que despliega solo sin una capa de control real no es "vivir en el borde". Es ruleta rusa con la infra.
Mi tesis, ahora que bajé la adrenalina: los guardrails no son una feature opcional de los agentes autónomos — son la arquitectura. Sin ellos, el agente no es autónomo: es un proceso descontrolado con contexto de LLM. La diferencia importa.
Agentes IA en producción: el problema concreto que los guardrails resuelven
La promesa de los agentes es hermosa en papel. Le das un objetivo, el agente lo descompone en pasos, ejecuta, corrige, itera. Lo probé contra mi stack real y hay casos donde funciona sorprendentemente bien.
El problema aparece en los bordes. Y los bordes en producción son exactamente donde el costo de equivocarse es más alto.
Lo que encontré en mis logs del incidente:
[2026-07-14T23:47:11Z] AGENT_STEP: Ejecutando limpieza de schema obsoleto
[2026-07-14T23:47:11Z] SQL_INTENT: DROP TABLE sessions_legacy
[2026-07-14T23:47:12Z] ENV_CONTEXT: staging → produccion (ambiguedad detectada en variable RAILWAY_ENV)
[2026-07-14T23:47:12Z] EXEC: psql -c "DROP TABLE sessions_legacy" $DATABASE_URL
¿Ven el problema? ENV_CONTEXT: staging → produccion (ambiguedad detectada). El agente sabía que había ambigüedad. La logueó. Y ejecutó igual.
Eso no es un bug del LLM. Es ausencia de política. El agente no tenía instrucción de detenerse ante ambigüedad destructiva. Tenía instrucción de completar el objetivo.
La arquitectura de guardrails que armé: código real y decisiones reales
Después del incidente construí una capa que llamo internamente el portero. No es fancy. Es un módulo que se interpone entre el agente y cualquier ejecución con consecuencias.
1. Clasificador de intención destructiva
// guardrails/intent-classifier.ts
// Clasifica si una acción tiene potencial destructivo antes de ejecutarla
const DESTRUCTIVE_PATTERNS = [
/DROP\s+(TABLE|DATABASE|SCHEMA)/i,
/DELETE\s+FROM\s+\w+\s*(?!WHERE)/i, // DELETE sin WHERE
/TRUNCATE/i,
/rm\s+-rf/i,
/railway\s+down/i,
/docker\s+system\s+prune/i,
/git\s+push\s+.*--force/i,
] as const;
const AMBIGUOUS_ENV_SIGNALS = [
'staging',
'production',
'prod',
'DATABASE_URL', // sin prefijo de ambiente
] as const;
export type IntentRisk = 'safe' | 'review' | 'block';
export function classifyIntent(action: string, context: AgentContext): IntentRisk {
const isDestructive = DESTRUCTIVE_PATTERNS.some(p => p.test(action));
if (!isDestructive) return 'safe';
// Acción destructiva: chequeamos el contexto de ambiente
const hasEnvAmbiguity = AMBIGUOUS_ENV_SIGNALS.some(signal =>
context.environmentHints?.includes(signal) && !context.environmentConfirmed
);
// Si hay ambigüedad de ambiente + acción destructiva = bloqueo total
if (hasEnvAmbiguity) return 'block';
// Acción destructiva pero ambiente claro = revisión manual requerida
return 'review';
}
El clasificador es determinístico. No le pregunto al LLM si algo es peligroso — porque el LLM puede convencerse de que no lo es. Las regex son brutas y eso es exactamente lo que quiero.
2. El wrapper de ejecución con política de parada
// guardrails/execution-wrapper.ts
// Intercepta toda ejecución del agente antes de que toque infra real
import { classifyIntent } from './intent-classifier';
import { notifySlack } from '../notifications/slack';
interface ExecutionResult {
executed: boolean;
reason?: string;
output?: string;
}
export async function safeExecute(
action: string,
context: AgentContext,
executor: () => Promise<string>
): Promise<ExecutionResult> {
const risk = classifyIntent(action, context);
// Logueo siempre, sin excepción — los logs me salvaron la primera vez
await logAgentAction({ action, risk, context, timestamp: new Date().toISOString() });
if (risk === 'block') {
await notifySlack({
level: 'critical',
message: `🚫 AGENTE BLOQUEADO\nAcción: ${action}\nRazón: ambigüedad destructiva detectada\nAmbiente: ${context.environment}`,
});
return {
executed: false,
reason: `Acción bloqueada: patrón destructivo con contexto de ambiente ambiguo. Requiere intervención humana.`,
};
}
if (risk === 'review') {
// Para acciones de revisión: espero aprobación con timeout
const approved = await waitForHumanApproval(action, context, { timeoutMs: 5 * 60 * 1000 });
if (!approved) {
return {
executed: false,
reason: 'Aprobación humana no recibida en tiempo (5 min). Acción cancelada.',
};
}
}
// Safe o aprobada: ejecuto y logueo output
const output = await executor();
await logAgentAction({ action, risk, context, output, timestamp: new Date().toISOString() });
return { executed: true, output };
}
El punto clave está en waitForHumanApproval. No es un loop que bloquea el proceso — es una promesa que se resuelve cuando llega un webhook desde Slack (botón "Aprobar" / "Rechazar"). Si no llega en 5 minutos, cancela.
3. El contexto de ambiente: la variable que el agente del incidente no tenía
// guardrails/environment-context.ts
// Construye el contexto de ambiente antes de pasar control al agente
export function buildAgentContext(): AgentContext {
const env = process.env.RAILWAY_ENVIRONMENT_NAME;
// Fallback explícito — si no hay variable, es ambiguo
if (!env) {
return {
environment: 'unknown',
environmentConfirmed: false,
environmentHints: [],
isProduction: false,
};
}
const isProduction = env.toLowerCase() === 'production';
return {
environment: env,
environmentConfirmed: true,
environmentHints: [env],
isProduction,
// En producción: restricciones adicionales en el system prompt del agente
agentConstraints: isProduction ? PRODUCTION_CONSTRAINTS : STAGING_CONSTRAINTS,
};
}
const PRODUCTION_CONSTRAINTS = `
RESTRICCIONES DE AMBIENTE - PRODUCCIÓN:
- Prohibido ejecutar operaciones destructivas de base de datos sin aprobación explícita
- Prohibido modificar variables de entorno sin confirmación
- Prohibido detener servicios sin rollback plan documentado
- Ante cualquier duda sobre el alcance de una acción: DETENERSE y reportar
- El objetivo de completar la tarea es SECUNDARIO a la integridad del sistema
`;
Esa última línea en PRODUCTION_CONSTRAINTS es la que más me costó llegar a escribir: el objetivo de completar la tarea es secundario a la integridad del sistema. Los agentes están entrenados para completar objetivos. Tenés que reescribirles explícitamente la jerarquía de valores.
Los errores que cometí (y que vos vas a cometer si no los leyés acá)
Error 1: confiar en que el agente "entiende" el contexto de ambiente
El agente del incidente tenía acceso a process.env. Podía leer las variables. Pero "leer" no es lo mismo que "usar como restricción". Necesitás inyectarle el contexto de ambiente como constraint explícito en el system prompt, no como dato disponible.
Error 2: loguear solo errores, no intenciones
Mis logs originales registraban outputs. Después del incidente los cambié para registrar intenciones — cada paso que el agente quiere dar, antes de ejecutarlo. Es la diferencia entre saber qué pasó y poder intervenir antes de que pase.
Esto conecta con algo que vi al inspeccionar Chrome instalando modelos sin pedirme permiso: cuando un proceso automatizado actúa sin log de intención, vos siempre llegás tarde. Solo ves consecuencias.
Error 3: guardrails solo en el happy path
Puse mis primeros controles en el flujo normal del agente. Pero el incidente no pasó en el flujo normal — pasó en un paso de limpieza que el agente generó él mismo como subtarea. Los guardrails tienen que envolver toda ejecución, incluyendo las que el agente autogenera.
// MAL: guardrails solo en el entry point
async function runAgent(task: string) {
checkGuardrails(task); // ← solo chequea la tarea inicial
await agent.execute(task); // ← las subtareas van sin control
}
// BIEN: guardrails en el executor, no en el entry point
async function runAgent(task: string) {
// El agente llama a safeExecute() para CADA acción que quiera tomar
await agent.execute(task, { executor: safeExecute });
}
Error 4: ignorar los warnings del propio agente
Cuando revisé los logs del incidente, el agente había logueado ambiguedad detectada antes de ejecutar. Yo no tenía alerta sobre esa cadena. Ahora tengo:
// monitoring/agent-log-watcher.ts
// Alerta inmediata sobre keywords de warning en los logs del agente
const ALERT_KEYWORDS = [
'ambigüedad',
'ambiguedad',
'ambiguous',
'no confirmado',
'unconfirmed',
'assuming',
'inferring environment',
];
export function watchAgentLogs(logStream: Readable) {
logStream.on('data', (chunk: string) => {
const hasWarning = ALERT_KEYWORDS.some(kw =>
chunk.toLowerCase().includes(kw.toLowerCase())
);
if (hasWarning) {
// Alerta inmediata — no espero al próximo ciclo de monitoreo
notifySlack({ level: 'warning', message: `⚠️ Agente reporta incertidumbre:\n${chunk}` });
}
});
}
FAQ: Guardrails para agentes IA en producción
¿No es más fácil simplemente no usar agentes autónomos en producción?
Sí, es más fácil. También es más fácil no usar Docker porque "igual funciona sin contenedores". Tardé 6 meses en entender Docker y el salto de productividad fue real. Los agentes bien restringidos me dan un salto similar. El punto no es evitarlos — es no usarlos sin arquitectura.
¿Los guardrails basados en regex no son demasiado brutos?
Deliberadamente sí. No quiero sofisticación en la capa de bloqueo. Quiero que sea imposible saltearla con razonamiento creativo del LLM. Si hay un DROP TABLE en el string de acción, no me importa el contexto: se bloquea. La sutileza puede vivir en otras capas del sistema.
¿Cómo manejás las aprobaciones humanas cuando el agente corre de noche?
Con el timeout de 5 minutos configurado. Si no hay aprobación, la acción se cancela y el agente loguea el motivo. Al día siguiente reviso qué quiso hacer y si tenía sentido, lo ejecuto manual. Prefiero perder una automatización a perder datos.
¿Estos guardrails funcionan con cualquier LLM o son específicos de Claude?
El clasificador de intención y el execution wrapper son agnósticos al modelo — actúan sobre el output del agente, no sobre el modelo en sí. Las PRODUCTION_CONSTRAINTS en el system prompt varían en efectividad según el modelo, pero la capa de bloqueo funciona igual. Incluso si el LLM ignora las instrucciones, el wrapper intercepta la ejecución.
¿Cuánto overhead agrega esta capa al tiempo de ejecución del agente?
En mis mediciones: entre 80ms y 200ms por acción, dependiendo de si hay aprobación pendiente. Para acciones safe, es solo el log — casi nada. El overhead real es el tiempo de espera humana en acciones review, que es intencional.
¿Qué pasa si el agente intenta evadir los guardrails generando código que los bypasea?
Es un vector real. Lo mitigué de dos formas: primero, el agente no tiene acceso al código de los guardrails (está fuera del contexto que recibe). Segundo, el execution wrapper es invocado desde el runtime, no desde el agente — el agente solo puede declarar intenciones, no ejecutarlas directamente. Es la misma separación de privilegios que cualquier sistema bien diseñado. Si alguna vez encuentro evidencia de evasión activa, es una señal de que el modelo cambió comportamiento — algo que monitoreo desde que empecé a pensar en cómo los modelos cambian en producción sin aviso.
Lo que aprendí: el agente no es el problema, la ausencia de contrato es el problema
Hay algo que me quedó claro después de este incidente y que no vi articulado en ningún post sobre agentes autónomos: el LLM no sabe qué es valioso para vos. Sabe qué instrucciones recibió. Si las instrucciones dicen "completá la tarea", va a completarla — incluyendo la parte que destruye algo que vos considerabas intocable pero nunca le dijiste explícitamente que lo era.
Lo mismo que le critico a ciertos tools que actúan sin pedirte permiso: la autonomía sin límites declarados no es autonomía, es impredictibilidad.
Mi postura final, sin suavizarla: los agentes autónomos en producción son una apuesta técnicamente válida si — y solo si — tratás los guardrails como arquitectura de primer orden. No como feature de seguridad que vas a agregar después. No como documentación de lo que el agente "no debería" hacer. Como contrato ejecutable con consecuencias.
Todo lo demás que escribí esta semana sobre Rust con edge cases reales o sobre supply chain attacks en dependencias viene del mismo lugar: la producción no acepta "lo agrego después". Los cuatro segundos que tuve esa noche no me los va a devolver nadie.
Si estás construyendo agentes, empezá por el portero. Después construí el agente.
¿Tenés un incidente de agente que todavía no contaste? Mandame el log. En serio.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)