Docs/Adapters/SecurePydanticAI

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.

pydantic-ai v2.xModel + ToolsPydantic AI Docs →

Quick Start

Before
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?")
After
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 suppliedGoverned by MAPLExecuted by
tools=[fn]YesMACAW
@agent.tool_plainYesMACAW
toolsets=[FunctionToolset(...)]YesMACAW
@agent.tool (takes RunContext)Yesthe toolset
toolsets=[MCPToolset(...)]Yesthe 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:

ParameterTypeDescription
app_namestrApplication identity. Determines resource names. Defaults to secure-pydantic-app.
intent_policydictDeclared intent. Narrows what the workspace policy already permits.
jwt_tokenstrCreates a user-mode agent with this identity.
user_namestrUser 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:

ResourceCoversConstrainable parameters
tool:<app>/generateEvery model request, streaming includedmodel, max_tokens, temperature, native_tools
tool:<app>/<tool_name>One per tool the agent can callthe 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

DeniedBehaviour
A model callPermissionDenied is raised. A FallbackModel can catch it and move to the next candidate.
A tool callRaised 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


Related Topics