SecurePydanticAI
Drop-in replacement for Pydantic AI's Agent. Model calls and tool calls both become MACAW resources, so a single MAPL policy governs which model an agent may use, how many tokens it may spend, which tools it may call, and whether provider-side tools are allowed.
Quick Start
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
agent = Agent(
OpenAIChatModel("gpt-4o-mini"),
tools=[query_catalog],
)
result = agent.run_sync("Which tables contain PII?")from macaw_adapters.pydantic_ai import SecureAgent
from pydantic_ai.models.openai import OpenAIChatModel
agent = SecureAgent(
OpenAIChatModel("gpt-4o-mini"),
app_name="catalog-agent",
tools=[query_catalog],
)
result = agent.run_sync("Which tables contain PII?")One Import, One Argument
SecureAgent subclasses pydantic_ai.Agent, so every method, decorator and keyword argument works unchanged. An Agent alias is also exported if you prefer to keep the original name and change only the import line.
Installation
pip install "macaw-adapters[pydantic-ai]"Requires the MACAW Client Library and a running LocalAgent. See Quick Start.
What Is Governed
Pydantic AI accepts tools in several ways. All of them are governed by MAPL — every tool call is an invoke_tool that is evaluated, signed and audited before anything runs. Where MACAW can hold the callable it also executes it, so the response is captured alongside the request.
| How tools are supplied | Governed by MAPL | Executed by |
|---|---|---|
| tools=[fn] | Yes | MACAW |
| @agent.tool_plain | Yes | MACAW |
| toolsets=[FunctionToolset(...)] | Yes | MACAW |
| @agent.tool (takes RunContext) | Yes | the toolset |
| toolsets=[MCPToolset(...)] | Yes | the MCP server |
Per-call overrides cannot route around policy
run(), run_sync(), run_stream() and override() all accept a per-call model or toolset. SecureAgent wraps whatever is passed to them, so an override is governed exactly like the agent's own configuration.
Model Routing
Pydantic AI's FallbackModel tries each candidate until one succeeds. SecureAgent secures the candidates rather than the router, so policy sees the model that would actually call the provider — and a MACAW denial is simply another reason to move to the next candidate.
from macaw_client import PermissionDenied
from pydantic_ai.models.fallback import FallbackModel
router = FallbackModel(
OpenAIChatModel("gpt-4o"), # preferred
OpenAIChatModel("gpt-4o-mini"), # fallback
fallback_on=(PermissionDenied,),
)
agent = SecureAgent(router, app_name="catalog-agent", intent_policy={
"resources": ["tool:catalog-agent/generate"],
"constraints": {
"parameters": {
"tool:catalog-agent/generate": {"model": ["gpt-4o-mini"]}
}
},
})
# gpt-4o is denied by policy, so the router serves gpt-4o-mini.
# The run succeeds instead of failing.One lever, three outcomes
The same model constraint governs cost tier, data residency (through region-pinned model ids) and access — and degrades gracefully rather than failing the run.
Provider-Side Tools
Native tools such as web search and code execution run at the model provider, not in your process, so they never reach a toolset. SecurePydanticAI reports the native tools offered on each request as a parameter of tool:<app>/generate, so MAPL can refuse the call before the provider is contacted.
{
"constraints": {
"denied_parameters": {
"tool:catalog-agent/generate": {
"native_tools": ["*web_search*", "*code_execution*"]
}
}
}
}Prevention, not detection
The denial happens before the model call is made, so nothing is searched and nothing is executed. Without a rule, the request reaches the provider as normal.
Multi-User: bind_to_user()
Build one agent for the service, then bind it to each user. The registration and the tools are shared; only the caller identity differs, so MACAW resolves each user's policy on every model and tool call.
from macaw_client import MACAWClient, RemoteIdentityProvider
# One service agent, shared by every user
service = SecureAgent(
OpenAIChatModel("gpt-4o-mini"),
app_name="catalog-agent",
tools=[query_catalog, export_dataset],
)
# Authenticate a user and bind
jwt_token, _ = RemoteIdentityProvider().login("alice", password)
alice = MACAWClient(user_name="alice", iam_token=jwt_token, agent_type="user")
alice.register()
alice_agent = service.bind_to_user(alice)
result = alice_agent.run_sync("Query the customers table.")Constructor
SecureAgent accepts every pydantic_ai.Agent argument, plus:
| Parameter | Type | Description |
|---|---|---|
| app_name | str | Application identity. Determines resource names. Defaults to secure-pydantic-app. |
| intent_policy | dict | Declared intent. Narrows what the workspace policy already permits. |
| jwt_token | str | Creates a user-mode agent with this identity. |
| user_name | str | User name for user mode. |
MAPL is restrict-only. An intent_policy can narrow what an application may do but never widen it, so the resource must be granted in the workspace policy first.
MAPL Tool Names
SecurePydanticAI registers two kinds of resource:
| Resource | Covers | Constrainable parameters |
|---|---|---|
| tool:<app>/generate | Every model request, streaming included | model, max_tokens, temperature, native_tools |
| tool:<app>/<tool_name> | One per tool the agent can call | the tool's own arguments |
The conversation is declared as an authenticated prompt, so every model request carries a cryptographically bound prompt lineage.
How Denials Surface
| Denied | Behaviour |
|---|---|
| A model call | PermissionDenied is raised. A FallbackModel can catch it and move to the next candidate. |
| A tool call | Raised as ToolFailed, so the model sees the denial and adapts. The tool never runs and the run still completes. |
Official API Reference
SDK Compatibility: pydantic-ai ≥2.22,<3