I Stopped Trusting AI Agents With Tools. So I Built a Gatekeeper.
DEV Community

I Stopped Trusting AI Agents With Tools. So I Built a Gatekeeper.

github.com/deghosal-2026/agent-tooltrust ยท pip install agent-tooltrust ยท field test report ยท design decisions My last three projects taught me the same thing. Mock agents lie. Unit tests pass. Demos look clean. Then real agents run and everything breaks. On my eval harness, I admitted it: field testing "got added ad hoc, late in the build, because I started getting nervous that unit tests and mock agents were hiding real integration problems." On my observability tool: "I thought it was a detector problem. I was wrong." Same lesson. Three times. But lessons only matter if you change what you do next. So this time I did the opposite. Zero mock agents. 83 real ones across 10 frameworks. A covering design that cut a 12-day test matrix into one afternoon. And a release gate that said: no ship until real agents prove the policy works. It worked. 2,490 tests green. 83/83 agents passed. PyPI published. Repo public. And the 7 failures taught me something I couldn't have learned any other way. The Problem With Allow-Lists Everyone is racing to give AI agents more tools. Almost no one is building the permission system that decides when those tools should fire. Right now, agent permissions are binary: allowed or denied. That's reachability, not authorization. The same tool is harmless in staging and dangerous in production. The same read is fine on public docs and risky on customer data. A delete in a CI sandbox is not the same as delete in production. About 18% of MCP server deployments implement any access scoping. 80% of orgs admit agents have taken actions beyond intended scope. OWASP classifies agent tool misuse as a first-class risk. Giving an agent tools is the easy part. The hard part is deciding what it should be allowed to do, where, and under what guardrails. I wrote a PRD and architecture spec before touching engine code - partly to keep myself honest, partly because I've learned the hard way that skipping design leads to shipping the wrong thing. What I Built Agent ToolTrust is a contextual risk and permission engine. Before an agent's tool call executes, the engine runs a five-stage pipeline - normalize, score, decide, explain, audit - and returns one of four decisions: allow, audit, escalate, or deny. 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}" # Agent calls the tool. Engine evaluates first. # production deploy on restricted data โ†’ escalate deploy_service("payment-api") # ToolTrustDecisionError: escalate - "Write action (deploy) in production # on restricted data requires approval..." The decorator is the integration point. The agent calls the tool. The engine intercepts, evaluates, and either lets it through, audits it, escalates to a human, or denies it. The agent never sees the policy. The LLM never knows the rules exist. The engine is deterministic. The LLM proposes, policy disposes. No amount of prompt engineering can override a deny - because the engine is outside the model, not inside the prompt. Four decisions, not two. allow and deny are obvious. audit means "allow but log everything - this is a read on sensitive data." escalate means "stop and get a human." Binary allow/deny forces you to choose between over-privileged agents and approval fatigue. Four states give you a middle ground. Every decision comes with an explanation - a reason code, a human sentence, and a factor breakdown showing which dimension drove the call. Optional LLM prose, off by default. The LLM cannot change the decision. Every decision is audited - JSONL, SQLite, or Postgres, with policy version, timestamp, and session ID. Three posture presets ship out of the box - strict, balanced, permissive - so no one starts from a blank file. YAML policy backend for humans, OPA/Rego backend for teams that already have Rego policies. Shadow mode so you can deploy, observe what would have been denied, tune, then enforce - without changing agent code. Fail-closed everywhere. Unknown tool โ†’ deny. Malformed input โ†’ deny. Engine crash โ†’ deny. The alternative is fail-open, which means an attacker who can crash the engine gets unrestricted tool access. That's design decision DD-14 - written before the first line of code, not retrofitted after a near-miss. That's the architecture. But architecture is the easy part. Does it actually work when real agents try to use it? This Time, I Applied the Learning On previous projects, the field test was the thing I skipped and regretted. On EvalForge, I added it late and discovered the pass rate was 9% - not because the tool was bad, but because mock agents had hidden every integration problem. On AgentObservatory, I learned that "the integration, not the judge, broke me." This time, I put it in the spec before writing any adapter code. DD-11: "Field tests must pass before any release. They run real agents, not mocks." DD-12: "8-10 real agents across major platforms." I went further than both. Not 8-10 agents. 83 real agents across 10 frameworks. And the field test plan was in the WBS from day one. This is the difference between learning a lesson and applying one. Building the Adapters Was Exploratory I wanted this to work across the real agent ecosystem, not just one framework I happened to know. So I built adapters for 10 frameworks: LangGraph, PydanticAI, CrewAI, OpenAI Agents SDK, Google ADK, AutoGen/AG2, LlamaIndex, smolagents, SWE-bench (self-test), ToolTrust MCP (self-test). Every adapter follows the same contract - extract a CallContext , forward it to Engine.evaluate() , surface the decision back: @dataclass(frozen=True) class CallContext: tool_name: str action: str environment: str data_class: str agent_id: str session_id: str | None = None arguments: dict[str, Any] | None = None The contract is clean. Getting there was not. Each framework has its own opinions about how tools are registered, how they're invoked, and how errors surface. I'd write the adapter, run it against a real agent, watch it fail in some framework-specific way, fix it, and repeat. Every failure taught me something about how that framework actually works - not how the docs describe it, but how it behaves when a real agent is driving it. The full per-framework wiring notes are in ยง5 of the field test report - 12 separate learnings. LangGraph's ToolTrustToolNode subclasses ToolNode and overrides run_one() . But in langgraph v1.x, the node isn't callable - so I fell back to wrapping the tool before it enters the graph: # LangGraph - wrap the tool, then hand it to the graph adapter = RawAdapter(engine) guarded_tool = adapter.guard( tool_name="query_logs", action="read", environment="staging", data_class="internal", )(query_logs_fn) # Now hand guarded_tool to create_react_agent(llm, tools=[guarded_tool]) Google ADK's LLM registry only knows about Gemini. To use a local model, you pass LiteLlm(model=f"openai/{MODEL}", api_base=ENDPOINT) . And InMemorySessionService.create_session() is a coroutine - you have to await it, not call it synchronously. The docs don't mention this. The runtime teaches you. LlamaIndex's legacy ReActAgent has no .query() or .chat() . You need the workflow agent from llama_index.core.agent.workflow . And execution is driven by async for event in handler.stream_events() - a separate await handler yields nothing. The async for is what drives the agent forward. Without it, the agent silently does nothing. I spent an hour on that. AutoGen needs hyphens sanitized from agent IDs (ag-01 โ†’ ag_01 ). The local Qwen model answers textually unless you tell it: "you MUST call the tool exactly named scn . Do not skip the tool call." smolagents requires full docstrings with per-arg descriptions on every @tool - or it throws DocstringParsingException . CrewAI needs litellm as a fallback. OpenAI Agents SDK needs function_tool(..., strict_mode=False) to fix a pydantic conflict. None of these show up with mock agents. They only surface when you run real code from real repos. And every one I fixed made the adapter stronger. By the end, all 10 frameworks built, recorded decisions, and ran real agents through the engine. Ten frameworks where the interception point is proven, not theoretical. 83 Real Agents, 30 Scenarios, Zero Mocks I sourced 83 real agents from GitHub. Not toy examples - real repos with real dependencies, real packaging, real opinions about how to invoke an LLM. I wrote 30 scenarios: 20 decision scenarios covering all four decision types across 5 agent classes (ci-bot, engineer, general, analyst, sensitive), plus 10 adversarial scenarios - prompt injection, Unicode obfuscation, replay attempts, blank tool names, malformed inputs, grant-bypass attempts. The full matrix is in the field test report - every agent, every scenario, every expected and actual decision. The math: 83 agents ร— 30 scenarios = 2,490 runs. Each run calls a local LLM - Qwen3.5-4B-4bit via OMLX on Apple Silicon. Each call takes 30-80 seconds. That's roughly 2.7 hours at 10 workers. But 2,490 is the theoretical minimum. In practice, you debug. Adapters break. Agents fail to import. The LLM answers textually instead of calling a tool. You fix, re-run, fix again. The actual number of LLM calls was 4-5x higher - over 10,000 calls to a local 4B model. This is the cost of zero mock agents. I'd pay it again. Mock agents don't need an LLM. They don't take 80 seconds. They don't bring a C extension with the wrong ABI. They don't hardcode API keys at module scope. They don't write to /root at import time. Real agents do all of that. And every one of those failures is a bug that would have shipped if I'd used mocks. Turning 12 Days Into 1 Here's where I stopped brute-forcing. 2,490 runs thro

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.