← Todo el material
Curso Avanzado 23 de agosto de 2026

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

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:

AtributoPreguntaBarato (bajo riesgo)Caro (alto riesgo)
Reversibilidad¿Puedo deshacerlo, y en cuánto tiempo?Borrador editable, cacheCorreo enviado, pago emitido, registro borrado
Radio de impacto¿A cuántas entidades toca?Un registro del propio usuarioUn lote, un tenant completo, toda la tabla
Costo¿Cuánto vale ejecutarla mal?Centavos en tokensDinero real, horas de reproceso
Visibilidad externa¿Sale del perímetro o compromete a la empresa?Nada saleCliente 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.

NivelRegla de asignaciónCompuertaEjemplos de tools
R0Solo lectura, sin efectos, dentro del perímetroNinguna. Ejecuta y registrabuscar_docs, leer_calendario, consultar_pedido
R1Escritura reversible, radio ≤ 1, sin salida externaEjecuta y notifica despuéscrear_borrador, etiquetar_ticket, guardar_nota
R2Escritura difícil de revertir, o radio > 1, o costo realAprobación de un operador internoagendar_reunion, actualizar_precio, cerrar_ticket_masivo
R3Irreversible, con salida externa o efecto vinculanteAprobación con doble control y ventana de cancelaciónenviar_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:read y soporte:write son 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ónCó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 agenteHereda 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ónLa 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_id del 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:

NivelQuién apruebaTimeoutEscalaciónDefault al expirar
R0Nadie——— (se ejecuta directo)
R1Nadie; se notifica post-hoc——Ejecutado, con opción de deshacer
R2Lead del dominio30 minSuplente a los 15 mindeny, y avisa al usuario
R3Rol con doble control2 hSuplente a los 30 min, después guardiadeny, 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.

CampoPara qué sirveEjemplo
idIdentificador único de la entradaevt_01H8...
tsInstante en UTC, con zona explícita2026-08-23T18:42:11Z
agente_id / rol_agenteQué agente actuósop-01 / agente_soporte
usuario_idEn nombre de quién actuóu_8842
sesion_id / thread_idReconstruir la conversación completathr_5f2a
tool / args_hashQué se hizo. Hash si los args llevan PIIemitir_reembolso / sha256:9c1f...
decision / reglaQué dijo el motor y por qué reglarequiere_aprobacion / reembolso-alto
politica_versionBajo qué versión de la política se decidió3
aprobador_id / viaQuién autorizó y por qué canalu_0031 / slack
resultadoQué pasó al ejecutarok / error / expirada
hash_anterior / hashLa cadena de integridadsha256:...

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.

AlcanceQué cortaLatencia típicaCosto colateral
SesiónUn hilo/thread_id específicoSiguiente acciónNinguno: solo ese usuario
AgenteTodas las sesiones de un agenteSiguiente acciónEse producto queda fuera
ToolUna herramienta, en todos los agentesSiguiente acciónDegradación parcial global
GlobalTodo el sistema agénticoSiguiente acciónServicio 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 if en 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: deny y 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

HarnessGobernanzaAgentes IAHuman in the loopSeguridadAuditoría

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.