tool-call-guard (Python)
Deny-by-default policy gate for AI agent tool calls. The Python half of tool-call-guard — same JSON policy model and audit schema as the npm package, so one security review covers both stacks.
from tool_call_guard import Guard, ToolCallDenied
guard = Guard(
{
"defaultAction": "deny", # anything unlisted is blocked
"tools": {
"search_*": {}, # allowlist a group
"send_email": {
"validate": lambda a: a["to"].endswith("@mycompany.com")
or "external recipients need approval",
"maxCallsPerMinute": 5,
},
"deploy": {"action": "approve"}, # human-in-the-loop
"shell_exec": {"action": "deny"},
},
},
approve=lambda req: ask_operator(req), # sync here; async via acheck()
)
@guard.protect("send_email")
def send_email(args):
...
send_email({"to": "attacker@evil.com"}) # raises ToolCallDenied
Install
pip install tool-call-guard
The core has zero dependencies and supports Python 3.9+. Provider SDKs are optional extras and currently require Python 3.10+ through their upstream packages. Validators accept plain callables (return True/False/reason-string, or raise) or pydantic-style model classes (anything with model_validate).
Provider adapters
OpenAI Agents SDK
pip install "tool-call-guard[openai]"
Attach the policy adapter to an OpenAI function tool's input guardrails. A denied call never reaches the function; by default the adapter returns a safe rejection message to the model.
from agents import function_tool
from tool_call_guard import Guard
from tool_call_guard.integrations.openai_agents import create_tool_input_guardrail
guard = Guard({
"tools": {
"search": {},
"shell": {"action": "deny"},
}
})
@function_tool(tool_input_guardrails=[create_tool_input_guardrail(guard)])
def search(query: str) -> str:
"""Search internal documents."""
return search_documents(query)
Set denied_behavior="raise_exception" to trip the run instead of returning model-visible rejection content. The default rejection text is generic; use the message option when the model should receive a curated reason. Invalid JSON arguments fail closed and are never copied into the adapter response.
Anthropic Claude Agent SDK
pip install "tool-call-guard[anthropic]"
Register the adapter as a PreToolUse hook:
from claude_agent_sdk import ClaudeAgentOptions
from tool_call_guard import Guard
from tool_call_guard.integrations.claude_agent_sdk import create_hook_matcher
guard = Guard({
"tools": {
"Read": {},
"mcp__docs__*": {},
"Bash": {"action": "deny"},
}
})
options = ClaudeAgentOptions(
hooks={"PreToolUse": [create_hook_matcher(guard)]},
)
Allowed calls return no permission decision, so the SDK's native permission checks still run. Denied calls return a structured PreToolUse denial with generic text unless you set the message option. With mode="dry-run", the hook records would_allow without changing the SDK's permission flow.
What the policy gives you
- Deny-by-default — unlisted tools are blocked; the allowlist is the policy.
- Wildcard rules —
"fs_*"budgets and gates a whole group; exact names beat patterns. - Argument validation — runs before quota, so malformed calls never consume budget.
- Quotas —
maxCallsper guard lifetime,maxCallsPerMinutesliding window (injectable clock). - Approval hooks —
action: "approve"calls your approver; no approver configured means deny, not allow. - Dry-run mode — everything proceeds, but the audit trail records what enforcement would have done. Observe a policy in production before turning it on. Approvers are never invoked during a rehearsal.
- Audit trail — in-memory ring buffer plus optional sinks;
jsonl_audit(path)writes one JSON line per decision, same schema as the JS package.
API sketch
guard = Guard(policy, mode="enforce"|"dry-run", approve=..., on_audit=...,
audit_args=True, max_audit_events=1000, now=time.time)
guard.check(tool, args) -> Decision # sync; sync approvers only
await guard.acheck(tool, args) # async; sync or async approvers
guard.wrap(name, fn) # sync fn -> sync wrapper, async -> async
@guard.protect(name) # decorator form
guard.wrap_tools({name: fn, ...})
guard.audit_log # ring buffer, newest last
guard.reset()
Decision: allowed, action, reason, tool, rule, and in dry-run would_allow + dry_run. Denied wrapped calls raise ToolCallDenied (with .decision).
Policy keys are camelCase (portable JSON, shared with the JS package); snake_case aliases (max_calls, …) are accepted in Python.
See the repository root for the full policy reference and the threat model this addresses.
License
MIT © Binaya Dhakal
Metadata
Release files for tool-call-guard 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| tool_call_guard-0.2.0.tar.gz | 14.7 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| tool_call_guard-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 26.6 kB
Release files / tool_call_guard-0.2.0.tar.gz
| Download URL | tool_call_guard-0.2.0.tar.gz |
|---|---|
| Size | 14.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
0733a0d6f66d185e17b9d81b6e410589aea7b588e226312243c44bb9b4040c79
|
|
BLAKE2b-256 checksum How to use checksums |
443681cab2e607c3253e1697b98ed25b62d3104fcac38866f08ce2eb0081ab6c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|
Release files / tool_call_guard-0.2.0-py3-none-any.whl
| Download URL | tool_call_guard-0.2.0-py3-none-any.whl |
|---|---|
| Size | 11.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d98fd8575757809b9c6b83312b9ddc5ea4ec1b5d0deb6874fc25bed165ab08eb
|
|
BLAKE2b-256 checksum How to use checksums |
61e34ef3583cf7aa40611914e28bd6a7a12882acd0abdfbf60e7176e1669e4c1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.13.7
|