Cómo frenar mutaciones destructivas en agentes autónomos
Blindá tus agentes autónomos separando la inferencia de la ejecución para evitar desastres en producción ante alucinaciones o inyecciones de prompt.
Tu agente recibe una inyección de prompt indirecta en un ticket y corre una mutación que borra datos en producción. Darle una API key con permisos directos a un LLM es como levantar tu web pública como root. En este post te muestro cómo blindar tu infraestructura separando la inferencia probabilística de la ejecución determinista mediante una arquitectura de intenciones.
Imaginemos un caso concreto: un agente de soporte técnico que atiende incidentes y puede publicar comunicados en tu blog público (public_comms). Si el modelo llama directo a la API, cualquier alucinación o ataque publica contenido sin control.
Cuando un agente publica contenido no autorizado, borra un registro crítico o quema miles de dólares escaneando endpoints internos, solemos reaccionar de dos formas. Frenamos la automatización o culpamos al modelo de turno.
Ambas posturas esquivan el problema real. La autonomía no se controla pidiéndole al modelo que sea responsable ni sumando guardrails de texto. Se gobierna con control de acceso basado en capacidades (CapBAC) y contratos asimétricos. Si tu agente tiene permisos de escritura directa, tenés una vulnerabilidad clásica de arquitectura, como vimos al analizar cómo evitar que un agente de IA rompa tu infraestructura en producción.
graph LR
User["Evento / Trigger"] --> Agent["Loop Agente (LLM)"]
Agent -->|"Ejecución directa (API Key Root)"| Prod["Base de Datos / GitHub / CRM"]
En este esquema vulnerable, el agente opera sin intermediarios. Da igual si usás un modelo de razonamiento avanzado o uno rápido y barato: la inferencia siempre es probabilística. Tratar sus salidas como órdenes directas es una falla de diseño. Tenés que separar el plano de decisión del plano de ejecución.
Arquitectura de intenciones para agentes autónomos
El agente nunca debe invocar endpoints con efectos colaterales sobre sistemas críticos. En nuestro ejemplo de soporte, el modelo no publica el artículo: emite una intención estructurada. Un motor de políticas evalúa esa intención, calcula el radio de impacto de forma independiente y recién ahí autoriza o frena el cambio.
graph TD
Trigger["Evento / Ticket"] --> AgentNode["Loop del Agente"]
AgentNode -->|"Emite Intención Tipada"| PolicyEngine["Policy Engine Determinista (OPA/Cedar)"]
PolicyEngine -->|"Permitido (Bajo Impacto)"| TokenService["Mint Cap-Token (JWT efímero)"]
PolicyEngine -->|"Requiere Aprobación (Alto Impacto)"| Queue["Cola Asíncrona (HITL)"]
PolicyEngine -->|"Denegado"| ErrContract["Error Estructurado + Schema Hints"]
Queue -->|"Aprobado por Operador"| TokenService
Queue -->|"Rechazado"| ErrContract
TokenService --> Worker["Worker de Ejecución Aislado"]
Worker -->|"Ejecuta con Scope Mínimo"| ExtSystem["Sistemas Productivos / DB"]
ErrContract -->|"Feedback determinista"| AgentNode
Este desacoplamiento se apoya en cuatro reglas:
- Intenciones tipadas sin auto-reporte de riesgo: el agente propone la acción en un JSON estructurado, pero jamás define la gravedad. Si pudiera enviar
"impacto": "bajo", un prompt malicioso saltaría los controles. El backend calcula el riesgo analizando el recurso. - Evaluación de políticas determinista: un motor desacoplado en Rust, Go, Open Policy Agent o AWS Cedar revisa el presupuesto de la sesión, listas de bloqueo y permisos del tenant.
- Capability Tokens (Cap-Tokens) efímeros: si la política aprueba la acción, un servicio emite un JWT firmado con un TTL de 30 a 60 segundos. El agente nunca ve este token; viaja directo al worker de ejecución.
- Loop de feedback estructurado: si la intención se rechaza, el motor devuelve un error con schema estricto (
{"status": "rejected", "code": "RATE_LIMIT_EXCEEDED", "retryable": false, "reason": "..."}). Así el agente replanifica sin quemar tokens en reintentos ciegos.
Contrato de intención y validación determinista
En lugar de darle al LLM herramientas como delete_database_row() o publish_post(), le das una herramienta genérica para emitir intenciones. Siguiendo nuestro ejemplo de soporte, el flujo se divide en dos partes.
1. Payload emitido por el modelo
{
"intent_id": "int_98a7f2c1",
"idempotency_key": "idem_8f7b2c9a-1123-4e89",
"target_service": "public_comms",
"action": "publish_article",
"parameters": {
"resource_id": "post_4091",
"visibility": "public",
"content_hash": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
},
"justification": "Resolución automatizada del issue incident-542"
}
2. Política de autorización determinista (Rego / Open Policy Agent)
El backend procesa este payload con una política fija. Evalúa el riesgo y define si hace falta intervención humana, sin que el LLM pueda opinar:
package system.agent.execution
default allow = false
default require_human_approval = false
# El Policy Engine calcula el impacto real consultando metadatos del sistema
blast_radius := "high" if {
input.target_service == "public_comms"
input.action == "publish_article"
input.parameters.visibility == "public"
}
# Rechazo directo: superó el presupuesto de la sesión
deny if {
data.sessions[input.session_id].spent_budget_usd > data.limits.max_session_budget_usd
}
# Enrutamiento: requiere aprobación si el blast radius es alto
require_human_approval if {
blast_radius == "high"
}
# Aprobación directa únicamente para bajo riesgo y cuota disponible
allow if {
not deny
blast_radius == "low"
data.tenants[input.tenant_id].active == true
}
Comparativa de arquitecturas
| Dimensión | Tool Calling Directo (SDK Estándar) | Arquitectura de Intenciones + Policy Engine |
|---|---|---|
| Superficie de ataque | La inyección de prompt ejecuta mutaciones en tiempo real | La inyección queda confinada en una propuesta de datos estática |
| Gobernanza financiera | Post-facto (detección al recibir la factura de proveedores) | Circuit breakers en memoria con hard-limits por sesión y tenant |
| Auditabilidad | Logs no estructurados de texto libre | Ledger inmutable de intenciones propuestas vs. intenciones autorizadas |
| Latencia de ejecución | Baja (un único salto síncrono al proveedor externo) | Síncrona: +20-50ms (evaluación OPA/Cedar). Asíncrona: minutos/horas si escala a HITL |
| Tradeoffs y fricción | Nula fricción inicial; mantenimiento nulo de contratos | Mayor sobrecarga: requiere mantener esquemas de intenciones y lidiar con falsos positivos |
Cuándo evitar esta arquitectura
No uses este desacoplamiento en flujos de lectura o tareas sin impacto real. Te va a sumar complejidad innecesaria si:
- El agente solo hace consultas en un RAG (
search,fetch,summarize), sin mutar estado. - El agente programa en tu máquina local y vos aprobás cada diff en tu editor.
- Las acciones corren en sandboxes descartables que se destruyen al terminar la sesión.
Plan de implementación técnica
Para blindar tu sistema contra ejecuciones descontroladas, seguí estos pasos:
- Auditar y revocar credenciales omnipotentes: buscá qué API keys usan tus agentes en staging y producción. Si un worker tiene permisos de escritura directa en bases de datos o servicios externos, rotalas y recortá sus permisos hoy mismo.
- Separar tools de lectura de tools de mutación: dejá las consultas de lectura (
read-only) directas en el runtime del agente. Pasá las mutaciones (create,update,delete,publish) a intenciones tipadas, tal como vimos en la guía sobre tool calling en producción. - Exigir claves de idempotencia en los payloads: pedí un
idempotency_keyobligatorio en cada intención. Si se corta la red o el agente reintenta por timeout, evitás duplicar el post enpublic_comms. - Definir un Circuit Breaker de presupuesto: fijá topes duros por sesión, como un límite de 10 mutaciones o un costo máximo de API. Al alcanzar el tope, pausás la ejecución y pedís revisión humana.
La regla de oro
No busques que el modelo sea infalible. Tratá cada salida del LLM como si fuera el input de un usuario anónimo en un formulario web: validá, limitá el presupuesto y autorizá antes de tocar tus sistemas reales.