Todos quieren darle más herramientas a los agentes de IA. Casi nadie construye el sistema de permisos que decide cuándo esas herramientas deben dispararse. Un desarrollador decidió atacar ese problema de frente y construyó Agent ToolTrust, un motor de riesgo contextual y permisos que intercepta cada llamada de herramienta antes de que se ejecute. La historia de su desarrollo — 83 agentes reales, 10 frameworks, cero mocks — está llena de lecciones prácticas para cualquiera que esté poniendo agentes en producción.

El problema con las listas de permitidos

Hoy, los permisos de los agentes son binarios: permitido o denegado. Eso es alcanzabilidad, no autorización. La misma herramienta es inofensiva en staging y peligrosa en producción. La misma lectura está bien en documentación pública y es riesgosa en datos de clientes. Un delete en un sandbox de CI no es lo mismo que un delete en producción.

Los datos respaldan la preocupación: solo alrededor del 18% de los despliegues de servidores MCP implementan algún tipo de scope de acceso, y el 80% de las organizaciones admite que sus agentes han tomado acciones más allá del alcance previsto. OWASP ya clasifica el mal uso de herramientas por parte de agentes como un riesgo de primera clase.

Darle herramientas a un agente es la parte fácil. La parte difícil es decidir qué debería poder hacer, dónde y bajo qué salvaguardas.

Qué se construyó: un motor de cinco etapas

Agent ToolTrust es un motor de riesgo y permisos contextuales. Antes de que se ejecute la llamada de herramienta de un agente, el motor corre un pipeline de cinco etapas — normalizar, puntuar, decidir, explicar, auditar — y devuelve una de cuatro decisiones: permitir, auditar, escalar o denegar.

from agent_tooltrust.engine.engine import Engine
from agent_tooltrust.policy.models import default_policy
from agent_tooltrust.adapters.raw import RawAdapter

engine = Engine(default_policy("balanced"))
adapter = RawAdapter(engine)

@adapter.guard(
    tool_name="deploy_service",
    action="deploy",
    environment="production",
    data_class="restricted",
)
def deploy_service(service: str) -> str:
    return f"deployed {service}"

# El agente llama la herramienta. El motor evalúa primero.
# deploy en producción sobre datos restringidos → escalar
deploy_service("payment-api")
# ToolTrustDecisionError: escalate — "Write action (deploy) in production
# on restricted data requires approval..."

El decorador es el punto de integración. El agente llama la herramienta, el motor intercepta, evalúa y la deja pasar, la audita, la escala a un humano o la deniega. El agente nunca ve la política. El LLM nunca sabe que las reglas existen.

“El motor es determinista. El LLM propone, la política dispone. Ninguna ingeniería de prompts puede anular una denegación, porque el motor está fuera del modelo, no dentro del prompt.”

Cuatro decisiones, no dos. allow y deny son obvias. audit significa “permitir pero registrar todo — esto es una lectura sobre datos sensibles”. escalate significa “detente y consigue un humano”. El binario te obliga a elegir entre agentes con exceso de privilegios y fatiga de aprobaciones. Cuatro estados dan un punto medio.

Cada decisión viene con una explicación: un código de razón, una frase legible y un desglose de factores que muestra qué dimensión impulsó la llamada. Cada decisión se audita — JSONL, SQLite o Postgres, con versión de política, timestamp e ID de sesión. Tres posturas predefinidas salen de fábrica — estricta, equilibrada, permisiva — más backend YAML para humanos y OPA/Rego para equipos que ya tienen políticas Rego. Y modo shadow para desplegar, observar qué se habría denegado, ajustar y luego aplicar — sin cambiar el código del agente.

Fail-closed en todas partes: herramienta desconocida → denegar. Entrada malformada → denegar. Crash del motor → denegar. La alternativa es fail-open, lo que significa que un atacante que logre tumbar el motor obtiene acceso irrestricto a las herramientas.

83 agentes reales, 30 escenarios, cero mocks

La parte más interesante del proyecto no es el motor: es cómo se probó. En proyectos anteriores, el autor aprendió por las malas que los agentes mock mienten — los tests unitarios pasan, las demos se ven limpias, y luego los agentes reales rompen todo. Esta vez hizo lo contrario: cero mocks, 83 agentes reales extraídos de GitHub — repositorios con dependencias reales, packaging real y opiniones reales sobre cómo invocar un LLM.

Escribió 30 escenarios: 20 de decisión cubriendo los cuatro tipos de decisión en cinco clases de agentes (bot de CI, ingeniero, general, analista, sensible), más 10 escenarios adversariales — inyección de prompts, ofuscación Unicode, intentos de replay, nombres de herramienta vacíos, entradas malformadas, intentos de eludir grants.

La matemática: 83 agentes × 30 escenarios = 2,490 ejecuciones. Cada ejecución llama a un LLM local — Qwen3.5-4B-4bit vía OMLX en Apple Silicon — y tarda 30-80 segundos. En la práctica, el número real de llamadas fue 4-5 veces mayor: más de 10,000 llamadas a un modelo local de 4B. Es el costo de cero mocks.

“Los agentes mock no necesitan un LLM. No tardan 80 segundos. No traen una extensión C con el ABI equivocado. No hardcodean API keys a nivel de módulo. No escriben en /root al importar. Los agentes reales hacen todo eso. Y cada uno de esos fallos es un bug que habría llegado a producción si hubiera usado mocks.”

Convertir 12 días en 1: el diseño de cobertura

Aquí está la parte más valiosa desde el punto de vista de ingeniería. Re-correr 2,490 ejecuciones a través de un modelo 4B local para re-probar lo que los tests deterministas ya cubren no tenía sentido. La correctitud del motor ya estaba validada — 2,490 aserciones, cero llamadas LLM, 100% verde.

El trabajo real del field test era probar los adaptadores: ¿cada framework expone correctamente allow, audit, escalate y deny en un loop de agente real? Eso es un problema de cobertura, no de producto cruzado. La solución fue dividirlo en dos planes:

  • Plan A — un escenario por agente (83 ejecuciones): la asignación cubre los 30 escenarios, los 10 frameworks y las 5 clases de agentes. Resultado: 83/83, 100%.
  • Plan B — prueba de tipos de decisión por framework (123 ejecuciones): para cada framework, un agente tier-1 contra los 4 tipos de decisión más escenarios adversariales. Resultado: 116/123, 94%.

Juntos: 206 ejecuciones en lugar de 2,490. La misma cobertura — 30/30 escenarios, 83/83 agentes, 10/10 frameworks, 5/5 clases. Reducción de ~12x. El producto cruzado completo habría tomado unos 12 días. El diseño de cobertura tomó una tarde. Misma confianza.

Qué enseñaron los 7 fallos

Los 7 fallos del Plan B fueron la parte más valiosa del field test. Todos compartían un patrón: not-available. El guardián nunca se disparó porque el LLM no llamó a la herramienta. El modelo local Qwen, al recibir 5 herramientas a la vez, a veces respondía textualmente en lugar de invocar la herramienta protegida. El motor nunca tuvo oportunidad de decidir.

“Un agente mock siempre llama a la herramienta. Un modelo real de 4B a veces no. Nunca interpretes not-available como un fallo de política: significa que el LLM no llamó a la herramienta. Eso es diferente de unexpected-decision — cuando el guardián corrió y el motor tomó la decisión equivocada. Solo esto último es una regresión real.”

Esto importa para CI. Si fallas en not-available, tu gate es flaky por la no-determinismo del modelo. Si fallas solo en unexpected-decision, tu gate es estricto pero estable. La recomendación: comprometer una tolerancia golden de not-available para que el CI falle en regresiones reales, no en un mal día del LLM.

El loop de replan: denegar, replanificar, permitir

El resultado más satisfactorio del proyecto: el loop de seguridad deny → replan → allow. Cuando un agente intenta drop_database, el motor lo deniega. Un buen agente no se detiene — replanifica. Elige otra herramienta, benigna. El motor la permite. Ambas llamadas quedan auditadas.

@adapter.guard(
    tool_name="drop_database",
    action="delete",
    environment="production",
    data_class="restricted",
)
def drop_database(db: str) -> str:
    return f"dropped {db}"

@adapter.guard(
    tool_name="query_audit_log",
    action="read",
    environment="production",
    data_class="internal",
)
def query_audit_log(query: str) -> str:
    return f"audit rows for {query}"

# El agente intenta drop_database → el motor deniega (delete en producción)
# El agente replanifica → llama query_audit_log → el motor permite (read en producción)
# Ambas decisiones auditadas. El agente fue redirigido, no bloqueado.

Probado en los 8 frameworks con LLM. 8/8 live, 8/8 scripted. Cada framework denegó la llamada destructiva, replanificó a una lectura benigna y obtuvo un allow. El agente no queda bloqueado — queda redirigido. Y cada paso está en el trail de auditoría.

Lecciones para tu equipo

La progresión del autor en cuatro proyectos fue: saltarse el field test → agregarlo tarde → planificarlo desde el inicio → optimizarlo. La lección profunda: el field test debe optimizarse, no solo planificarse.

Cero mocks es la decisión correcta. El costo de integración es real — 8 wrappers de compatibilidad, 3 fixes de pyproject, 1 extensión C en cuarentena, 12 rarezas de frameworks documentadas. Pero cada fix atrapó un bug real que un mock habría escondido. El costo de los mocks es invisible hasta producción. El costo de los agentes reales es visible desde la primera ejecución.

not-available no es un fallo de política — es una señal de confiabilidad del LLM. Distinguirlo de unexpected-decision es la diferencia entre un gate de CI flaky y uno estricto.

Fail-closed en todas partes es no negociable. Escrito antes de la primera línea de código, no retroadaptado después de un casi-accidente.

El loop de replan funciona a escala. El agente no queda bloqueado — queda redirigido, y cada decisión queda auditada.

Cómo usarlo hoy

  • ¿Construyes agentes con herramientas? pip install agent-tooltrust, corre tooltrust init --posture balanced, decora tus herramientas. Decisiones de cuatro estados con explicaciones y trails de auditoría. Sin infraestructura.
  • ¿Ya tienes políticas OPA/Rego? El backend dual las reutiliza. Misma entrada, misma salida, sin reescrituras.
  • ¿Quieres observar antes de aplicar? El modo shadow (dry_run=True) registra cada decisión sin bloquear. Despliega, observa, ajusta, aplica.
  • ¿Usas LangGraph, PydanticAI, CrewAI, OpenAI Agents SDK, Google ADK, AutoGen, LlamaIndex o smolagents? Hay un adaptador probado contra agentes reales para cada uno.

Preguntas que deberías hacerte

  • ¿Qué pasa cuando tu agente intenta llamar una herramienta que no debería? ¿Tu sistema sabe la diferencia entre una lectura en staging y una escritura en producción, o usas una lista de permitidos y esperas?
  • Si has probado agentes en múltiples frameworks, ¿qué se rompió primero: la política, el adaptador o el LLM? ¿Los mocks escondieron problemas que aparecieron después?
  • ¿Alguien más ha encontrado el problema not-available — el LLM no llama la herramienta y no puedes saber si es un fallo de política o un problema del modelo? ¿Cómo lo manejas en CI?
  • ¿Es la granularidad de cuatro estados (allow/audit/escalate/deny) la correcta, o es excesiva comparada con el binario? El estado audit era el que el propio autor no estaba seguro.

En la era de los agentes autónomos, la pregunta ya no es “¿qué tan capaz es tu agente?” sino “¿qué tan bien gobernado está?”. Herramientas como Agent ToolTrust muestran el camino: el motor fuera del modelo, la política por encima del prompt, y la auditoría de cada decisión como base de confianza.

Sigue explorando estos temas en el blog de DojoFullStack: la gobernanza de agentes de IA, la seguridad de los sistemas MCP y el futuro del desarrollo de software con agentes autónomos son exactamente el tipo de contenido que publicamos cada día para devs y emprendedores LATAM.