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.pyRun 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")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 PASSScope: 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.policy—db.writematched thereviewrule; it's suspended for human approval (see Approvals). The demo runs withRAXIT_DEFER_TIMEOUT=0, so the defer surfaces as a raisedRaxitDeniedrather than blocking inline.deny.default_or_forbid—shell.execappears nowhere in the policy, so it falls todefault: 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.
Related
- LangGraph (Python) — same zero-code autopatch story at a different interception point.
- LlamaIndex — same story for LlamaIndex's
FunctionTool.callinterception point.

