Metadata-Version: 2.4
Name: tool-call-guard
Version: 0.1.0
Summary: Deny-by-default policy gate for AI agent tool calls: allowlists, argument validation, rate caps, human-approval hooks, dry-run mode, and an audit trail. Framework-agnostic, zero dependencies.
Project-URL: Homepage, https://github.com/binaydhakal/tool-call-guard
Project-URL: Repository, https://github.com/binaydhakal/tool-call-guard
Project-URL: Issues, https://github.com/binaydhakal/tool-call-guard/issues
Author-email: Binaya Dhakal <binaydhakal35@gmail.com>
License: MIT
License-File: LICENSE
Keywords: agents,ai,allowlist,audit,guardrails,human-in-the-loop,llm,mcp,policy,rate-limit,security,tool-calling
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Security
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# tool-call-guard (Python)

[![PyPI](https://img.shields.io/pypi/v/tool-call-guard)](https://pypi.org/project/tool-call-guard/)
[![license](https://img.shields.io/pypi/l/tool-call-guard)](../LICENSE)

**Deny-by-default policy gate for AI agent tool calls.** The Python half of [tool-call-guard](https://github.com/binaydhakal/tool-call-guard) — same JSON policy model and audit schema as the [npm package](https://www.npmjs.com/package/tool-call-guard), so one security review covers both stacks.

```python
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

```sh
pip install tool-call-guard
```

Zero dependencies, fully typed, Python 3.9+. Validators accept plain callables (return `True`/`False`/reason-string, or raise) or pydantic-style model classes (anything with `model_validate`).

## 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** — `maxCalls` per guard lifetime, `maxCallsPerMinute` sliding 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

```python
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](https://github.com/binaydhakal/tool-call-guard) for the full policy reference and the threat model this addresses.

## License

MIT © [Binaya Dhakal](https://www.dhakalbinaya.com.np)
