El Harness — Clase 3: gobernanza y autonomía
Tercera clase de la serie: la autonomía no es un interruptor, es una política. Cómo clasificar acciones por riesgo, escribir la política como artefacto declarativo en vez de ifs dispersos, RBAC por herramienta, la intersección de permisos agente ∩ usuario, aprobación asíncrona por canal externo con timeout y escalación, audit log encadenado por hash y revocación en caliente.
☰ Índice del contenido
- La tesis de la clase
- Paso 1 — Clasificar acciones, no agentes
- Paso 2 — La política como artefacto, no como if
- Paso 3 — RBAC por herramienta
- Paso 4 — Identidad del agente vs identidad del usuario
- Paso 5 — Aprobación asíncrona por canal externo
- Paso 6 — Qué pasa cuando el aprobador no responde
- Paso 7 — El audit log inmutable
- Paso 8 — Revocación en caliente
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
En la Clase 2 trabajamos la capa de contexto: qué ve el modelo en cada turno y cómo se elige. Todo ese trabajo sirve para que el agente decida bien. Esta clase asume lo contrario: que en algún momento va a decidir mal, y que el sistema tiene que seguir siendo seguro igual. La viñeta de Gobernanza de la Clase 1 decía: “quién puede hacer qué y qué acciones necesitan aprobación. Mínimo privilegio: el agente solo toca lo que necesita.” Esta clase convierte esas dos frases en arquitectura.
Un aviso de encuadre antes de partir, porque el material es defensivo: todo lo que viene sirve para diseñar controles sobre tus propios agentes, en tus propios sistemas. Cuando aparezca un vector de ataque —el confused deputy, la Lethal Trifecta— aparece únicamente para explicar el control que lo neutraliza.
Y una delimitación importante. El mecanismo de pausar un agente y esperar a un humano ya está cubierto a fondo en human in the loop con LangGraph: interrupt(), Command(resume=...), el checkpointer como requisito y los cuatro usos de la interrupción. No voy a reexplicar interrupt(). Esta clase responde la pregunta anterior y la posterior: quién decide que esa acción necesitaba una pausa, y qué pasa mientras el agente está pausado y el aprobador no contesta.
La tesis de la clase
La autonomía no es un interruptor, es una política. Y una política que vive en el prompt es una sugerencia estadística; una política que vive en el código que ejecuta la tool es una garantía. La pregunta correcta nunca fue “¿cuánta autonomía le doy al agente?”, sino “¿qué nivel de riesgo tiene esta acción concreta, y qué compuerta le corresponde?”.
Esa segunda mitad no la inventé acá: es la bisagra de red teaming y seguridad, donde queda formulada así — “un prompt que dice ‘nunca envíes correos a dominios externos’ es una sugerencia estadística; una tool que rechaza dominios fuera de la allowlist es una garantía”. Toda la gobernanza es esa frase aplicada sistemáticamente: cada regla que quieras hacer cumplir hay que bajarla del prompt al código.
Paso 1 — Clasificar acciones, no agentes
El error de diseño más común es preguntarse si el agente es autónomo. No lo es ni deja de serlo: el mismo agente ejecuta acciones de riesgos radicalmente distintos. Leer el calendario y transferir dinero no pueden compartir régimen de permisos solo porque las ejecuta el mismo bucle.
El playbook de agentes autónomos ya establece el espectro de supervisión (HITL, HOTL, agent-in-the-loop, autónomo) y los tiers de riesgo, además de los post-mortems reales que conviene tener presentes — no los repito acá, están ahí con nombre y fecha. Lo que falta, y es lo que agrega esta clase, es el criterio que asigna el nivel. Cuatro atributos, evaluados por acción:
| Atributo | Pregunta | Barato (bajo riesgo) | Caro (alto riesgo) |
|---|---|---|---|
| Reversibilidad | ¿Puedo deshacerlo, y en cuánto tiempo? | Borrador editable, cache | Correo enviado, pago emitido, registro borrado |
| Radio de impacto | ¿A cuántas entidades toca? | Un registro del propio usuario | Un lote, un tenant completo, toda la tabla |
| Costo | ¿Cuánto vale ejecutarla mal? | Centavos en tokens | Dinero real, horas de reproceso |
| Visibilidad externa | ¿Sale del perímetro o compromete a la empresa? | Nada sale | Cliente lo recibe, tiene efecto legalmente vinculante |
El cuarto atributo es el que la mayoría subestima. Una acción puede ser barata, reversible y de radio uno, y aun así ser la más grave de la lista: un mensaje enviado a un cliente no se puede des-enviar, y en la práctica compromete a la empresa. La jurisprudencia relevante está en el playbook.
Con esos cuatro atributos, la regla de asignación que uso es deliberadamente conservadora: el nivel de una acción es el del atributo más caro que tenga, nunca el promedio. Una acción reversible, de radio uno y de costo cero, pero con visibilidad externa, es de alto riesgo. Punto.
| Nivel | Regla de asignación | Compuerta | Ejemplos de tools |
|---|---|---|---|
| R0 | Solo lectura, sin efectos, dentro del perímetro | Ninguna. Ejecuta y registra | buscar_docs, leer_calendario, consultar_pedido |
| R1 | Escritura reversible, radio ≤ 1, sin salida externa | Ejecuta y notifica después | crear_borrador, etiquetar_ticket, guardar_nota |
| R2 | Escritura difícil de revertir, o radio > 1, o costo real | Aprobación de un operador interno | agendar_reunion, actualizar_precio, cerrar_ticket_masivo |
| R3 | Irreversible, con salida externa o efecto vinculante | Aprobación con doble control y ventana de cancelación | enviar_correo_cliente, emitir_reembolso, publicar, borrar_registro |
La distinción entre R1 y R2 es la que más rendimiento da en producción: notificar no es aprobar. En R1 el agente actúa y avisa; el humano puede deshacer si quiere. En R2 el agente no actúa hasta que alguien diga que sí. Confundirlas en cualquiera de las dos direcciones tiene costo: llevar todo a R2 mata la utilidad del agente y entrena a los operadores a apretar “aprobar” sin leer; llevar R2 a R1 es cómo se llega a los post-mortems.
Este mapa se cruza con el criterio de cuándo un agente se justifica, donde el costo del error ya aparecía como uno de los cuatro criterios. Acá ese costo deja de ser una consideración de diseño y se vuelve un campo de configuración.
Paso 2 — La política como artefacto, no como if
Casi todos los agentes que he revisado tienen la gobernanza escrita así:
# El anti-patrón. Funciona el primer mes.
if tool == "emitir_reembolso" and args["monto"] > 50000:
pedir_aprobacion()
El problema no es que esté mal: es que está en el lugar equivocado. Una regla dentro del código tiene tres consecuencias caras. No la puede leer nadie que no lea Python, así que el dueño del dominio —que es quien sabe cuál es el monto correcto— no puede revisarla. No se puede auditar sin hacer arqueología en el repositorio. Y cambiar el umbral de 50.000 requiere un despliegue, lo que en la práctica significa que nunca se ajusta.
La corrección es separar política de motor. La política es un archivo declarativo, versionado en Git, revisable por el dueño del dominio y por seguridad. El motor es código genérico que no sabe nada de reembolsos: solo sabe evaluar reglas.
# politicas.yaml — la gobernanza como dato, no como código.
# Lo revisa el dueño del dominio; lo aprueba seguridad; vive en Git.
version: 3
default: deny # fail-closed: lo que no está declarado, no corre
roles:
# agente_analitica NO tiene ningún scope :write. Ese es el punto de
# separar lectura de escritura: un rol de consulta sale gratis.
agente_soporte: [soporte:read, soporte:write, pagos:read, pagos:write]
agente_analitica: [soporte:read, pagos:read]
tools:
consultar_pedido: { scope: soporte:read, riesgo: R0, dominio: soporte }
etiquetar_ticket: { scope: soporte:write, riesgo: R1, dominio: soporte }
emitir_reembolso: { scope: pagos:write, riesgo: R3, dominio: pagos }
enviar_correo: { scope: soporte:write, riesgo: R3, dominio: soporte }
reglas:
# Se evalúan en orden; la primera que matchea decide.
- id: reembolso-alto
cuando: { tool: emitir_reembolso, args: { monto: { gt: 50000 } } }
efecto: requiere_aprobacion
aprobador: rol:finanzas
timeout_min: 120
al_expirar: deny # R3 SIEMPRE fail-closed
- id: reembolso-bajo
cuando: { tool: emitir_reembolso, args: { monto: { lte: 50000 } } }
efecto: requiere_aprobacion
aprobador: rol:soporte_lead
timeout_min: 30
al_expirar: deny
- id: correo-externo
cuando: { tool: enviar_correo, args: { destino_externo: true } }
efecto: requiere_aprobacion
aprobador: rol:soporte_lead
timeout_min: 60
al_expirar: deny
- id: escritura-reversible
cuando: { riesgo: R1 }
efecto: allow # ejecuta y notifica: notificar no es aprobar
- id: lectura-libre
cuando: { riesgo: R0 }
efecto: allow
Dos decisiones de diseño que vale la pena hacer explícitas, porque son las que hacen la diferencia entre una política y un adorno.
default: deny. Esto es fail-closed: cuando el sistema no sabe qué hacer, la respuesta es no. Su opuesto, fail-open, deja pasar ante la duda. En una compuerta de seguridad, fail-open significa que cualquier tool nueva que alguien agregue el martes queda automáticamente sin control, y nadie se entera hasta el incidente. Con default: deny, la tool nueva simplemente no funciona hasta que alguien la declare — molesto y correcto.
El orden importa. La primera regla que hace match decide. Esto vuelve la política legible de arriba hacia abajo, pero exige que las reglas específicas vayan antes que las generales. Si lectura-libre estuviera arriba, ninguna otra regla se evaluaría para acciones R0.
Ahora el motor. Nota que no menciona reembolsos ni correos en ninguna parte: es código de infraestructura, escrito una vez.
"""Motor de políticas: evalúa una llamada a tool contra politicas.yaml.
Diseño defensivo — protege los agentes propios de la organización:
- fail-closed por defecto (lo no declarado se deniega),
- los permisos efectivos son la INTERSECCIÓN agente ∩ usuario,
- el motor no conoce el negocio: todo el negocio está en el YAML.
Devuelve una Decision, nunca ejecuta. Quien ejecuta es el runtime,
y solo si la decisión fue ALLOW.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
import yaml # PyYAML: yaml.safe_load, nunca yaml.load
class Efecto(str, Enum):
ALLOW = "allow"
DENY = "deny"
REQUIERE_APROBACION = "requiere_aprobacion"
@dataclass(frozen=True)
class Solicitud:
"""Una llamada a tool tal como la propuso el modelo, más la
identidad de quién la ejecuta y en nombre de quién."""
tool: str
args: dict[str, Any]
rol_agente: str # identidad del AGENTE
scopes_usuario: set[str] # scopes del USUARIO en cuyo nombre actúa
sesion_id: str
@dataclass(frozen=True)
class Decision:
efecto: Efecto
motivo: str
regla: str | None = None
riesgo: str = "R3" # si no se sabe, se asume lo peor
aprobador: str | None = None
timeout_min: int | None = None
al_expirar: Efecto = Efecto.DENY
scopes_efectivos: frozenset[str] = field(default_factory=frozenset)
_OPS = {
"gt": lambda v, r: v is not None and v > r,
"gte": lambda v, r: v is not None and v >= r,
"lt": lambda v, r: v is not None and v < r,
"lte": lambda v, r: v is not None and v <= r,
"eq": lambda v, r: v == r,
"in": lambda v, r: v in r,
}
def _match_arg(valor: Any, cond: Any) -> bool:
"""Un valor de argumento contra una condición del YAML.
Si la condición es un dict, es un operador; si no, igualdad."""
if isinstance(cond, dict):
return all(_OPS[op](valor, ref) for op, ref in cond.items())
return valor == cond
class MotorDePoliticas:
def __init__(self, ruta_yaml: str):
with open(ruta_yaml, encoding="utf-8") as f:
self.p = yaml.safe_load(f)
self.version = self.p["version"]
def _scopes_efectivos(self, s: Solicitud) -> frozenset[str]:
"""LA REGLA CENTRAL: intersección, jamás unión.
El agente no puede hacer nada que el usuario no pueda hacer,
y el usuario no gana capacidades por usar el agente."""
del_agente = set(self.p["roles"].get(s.rol_agente, []))
return frozenset(del_agente & s.scopes_usuario)
def evaluar(self, s: Solicitud) -> Decision:
meta = self.p["tools"].get(s.tool)
# 1. Tool no declarada en la política -> no existe para el agente.
if meta is None:
return Decision(Efecto.DENY,
f"tool '{s.tool}' no está en el catálogo")
efectivos = self._scopes_efectivos(s)
# 2. Chequeo de scope ANTES que cualquier regla de negocio.
# Sin el scope, no hay aprobación humana que valga: nadie
# puede autorizar lo que el usuario no tenía permitido.
if meta["scope"] not in efectivos:
return Decision(
Efecto.DENY,
f"scope '{meta['scope']}' fuera de la intersección "
f"agente ∩ usuario",
riesgo=meta["riesgo"], scopes_efectivos=efectivos)
# 3. Primera regla que matchea decide (el orden del YAML manda).
for regla in self.p["reglas"]:
cuando = regla["cuando"]
if "tool" in cuando and cuando["tool"] != s.tool:
continue
if "riesgo" in cuando and cuando["riesgo"] != meta["riesgo"]:
continue
if not all(_match_arg(s.args.get(k), v)
for k, v in cuando.get("args", {}).items()):
continue
return Decision(
efecto=Efecto(regla["efecto"]),
motivo=f"regla '{regla['id']}'",
regla=regla["id"],
riesgo=meta["riesgo"],
aprobador=regla.get("aprobador"),
timeout_min=regla.get("timeout_min"),
al_expirar=Efecto(regla.get("al_expirar", "deny")),
scopes_efectivos=efectivos)
# 4. Ninguna regla matcheó -> se aplica el default del YAML.
return Decision(Efecto(self.p["default"]),
"ninguna regla aplicó; default de la política",
riesgo=meta["riesgo"], scopes_efectivos=efectivos)
El flujo completo, con el motor en el único lugar donde puede estar — entre la decisión del modelo y la ejecución real:
el modelo propone una llamada a tool
│
▼
┌───────────────────┐
│ MOTOR DE POLITICAS│
│ politicas.yaml │
└─────────┬─────────┘
│
┌───────────────┼───────────────┐
allow │ deny │ │ requiere
▼ ▼ ▼ aprobacion
┌────────────┐ ┌────────────┐ ┌────────────────┐
│ chequeo de │ │ error a la │ │ cola de │
│ revocacion │ │ traza del │ │ aprobacion │
└──────┬─────┘ │ modelo │ │ (canal externo)│
│ └────────────┘ └───────┬────────┘
▼ │
┌────────────┐ │ aprobado
│ EJECUTAR │ ◄────────────────────────┘
└──────┬─────┘
▼
┌────────────┐
│ audit log │
└────────────┘
Fíjate en la rama deny: el error vuelve a la traza del modelo, no al usuario. Un deny bien redactado (“no tienes permiso para reembolsos sobre 50.000; pide autorización a finanzas”) es contexto útil: el agente puede explicarle al usuario qué pasó y ofrecer la alternativa. Un deny que revienta la ejecución con una excepción hace que el agente reintente en bucle. Esa es la conexión directa con la capa de gestión de errores de la Clase 1.
Paso 3 — RBAC por herramienta
RBAC (Role-Based Access Control, control de acceso basado en roles) es el modelo donde los permisos no se asignan a personas ni a agentes uno por uno, sino a roles; y a cada identidad se le asigna un rol. En MCP aparece mencionado en una línea dentro del gateway corporativo. Acá lo desarrollo, porque en un agente tiene una particularidad que no tiene en una app normal.
La cadena es de tres eslabones — rol, scopes, tools — y se lee de arriba hacia abajo. Acá está el rol agente_analitica de la política de más arriba, el de solo consulta:
┌───────────────────┐
│ ROL: agente_ │
│ analitica │
└─────────┬─────────┘
│
┌─────────────┴─────────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ soporte:read │ │ pagos:read │
└──────┬───────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ consultar_ │ │ consultar_ │
│ pedido │ │ saldo │
│ buscar_docs │ │ │
└──────────────┘ └──────────────┘
Ningún scope :write en el rol, asi que etiquetar_ticket
y emitir_reembolso quedan inalcanzables para este agente,
aunque el modelo las vea listadas en su contexto
Un scope es un permiso nombrado, con la forma dominio:verbo. Existe porque asignar tools directamente a roles no escala: con 42 tools y 6 roles tendrías 252 casillas que mantener a mano. Con scopes tienes 6 roles × ~4 scopes, y cada tool declara el suyo una vez.
Tres reglas prácticas que evitan que el RBAC se degrade con el tiempo:
- Separar lectura de escritura siempre.
soporte:readysoporte:writeson scopes distintos, aunque hoy los tenga el mismo rol. El día que quieras un agente de solo consulta, ya está listo. Es la misma conclusión de la tabla de arreglos estructurales de red teaming: separar tools de lectura y escritura por rol. - Un rol por agente, no un rol compartido. Si el agente de soporte y el de analítica comparten rol, no puedes revocarle permisos a uno sin romper al otro, y el audit log no distingue quién hizo qué.
- El scope se chequea antes de la regla de negocio. En el motor de arriba eso es el paso 2, y el orden es deliberado: si el usuario no tenía el permiso, no hay aprobación humana que lo repare. Un aprobador puede decidir sobre el si conviene, nunca sobre el si está permitido.
Paso 4 — Identidad del agente vs identidad del usuario
Este es el punto que en mi experiencia falta en casi todas las implementaciones, y el que produce los agujeros más silenciosos.
Un agente en producción tiene dos identidades en juego a la vez. La suya propia —el service account con el que se conecta a los sistemas, sus credenciales, su rol— y la de la persona en cuyo nombre está actuando en ese turno. Son cosas distintas y confundirlas tiene nombre propio en seguridad: el confused deputy (el “delegado confundido”). El patrón: un componente con permisos amplios ejecuta, sin darse cuenta, una acción pedida por alguien que no tenía esos permisos. El componente no fue hackeado; fue usado como intermediario.
En un agente esto es especialmente fácil de provocar. El agente tiene credenciales de servicio potentes porque necesita atender a todos los usuarios; y recibe instrucciones en lenguaje natural desde un canal que puede estar contaminado. Ahí es donde conecta con la Lethal Trifecta que documenta MCP en su capítulo de seguridad: datos privados + contenido no confiable + capacidad de exfiltración. El control que la neutraliza en esta capa es concreto, y es una sola línea:
Los permisos efectivos son la intersección de los del agente y los del usuario, nunca la unión. Si el agente puede leer todos los tickets y el usuario solo los suyos, el agente actuando por ese usuario puede leer solo los suyos. Sin excepciones, sin “modo administrador”, sin atajos para tareas programadas.
PERMISOS DEL AGENTE PERMISOS DEL USUARIO
(service account) (quien pide la accion)
┌─────────────────┐ ┌─────────────────┐
│ soporte:read │ │ soporte:read │
│ soporte:write │ │ pagos:read │
│ pagos:read │ │ rrhh:read │
└────────┬────────┘ └────────┬────────┘
│ │
└────────────┬─────────────┘
▼
┌────────────────────┐
│ INTERSECCION │
│ soporte:read │
│ pagos:read │
└────────────────────┘
esto es lo que corre
soporte:write se cae: el usuario no lo tiene
rrhh:read se cae: el agente no lo tiene
Fíjate en las dos exclusiones de abajo, porque protegen contra ataques opuestos. Que se caiga soporte:write impide la escalada de privilegios vía agente: un usuario de solo lectura no gana capacidad de escritura por el hecho de pedírsela a un agente que sí la tiene. Que se caiga rrhh:read impide lo contrario: aunque el usuario sea gerente de RRHH, este agente no fue construido para tocar ese dominio y no lo va a tocar. Eso es mínimo privilegio en las dos direcciones.
Tres consecuencias operativas que hay que resolver explícitamente:
| Situación | Cómo se resuelve |
|---|---|
| Tarea programada (no hay usuario) | Se le asigna una identidad de servicio propia, con su rol y sus scopes, normalmente más restringido que el interactivo. Nunca “sin usuario, entonces sin chequeo” |
| Sub-agente que llama a otro agente | Hereda la identidad del usuario original y la intersección se recalcula. Un sub-agente jamás amplía permisos |
| Token del usuario vencido a mitad de sesión | La acción se deniega, aunque el agente esté a medio camino. La identidad se revalida por acción, no por sesión |
El paralelo útil está en gobernanza en MLOps, donde la auditoría responde “quién entrenó, aprobó y desplegó cada versión”. Acá la pregunta equivalente es quién pidió, en nombre de quién y quién aprobó cada acción — y sin identidades separadas, esa pregunta no tiene respuesta.
Paso 5 — Aprobación asíncrona por canal externo
interrupt() pausa el grafo dentro del proceso y guarda el estado en el checkpointer. Perfecto. Pero la aprobación real no llega por ese canal: llega por Slack, por correo, por un ticket, por una app interna — y llega en minutos u horas, no en el mismo request. El hueco de diseño está entre “el grafo está pausado” y “alguien apretó aprobar en Slack a las 19:40”.
Lo que conecta ambos mundos es una cola de aprobaciones persistente, con su propia tabla, que es la fuente de verdad. El grafo pausado no espera a nadie: espera a que alguien lo reanude.
t0 el agente propone
│ emitir_reembolso(monto=90000)
▼
t0 motor: REQUIERE_APROBACION
│ regla 'reembolso-alto'
│ solicitud creada, estado PENDIENTE
│ se notifica a rol:finanzas por Slack
▼
t0 el grafo queda congelado en el checkpointer
│
│ el proceso puede morir y reiniciarse:
│ la cola es la fuente de verdad
│
▼
t+30m nadie respondio: ESCALACION al suplente
│ se re-notifica
▼
t+120m se cumple timeout_min
│
├──► al_expirar deny: EXPIRADA. Se reanuda con
│ rechazo, el agente informa al usuario
│
└──► si alguien aprobo antes: APROBADA.
Se reanuda con Command(resume=...)
La solicitud de aprobación necesita cinco campos y ninguno es opcional: qué se pide (tool y argumentos completos, no un resumen), quién lo pide (agente y usuario), por qué llegó a la cola (la regla que disparó), hasta cuándo vale, y qué pasa si expira. Un botón de “aprobar” sin los argumentos exactos a la vista es un botón que la gente aprieta a ciegas.
Dos advertencias que salen caras si se ignoran.
La solicitud debe estar atada al estado exacto que se aprobó. Guarda el
thread_iddel checkpoint y un hash de los argumentos. Si entre la creación y la aprobación el estado cambió, la aprobación ya no es válida y hay que volver a preguntar. Aprobar “el reembolso de 90.000” y que se ejecute uno de 140.000 porque el agente siguió razonando es exactamente el tipo de fallo que nadie ve venir.
La reanudación tiene que ser idempotente. Un webhook de Slack se puede entregar dos veces; un operador impaciente aprieta “aprobar” tres veces. Si eso emite tres reembolsos, la compuerta empeoró el sistema en lugar de mejorarlo. La idempotencia como contrato para efectos secundarios está desarrollada en LangGraph a producción, y acá deja de ser una buena práctica para volverse un requisito de la capa.
Paso 6 — Qué pasa cuando el aprobador no responde
Este es el hueco más grande que veo en implementaciones reales. Todo el mundo diseña el camino feliz —el operador aprueba— y a lo sumo el rechazo. Casi nadie diseña el silencio, que en producción es el caso más frecuente: es viernes a las 20:00, el aprobador está de vacaciones y su suplente no está configurado.
Una solicitud de aprobación necesita cuatro parámetros, no uno:
| Nivel | Quién aprueba | Timeout | Escalación | Default al expirar |
|---|---|---|---|---|
| R0 | Nadie | — | — | — (se ejecuta directo) |
| R1 | Nadie; se notifica post-hoc | — | — | Ejecutado, con opción de deshacer |
| R2 | Lead del dominio | 30 min | Suplente a los 15 min | deny, y avisa al usuario |
| R3 | Rol con doble control | 2 h | Suplente a los 30 min, después guardia | deny, siempre, sin configuración alternativa |
Y la advertencia que resume la sección entera:
El default al expirar un timeout debe ser denegar en toda acción de alto riesgo. Un fail-open silencioso —“si nadie respondió en 2 horas, ejecuta igual”— convierte la compuerta en decoración: el atacante (o simplemente la mala suerte de un viernes) solo tiene que esperar. Peor aún, produce una falsa sensación de control, porque el sistema tiene aprobaciones y la gente asume que funcionan. Fail-open solo es aceptable en R0 y R1, donde de todas formas no hay compuerta.
Hay una tentación que conviene nombrar para descartarla: dejar que el propio modelo apruebe cuando nadie responde. No. Un LLM revisor puede pre-filtrar, priorizar la cola o resumirle al humano por qué la acción es razonable — eso agrega valor real. Pero no puede ser el aprobador final de una acción R3, por una razón que no es técnica: un modelo no puede asumir responsabilidad. La compuerta existe para que haya alguien que responda por la decisión, y ese alguien tiene que tener nombre.
Tres detalles que hacen que esto funcione en la práctica:
- Timeouts en horario laboral, no en horas absolutas. “2 horas” un viernes a las 20:00 no significa nada. Si tu operación no es 24/7, el reloj debe contar sobre la ventana de atención real.
- El usuario tiene que enterarse. Una solicitud que expira en silencio es una tarea que el usuario cree en curso. El rechazo por expiración se comunica con su motivo.
- La tasa de expiración es una métrica de producto. Si una de cada tres aprobaciones expira, la política está mal calibrada o el equipo está saturado. Ese número va al dashboard junto a la latencia.
Paso 7 — El audit log inmutable
Un agente autónomo sin registro no es auditable, y por lo tanto no es defendible. La observabilidad general —logging estructurado con request_id, trazas, OpenTelemetry— está cubierta en observabilidad y seguridad en producción. El audit log de gobernanza es otra cosa, y conviene no mezclarlos: las trazas sirven para depurar y se pueden rotar; el audit log sirve para responder legalmente y no se puede editar.
Append-only significa que solo se agrega al final: no hay UPDATE ni DELETE. Y el encadenamiento por hash agrega la propiedad que lo vuelve útil: cada entrada incluye el hash de la anterior, de modo que alterar o borrar un registro rompe la cadena de todos los siguientes, y se nota. No impide que alguien con acceso a la base modifique una fila; garantiza que no lo pueda hacer sin dejar rastro. Es la misma idea que un ledger, sin nada de blockchain.
| Campo | Para qué sirve | Ejemplo |
|---|---|---|
id | Identificador único de la entrada | evt_01H8... |
ts | Instante en UTC, con zona explícita | 2026-08-23T18:42:11Z |
agente_id / rol_agente | Qué agente actuó | sop-01 / agente_soporte |
usuario_id | En nombre de quién actuó | u_8842 |
sesion_id / thread_id | Reconstruir la conversación completa | thr_5f2a |
tool / args_hash | Qué se hizo. Hash si los args llevan PII | emitir_reembolso / sha256:9c1f... |
decision / regla | Qué dijo el motor y por qué regla | requiere_aprobacion / reembolso-alto |
politica_version | Bajo qué versión de la política se decidió | 3 |
aprobador_id / via | Quién autorizó y por qué canal | u_0031 / slack |
resultado | Qué pasó al ejecutar | ok / error / expirada |
hash_anterior / hash | La cadena de integridad | sha256:... |
Dos campos merecen defensa porque suelen recortarse. politica_version: sin él, cuando alguien pregunta “¿por qué el sistema permitió esto en marzo?”, no tienes forma de saber qué decía la política en marzo. Y args_hash: guardar los argumentos en claro mete PII en un log que por diseño no se puede borrar, lo que choca de frente con cualquier obligación de eliminación de datos. El hash prueba qué se aprobó sin almacenar el contenido; los datos completos viven donde sí puedes borrarlos, con su retención.
"""Audit log append-only encadenado por hash.
Propiedad: si alguien edita o borra una entrada, la verificación de la
cadena falla desde ahí en adelante. No previene la manipulación; la
hace DETECTABLE, que es lo que se necesita post-incidente.
Nota de alcance: esto es integridad, no autenticidad. Para que una
entrada sea atribuible a tu sistema hace falta además firmar el hash
con una clave privada custodiada fuera de la app.
"""
import hashlib
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
GENESIS = "0" * 64
DDL = """
CREATE TABLE IF NOT EXISTS audit (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
agente_id TEXT NOT NULL,
rol_agente TEXT NOT NULL,
usuario_id TEXT,
sesion_id TEXT NOT NULL,
tool TEXT NOT NULL,
args_hash TEXT NOT NULL,
decision TEXT NOT NULL,
regla TEXT,
politica_ver INTEGER NOT NULL,
aprobador_id TEXT,
via TEXT,
resultado TEXT,
hash_anterior TEXT NOT NULL,
hash TEXT NOT NULL
);
-- Append-only a nivel de motor: la app no tiene UPDATE ni DELETE, y
-- el usuario de BD del servicio tampoco debería tener esos permisos.
CREATE TRIGGER IF NOT EXISTS audit_no_update
BEFORE UPDATE ON audit
BEGIN SELECT RAISE(ABORT, 'audit log es append-only'); END;
CREATE TRIGGER IF NOT EXISTS audit_no_delete
BEFORE DELETE ON audit
BEGIN SELECT RAISE(ABORT, 'audit log es append-only'); END;
"""
CAMPOS = ("ts", "agente_id", "rol_agente", "usuario_id", "sesion_id",
"tool", "args_hash", "decision", "regla", "politica_ver",
"aprobador_id", "via", "resultado")
def _hash_entrada(fila: dict[str, Any], hash_anterior: str) -> str:
"""El hash cubre TODOS los campos más el hash previo.
sort_keys=True: el orden del dict no puede cambiar el hash."""
payload = json.dumps({**fila, "hash_anterior": hash_anterior},
sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def hash_args(args: dict[str, Any]) -> str:
"""Los argumentos no se guardan en claro: pueden llevar PII y este
log no se puede borrar. El hash prueba QUÉ se aprobó."""
crudo = json.dumps(args, sort_keys=True, ensure_ascii=False,
separators=(",", ":"))
return "sha256:" + hashlib.sha256(crudo.encode("utf-8")).hexdigest()
class AuditLog:
def __init__(self, ruta: str = "audit.db"):
self.cx = sqlite3.connect(ruta, isolation_level=None)
self.cx.executescript(DDL)
def _ultimo_hash(self) -> str:
fila = self.cx.execute(
"SELECT hash FROM audit ORDER BY seq DESC LIMIT 1").fetchone()
return fila[0] if fila else GENESIS
def registrar(self, **campos: Any) -> str:
fila = {c: campos.get(c) for c in CAMPOS}
fila["ts"] = datetime.now(timezone.utc).isoformat(
timespec="seconds").replace("+00:00", "Z")
# BEGIN IMMEDIATE evita que dos escrituras concurrentes lean el
# mismo 'último hash' y bifurquen la cadena.
self.cx.execute("BEGIN IMMEDIATE")
try:
anterior = self._ultimo_hash()
h = _hash_entrada(fila, anterior)
self.cx.execute(
f"INSERT INTO audit ({','.join(CAMPOS)},"
f" hash_anterior, hash) VALUES "
f"({','.join('?' * len(CAMPOS))},?,?)",
(*[fila[c] for c in CAMPOS], anterior, h))
self.cx.execute("COMMIT")
except Exception:
self.cx.execute("ROLLBACK")
raise
return h
def verificar(self) -> tuple[bool, int | None]:
"""Recorre la cadena. Devuelve (íntegra, seq del primer roto).
Se corre en un job periódico, no solo cuando hay incidente:
detectar la manipulación seis meses después no sirve de nada."""
anterior = GENESIS
cols = ",".join(("seq", *CAMPOS, "hash_anterior", "hash"))
for fila in self.cx.execute(
f"SELECT {cols} FROM audit ORDER BY seq"):
seq, *resto = fila
datos = dict(zip(CAMPOS, resto[:len(CAMPOS)]))
h_ant, h = resto[len(CAMPOS)], resto[len(CAMPOS) + 1]
if h_ant != anterior or _hash_entrada(datos, h_ant) != h:
return False, seq
anterior = h
return True, None
Cómo se usa post-incidente, que es lo único que justifica el esfuerzo. Alguien reporta un reembolso indebido. Con este log respondes en minutos: qué agente lo emitió, en nombre de qué usuario, bajo qué versión de la política, qué regla decidió que solo requería un aprobador, quién aprobó y por qué canal. Sin él, la respuesta es una reconstrucción a partir de logs de aplicación que se rotan a los 30 días. La diferencia entre ambas situaciones no es de comodidad: es si la organización puede o no defender lo que hizo su agente.
Paso 8 — Revocación en caliente
Las políticas se evalúan por acción, pero hay un caso que no cubren: el agente ya está corriendo y hay que detenerlo ahora. El kill switch aparece mencionado en cómo funciona un agente y en el playbook. Lo que falta es el detalle que decide si funciona: el agente consulta el estado de revocación antes de cada acción, no al arrancar. Un agente que lee su configuración al inicio del run es un agente que no se puede detener durante el run — justo cuando lo necesitas.
| Alcance | Qué corta | Latencia típica | Costo colateral |
|---|---|---|---|
| Sesión | Un hilo/thread_id específico | Siguiente acción | Ninguno: solo ese usuario |
| Agente | Todas las sesiones de un agente | Siguiente acción | Ese producto queda fuera |
| Tool | Una herramienta, en todos los agentes | Siguiente acción | Degradación parcial global |
| Global | Todo el sistema agéntico | Siguiente acción | Servicio caído. Solo para incidente confirmado |
El de tool es el más útil y el menos implementado: cuando aparece un problema con un conector concreto —una integración que se comporta raro, un servidor MCP sospechoso— quieres apagar esa tool sin apagar el producto. Vale la pena construirlo antes de necesitarlo.
Junto a la revocación van los dos topes por agente, que el playbook lista como must-have y que en esta capa son simplemente tres contadores más en la misma verificación previa:
"""Chequeo de revocación y topes, ANTES de cada ejecución de tool.
La clave de diseño está en 'antes de cada': si esto se consulta una vez
al arrancar el run, el kill switch no sirve para detener nada en curso.
El backend es un almacén compartido de baja latencia (Redis en real);
acá va en memoria para que el ejemplo sea ejecutable.
"""
import time
from dataclasses import dataclass
class Revocaciones:
"""Fuente de verdad de qué está apagado. La escriben el panel de
operaciones y los runbooks de incidente."""
def __init__(self):
self._off: set[str] = set() # claves: "sesion:X", "tool:Y"...
def revocar(self, alcance: str, ident: str = "*") -> None:
self._off.add(f"{alcance}:{ident}")
def restaurar(self, alcance: str, ident: str = "*") -> None:
self._off.discard(f"{alcance}:{ident}")
def esta_revocado(self, sesion: str, agente: str,
tool: str) -> str | None:
"""Del alcance más amplio al más estrecho: si hay kill global,
no hay nada más que evaluar."""
for clave in ("global:*", f"tool:{tool}",
f"agente:{agente}", f"sesion:{sesion}"):
if clave in self._off:
return clave
return None
@dataclass
class Topes:
"""Topes POR AGENTE Y POR RUN. Un tope global compartido entre
agentes no sirve: el que se desboca consume el de los demás."""
max_pasos: int = 40
max_usd: float = 5.0
max_segundos: int = 900
class Guardia:
def __init__(self, topes: Topes, revocaciones: Revocaciones):
self.t = topes
self.rev = revocaciones
self.pasos = 0
self.usd = 0.0
self.inicio = time.monotonic()
def antes_de_ejecutar(self, sesion: str, agente: str,
tool: str) -> None:
"""Lanza si el run no puede continuar. Se llama SIEMPRE, incluso
para tools R0: un bucle de lecturas también quema presupuesto."""
clave = self.rev.esta_revocado(sesion, agente, tool)
if clave:
raise PermissionError(f"revocado por alcance '{clave}'")
if self.pasos >= self.t.max_pasos:
raise RuntimeError(f"step cap: {self.t.max_pasos} pasos")
if self.usd >= self.t.max_usd:
raise RuntimeError(f"budget cap: US$ {self.t.max_usd}")
if time.monotonic() - self.inicio > self.t.max_segundos:
raise TimeoutError(f"wall clock: {self.t.max_segundos}s")
self.pasos += 1
def cobrar(self, usd: float) -> None:
"""Se llama tras cada llamada al modelo, con el costo real de
los tokens. Sin esto, el budget cap es decorativo."""
self.usd += usd
Los valores de Topes son ilustrativos: cada agente tiene los suyos, y se calibran mirando la distribución real de pasos y costo de los runs exitosos, no adivinando. La regla es que el tope quede por sobre el percentil alto de lo normal y muy por debajo de lo catastrófico.
Y un detalle sobre max_segundos que la mayoría omite: sin él, un agente puede quedarse esperando indefinidamente una tool lenta sin gastar pasos ni dinero, y sin que ninguna alarma se dispare. Es exactamente el perfil del bucle silencioso que documenta el playbook — ni errores, ni timeouts, ni 500s. Solo la factura.
Errores comunes
- Poner la política en el system prompt. “Nunca emitas reembolsos sobre 50.000” es una sugerencia estadística: la mayoría de las veces se cumple, y basta con que falle una para que sea un incidente. La regla vive en el motor que ejecuta la tool, y el prompt solo la explica para que el agente sepa qué ofrecerle al usuario.
- Clasificar agentes en vez de acciones. “Nuestro agente es semi-autónomo” no es una decisión de diseño, es una etiqueta. La unidad de gobernanza es la acción, con sus cuatro atributos de riesgo.
- Promediar los atributos de riesgo. Una acción reversible, barata y de radio uno pero con salida externa es de alto riesgo. Manda el atributo más caro, nunca el promedio.
- Reglas de negocio dispersas como
ifen el código. El dueño del dominio no puede leerlas, seguridad no las puede auditar y cambiar un umbral requiere despliegue, así que nunca se ajusta. - Default fail-open. Si la tool nueva que alguien agregó el martes queda sin control hasta que alguien se acuerde de declararla, la política no protege: documenta.
default: denyy la tool nueva simplemente no arranca. - Unir permisos en vez de intersectarlos. Es la definición operativa del confused deputy: el agente presta sus privilegios amplios a quien no los tenía. Intersección, siempre, y revalidada por acción.
- No diseñar el silencio del aprobador. El caso más frecuente en producción no es aprobar ni rechazar: es que nadie conteste. Sin timeout, escalación y default explícito, la compuerta se cuelga o —peor— se abre sola, y el fail-open deja una falsa sensación de control: el sistema tiene compuertas y todos asumen que funcionan.
- Aprobar sin ver los argumentos exactos. Un botón con un resumen entrena al operador a apretar sin leer. Y si el estado cambió entre la solicitud y la aprobación, hay que volver a preguntar.
- Mezclar el audit log con las trazas de observabilidad. Las trazas se rotan y se editan; el audit log responde legalmente y es append-only. Son dos ciclos de vida distintos — y por lo mismo, los argumentos con PII van hasheados: un log que no se puede borrar choca con cualquier obligación de eliminación de datos.
- Consultar la revocación al arrancar el run. Es un kill switch que no mata nada en curso. Se consulta antes de cada acción, incluidas las de solo lectura.
- Topes globales en vez de por agente. El agente que se desboca consume el presupuesto de todos los demás, y el que se cae es el que se portaba bien.
La frase para llevarse
“Un agente no es autónomo ni supervisado: cada una de sus acciones lo es. La gobernanza no consiste en decidir cuánto confías en el modelo, sino en escribir —fuera del modelo, donde no pueda negociarlo— qué está permitido, quién lo autoriza y qué pasa cuando nadie contesta.”
Qué viene
La Clase 4 cierra la serie. Llevamos tres clases mirando capas por separado: el mapa completo, el contexto y la gobernanza. La última las junta en un caso real y responde las dos preguntas que quedan pendientes cuando ya entiendes las piezas: en qué orden se construyen —porque construirlas en el orden equivocado es la forma más común de perder tres meses— y cómo saber en qué nivel estás, con una tabla de madurez de las 7 capas para autoevaluar tu sistema y decidir cuál es la siguiente inversión que rinde.
Material relacionado
- Clase 1: un agente de IA por dentro — las 7 capas y la tesis de la serie
- Clase 2: context engineering — la capa anterior
- Human in the loop con LangGraph — el mecanismo:
interrupt()yCommand(resume=...) - Playbook de agentes autónomos — el espectro de supervisión y los post-mortems reales
- Fundamentos de MCP — Lethal Trifecta, catálogo aprobado y gateway corporativo
- Red teaming y seguridad — cómo se prueba que estos controles aguantan
- Observabilidad y seguridad en producción — rate limiting, trazas y tope de presupuesto del proveedor
- LangGraph a producción — idempotencia, reintentos y time travel
- Gobernanza y madurez en MLOps — el paralelo del lado del modelo
- Glosario de agentes IA — los términos de la serie
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.