A deterministic policy gate between an LLM agent and its tools: every call is allowed or denied by code before it executes, denied by default, and logged for audit.
Project description
toolwarden
A deterministic gate between an LLM agent and its tools. Every tool call is checked against policy before it executes and is allowed or denied by code that returns the same answer every time, not by a model. Every decision is logged for audit. Zero dependencies, Python 3.11+.
The failure it prevents: an agent told "read-only on prod" or "never email outside the company" violates that instruction, and the call goes through, because those sentences are suggestions to a language model and nothing deterministic stands between the agent and the tool. toolwarden is that deterministic thing.
Install
pip install toolwarden
The core, the shipped normalizers, and the Claude Code hook are stdlib only.
Framework adapters live behind extras: toolwarden[openai-agents],
toolwarden[langchain], toolwarden[anthropic].
Quickstart
from toolwarden import (
Gate, policy, Deny, Allow, NOT_APPLICABLE, Facts, Verdict, ToolDenied,
)
from toolwarden.normalizers import (
SQL_CLASS, SqlClass, classify_sql,
RECIPIENT_DOMAINS, recipient_domains,
)
COMPANY = "ourcompany.com"
@policy("prod_db_read_only", tools=("db_exec",), needs=(SQL_CLASS,), permits=False)
def prod_db_read_only(f: Facts) -> Verdict:
if f.principal.get("env") != "production":
return NOT_APPLICABLE
if f[SQL_CLASS] is not SqlClass.READ: # WRITE and UNKNOWN both land here
return Deny("statement is not a provable read against the production database")
return NOT_APPLICABLE # the permit comes from db_read_scope
@policy("db_read_scope", tools=("db_exec",), needs=(SQL_CLASS,))
def db_read_scope(f: Facts) -> Verdict:
if f[SQL_CLASS] is SqlClass.READ:
return Allow()
return NOT_APPLICABLE
@policy("internal_email_only", tools=("send_email",), needs=(RECIPIENT_DOMAINS,))
def internal_email_only(f: Facts) -> Verdict:
external = f[RECIPIENT_DOMAINS] - {COMPANY}
if external:
return Deny(f"recipient domain(s) outside {COMPANY}: "
f"{', '.join(sorted(external))}")
return Allow()
def db_exec(query: str) -> list[dict]: ...
def send_email(to: list[str], subject: str, body: str) -> str: ...
gate = Gate(
policies=[prod_db_read_only, db_read_scope, internal_email_only],
normalizers=[classify_sql, recipient_domains],
tools=("db_exec", "send_email"),
)
guarded = gate.wrap(
{"db_exec": db_exec, "send_email": send_email},
principal={"env": "production", "agent": "support-bot"},
)
rows = guarded["db_exec"](query="SELECT id FROM orders WHERE id = 42") # allowed
try:
guarded["db_exec"](query="DELETE FROM orders") # denied
except ToolDenied as e:
print(e.refusal.message())
An allow requires zero denials and at least one explicit permit. A tool no policy names is denied. A policy that raises denies itself. Registration order cannot influence any byte of any output.
The safety invariant
A bad input must never become an allow. The mechanism is a three-state fact model:
- Computed: a normalizer turned a raw argument into a typed value.
"Absent but fine" is a value too (an empty tuple,
SqlClass.UNKNOWN). - Unavailable: the normalizer could not trust the input, or raised. This
is a value, not an exception. Any policy that needs that fact is denied by
the engine (kind
unparseable) before its body runs. - Undeclared: the policy never declared the fact, or no normalizer provides it for that tool. The gate refuses to construct.
Policy bodies therefore run only on facts that computed. There is no code path from "could not parse" to "allowed", and none of the paths depend on a policy author remembering to check anything.
The shipped normalizers carry their own adversarial hardening: the SQL
classifier lexes string literals and comments in a single left-to-right
pass, so a write keyword hidden by comment tricks or quote tricks either
surfaces as WRITE or degrades to UNKNOWN, both denied by a read-only
policy; the email normalizer accepts only single-@ addresses and compares
the whole domain after that @, so evil@sub.ourcompany.com.attacker.com
resolves to attacker.com's domain, not yours, and a multi-@ address is
refused outright rather than split; the amount normalizer rejects bools, NaN,
and infinities before any cap comparison can be sweet-talked.
Audit records
Facts are loggable, raw arguments are not. Each decision renders a
byte-stable canonical JSON body: the computed facts, the fact errors, every
policy's verdict, every denial with its reason, and shape summaries of the
arguments (type, length, truncated digest) instead of their values.
Timestamps and ids live only in the envelope, so a stored line can be
verified against a replayed decision. The policyset_sha256 fingerprint
moves whenever a policy's or normalizer's own source or declaration changes,
because a normalizer edit changes decisions as surely as a policy edit does.
It digests each body's literal text, not state that text references (a
closure variable or a module-level constant), so keep behavior-affecting
values inside the body if you rely on the fingerprint to track them.
Attach points
gate.wrap(tools, principal=...)guards plain callables, sync or async, binding arguments through the tool's own signature.toolwarden.adapters.openai_agents.toolwarden_guardrailfor the OpenAI Agents SDK, withassert_all_guardedto turn unguarded tools into a boot failure.toolwarden.adapters.anthropic_loop.run_tool_usesexecutes the tool_use blocks of an Anthropic message under the gate.toolwarden.adapters.langchain.toolwarden_tool_wrapperfor LangChain'swrap_tool_callmiddleware hook.toolwarden-hook, a Claude Code PreToolUse hook that tightens, never loosens, and converts its own crashes into explicit denies.
The denied model always receives the same payload: which policies denied, every reason, and a decision id that joins the transcript to the audit line.
Evidence
Every number below is produced by one offline command, stdlib only, no install step:
python3 evidence/recompute.py
From the current committed run (evidence/results.json): 61 cases, of
which 34 are adversarial evasion attempts (comment-split, comment-quote, and
MySQL executable-comment SQL, the backtick and dollar-quote paired-phantom
attacks, the PostgreSQL # operator, stacked statements, the domain suffix
attack, string-not-list PHI tags, bool and Infinity amounts, malformed and
ungoverned calls).
Adversarial calls allowed: 0. Planted secret values leaked into any record,
envelope, or refusal: 0. Record-body mismatches across reversed
registration orders: 0. All six denial kinds are exercised.
What v0.1 does not do
No stateful facts (rate limits, per-day spend), no MCP proxy, no output
governance, no Cedar export. The healthcare angle is one worked policy
(phi_minimum_necessary); the full pack is v0.2. Decisions and rejected
alternatives are recorded in docs/DESIGN.md.
License
MIT.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file toolwarden-0.1.0.tar.gz.
File metadata
- Download URL: toolwarden-0.1.0.tar.gz
- Upload date:
- Size: 132.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f4f109446657e236d5d3684f202de8674da62ff90df936f971fafff415052f24
|
|
| MD5 |
78e5170f5c5432e0c492df66b80c9d7c
|
|
| BLAKE2b-256 |
50ba4e3365755a995bc07eef8496144dd5bd78346aaf41afa8e03f580b9f056a
|
Provenance
The following attestation bundles were made for toolwarden-0.1.0.tar.gz:
Publisher:
release.yml on amirfandev/toolwarden
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toolwarden-0.1.0.tar.gz -
Subject digest:
f4f109446657e236d5d3684f202de8674da62ff90df936f971fafff415052f24 - Sigstore transparency entry: 2258179261
- Sigstore integration time:
-
Permalink:
amirfandev/toolwarden@2f5d2bce4219b33cbf9f3e3af09d61e1f6b28f09 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/amirfandev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2f5d2bce4219b33cbf9f3e3af09d61e1f6b28f09 -
Trigger Event:
release
-
Statement type:
File details
Details for the file toolwarden-0.1.0-py3-none-any.whl.
File metadata
- Download URL: toolwarden-0.1.0-py3-none-any.whl
- Upload date:
- Size: 58.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c7f66fc1a64b2e941a746fe89fdcf01e31749d9c4933174acec21a5a04f985a
|
|
| MD5 |
fa2f79d59d67e413f019b8f5b0fdabd9
|
|
| BLAKE2b-256 |
fa8c0c98c49f0d3ec36c49e7eb7e7491cdf02ddf604a584cb0c79d17bfa31812
|
Provenance
The following attestation bundles were made for toolwarden-0.1.0-py3-none-any.whl:
Publisher:
release.yml on amirfandev/toolwarden
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
toolwarden-0.1.0-py3-none-any.whl -
Subject digest:
4c7f66fc1a64b2e941a746fe89fdcf01e31749d9c4933174acec21a5a04f985a - Sigstore transparency entry: 2258179553
- Sigstore integration time:
-
Permalink:
amirfandev/toolwarden@2f5d2bce4219b33cbf9f3e3af09d61e1f6b28f09 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/amirfandev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@2f5d2bce4219b33cbf9f3e3af09d61e1f6b28f09 -
Trigger Event:
release
-
Statement type: