Agent security · Open source

A deny-by-default gate for AI agent tool calls

OWASP's 2026 work on agentic security documents how legitimate tool access can turn prompt injection into destructive actions, data loss, and unauthorized resource access. The pattern is usually not an exotic jailbreak. An agent reads a web page or an email, the page contains instructions meant for the model, and the agent follows them, with your API keys and your authority. Prompt injection was an annoyance when models could only talk. Once they can call tools, it is a security problem.

I ship agent features for a living, so I went looking for the enforcement layer: the piece that decides, at runtime, "this call is allowed and this one is not." I mostly found two other things. Scanners, which inspect an MCP server for malicious tool definitions before you install it. And guardrails, which filter prompts and model output. Both are useful. Neither stands between the model and the actual tool call.

The gap is not a secret. The OpenAI Agents SDK's tracker has long design threads on tool-level governance and pre-execution validation, and Vercel's AI SDK added approval hooks this year. Framework by framework, pieces are landing. What I could not find is a policy layer that is framework-agnostic and identical across languages, so one reviewed document governs a Node gateway and a Python agent the same way.

So I built it. tool-call-guard is a small policy engine that wraps any framework's tools. The policy is a JSON document:

{
  "defaultAction": "deny",
  "tools": {
    "search_*":   {},
    "send_email": { "maxCallsPerMinute": 5 },
    "deploy":     { "action": "approve" },
    "shell_exec": { "action": "deny" }
  }
}

Anything not listed is blocked. search_* allowlists a group of tools. send_email is rate capped. deploy goes to a human before it runs. shell_exec never runs, even if someone later flips the default to allow.

There is no model anywhere in this. It is deterministic code you can read in one code review, with tests that run on fake clocks. The same JSON document is enforced by two implementations: @yanib/tool-call-guard on npm and tool-call-guard on PyPI. Your Node gateway and your Python agents run one policy, and their audit logs share a schema, so a single dashboard can watch both.

The decisions that took actual thought

The code is small. The behavior around the edges is where the time went.

  • Deny by default. An empty policy blocks everything. If the posture is opt-out, one forgotten tool becomes the hole.
  • Validation runs before quota. Malformed calls never consume budget, so an attacker cannot starve a tool by spamming garbage at it.
  • An approval rule with no approver configured denies. Failing open here would turn a typo in your wiring into a bypass.
  • Dry-run is a first-class mode. Nobody turns on enforcement against production traffic on day one. In dry-run everything proceeds, and the audit log records what would have been blocked. Approvers are never invoked during a rehearsal, because paging a human for practice is how you teach them to ignore pages.
  • Wildcard rules share one budget. "fs_*": { "maxCalls": 10 } means ten calls across the whole group. Per-tool budgets under a wildcard would multiply your exposure by however many tools match.

The bug my own tests caught

Validators in the Python version accept either a plain function or a pydantic model class. My dispatch checked callable(validator) first. Pydantic model classes are classes, and classes are callable, so the guard tried to construct the model instead of calling model_validate, and every schema validation failed. The mirrored test suite caught it before anyone else could. A real user with real pydantic models would have hit it within minutes.

I mention this because "70 tests, all green" sounds like ceremony until one of them catches something like that. Now it is a regression test.

Naming, or: check the squashed variants

Publishing was its own small comedy. npm rejected the bare name because toolcallguard exists, a different library that does schema validation with retries. Earlier this week PyPI rejected another package of mine, token-budget, because token-bucket exists. Two registries, two different similarity rules, both invisible until the moment you publish. The lesson I now follow: before committing to any package name, check the collapsed variants, with hyphens stripped, on every registry you care about. The npm package ended up scoped as @yanib/tool-call-guard, which I have made peace with. The scope is my company. Every install line is a small ad.

Using it

npm i @yanib/tool-call-guard      # JS / TS
pip install tool-call-guard       # Python

Wrap your tools, hand the guard your policy, and denied calls throw before the tool runs. The README covers approval hooks, JSONL audit sinks, and the dry-run rollout path.

This sits in the middle of a defense-in-depth stack. Scanners vet what you install. Model guardrails filter text. This enforces what actually executes. If you find a hole in the policy model, open an issue. The library is small on purpose and I would like to keep it auditable.


I'm Binaya. I co-founded Yanib and build AI features at Fluid. More at dhakalbinaya.com.np.