Skip to main content

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_guardrail for the OpenAI Agents SDK, with assert_all_guarded to turn unguarded tools into a boot failure.
  • toolwarden.adapters.anthropic_loop.run_tool_uses executes the tool_use blocks of an Anthropic message under the gate.
  • toolwarden.adapters.langchain.toolwarden_tool_wrapper for LangChain's wrap_tool_call middleware 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

toolwarden-0.1.0.tar.gz (132.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

toolwarden-0.1.0-py3-none-any.whl (58.4 kB view details)

Uploaded Python 3

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

Hashes for toolwarden-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f4f109446657e236d5d3684f202de8674da62ff90df936f971fafff415052f24
MD5 78e5170f5c5432e0c492df66b80c9d7c
BLAKE2b-256 50ba4e3365755a995bc07eef8496144dd5bd78346aaf41afa8e03f580b9f056a

See more details on using hashes here.

Provenance

The following attestation bundles were made for toolwarden-0.1.0.tar.gz:

Publisher: release.yml on amirfandev/toolwarden

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for toolwarden-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4c7f66fc1a64b2e941a746fe89fdcf01e31749d9c4933174acec21a5a04f985a
MD5 fa2f79d59d67e413f019b8f5b0fdabd9
BLAKE2b-256 fa8c0c98c49f0d3ec36c49e7eb7e7491cdf02ddf604a584cb0c79d17bfa31812

See more details on using hashes here.

Provenance

The following attestation bundles were made for toolwarden-0.1.0-py3-none-any.whl:

Publisher: release.yml on amirfandev/toolwarden

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page