raxit
Frameworks

pydantic-ai

Governing pydantic-ai agents in Python, zero-code.

Every registered function tool in pydantic-ai executes through one method: pydantic_ai.toolsets.function.FunctionToolset.call_tool (ToolManager._raw_execute calls toolset.call_tool(name, args, ctx, tool) for every tool). raxit's auto-patcher wraps that method, so every tool call resolves to permit / defer / deny before it executes, with no governance code in the agent. Python only — pydantic-ai has no JS/TS SDK.

Run it yourself

For your own pydantic-ai agent, install the SDK and launch it under raxit run:

pip install raxit
raxit run -- python your_agent.py

Run the same file without raxit run and it runs ungoverned — governance is a deploy-time choice, not a code change.

How it works

Tools are ordinary @agent.tool_plain-registered functions with no governance code. The only "wiring" is each tool's name, the policy resource it's governed as:

# imports NOTHING from raxit
from pydantic_ai import Agent
from pydantic_ai.models.test import TestModel

def db_write(payload: str) -> str: ...

agent = Agent(TestModel())
agent.tool_plain(name="db.write")(db_write)
agent.run_sync("go")
.raxit/security.yaml
apiVersion: raxit.security/v1
agent: pydantic-ai-bot
default: block
rules:
  - effect: allow
    resource: fetch_docs
  - effect: review              # unconditional review == defer.policy
    resource: db.write
  # shell.exec is intentionally UNLISTED -> default-deny (deny.default_or_forbid).

A denied or deferred call raises raxit.autopatch.RaxitDenied from inside call_tool, carrying the reason code — the tool body never executes. Catch it like any other exception, or let it propagate to your own agent loop. raxit explain <call_id> shows the full decision record.

Real captured output from a run:

[PASS] autopatch: install() governed the pydantic-ai FunctionToolset.call_tool dispatch — patched={'pydantic-ai': ['call_tool']}
  fetch_docs   -> permit  -> ran: fetch_docs tool body ran (ledger: ['fetch_docs'])
  db.write     -> defer  -> RaxitDenied raised: reason=defer.policy
  shell.exec   -> deny   -> RaxitDenied raised: reason=deny.default_or_forbid
  audit.verify  -> ok=True signatures_verified=True signed_count=3 count=3
all checks PASS

Scope: no MODIFY here

FunctionToolset.call_tool's first positional argument is the tool name (a string), not a dict payload, so the shared engine's positional-dict MODIFY convention doesn't apply naturally — argument-rewriting verdicts aren't exercised here. Same scope note our test suite documents for this framework.

Why one Agent per tool

pydantic-ai's TestModel deterministically calls every tool registered on the Agent it drives, in one run_sync. Registering all three demo tools on one shared Agent would let a mid-run deny abort the run before a sibling permit's body could be observed — so the demo builds a fresh, single-tool Agent per resource.

What got denied and why

  • defer.policydb.write matched the review rule; it's suspended for human approval (see Approvals). The demo runs with RAXIT_DEFER_TIMEOUT=0, so the defer surfaces as a raised RaxitDenied rather than blocking inline.
  • deny.default_or_forbidshell.exec appears nowhere in the policy, so it falls to default: block. The tool body never ran, confirmed by the demo's own call log.

Every decision — the permit, the defer, and the deny — lands in one hash-chained, Ed25519-signed Decision Provenance log, verified green with client.audit.verify(signatures=True). See Audit and Decisions for the full permit / defer / deny vocabulary and reason-code catalog.

  • LangGraph (Python) — same zero-code autopatch story at a different interception point.
  • LlamaIndex — same story for LlamaIndex's FunctionTool.call interception point.

On this page