Skip to main content

Secure a PydanticAI agent with Permit.io

Add Permit.io permission checks to the tools of a PydanticAI financial advisor agent. This tutorial is for AI agent builders who use PydanticAI and want each tool call to respect the user's permissions. It applies all four perimeters of the Four-Perimeter Framework, and the source is in permitio/Permit-PydanticAI.

PerimeterAgent toolPermit call
Prompt filteringvalidate_financial_querypermit.check() for receive on financial_advice
RAG data protectionaccess_financial_knowledgepermit.filter_objects() for read on financial_document
Secure external accesscheck_action_permissionspermit.check() for update on portfolio
Response enforcementvalidate_financial_responsepermit.check() for requires_disclaimer on financial_response

Prerequisites and tech stack

Tool or servicePurpose
PydanticAIAgent framework with typed tools and results
Permit.io account and an environment API key (Get your API key)Stores the policy
An Anthropic API keyPydanticAI reads the key from ANTHROPIC_API_KEY. Set the model string in example/main.py to a model that is Active on the Claude model deprecations page, such as anthropic:claude-sonnet-4-6. See step 5 of Run the agent and verify the perimeters. To use OpenAI instead, set the model string to an OpenAI model.
DockerRuns the Edge policy decision point (PDP). The policy uses attribute-based access control (ABAC), which the Cloud PDP doesn't evaluate. See Cloud PDP capabilities.
Python 3.11 or laterRuns the agent. pyproject.toml declares requires-python = ">=3.9", but example/config.py imports typing.NotRequired, which Python added in 3.11, so the script fails to import on 3.9 and 3.10. The repository's .python-version pins 3.13.
uvInstalls the example's dependencies
PydanticAI versions

The example requires pydantic-ai>=0.0.20 and uses the result_type argument and result.data. Later PydanticAI releases renamed some of these APIs. If the agent fails to start, check the PydanticAI changelog against the installed version.

Required environment variables

The example reads the Permit settings from these variables. It uses PERMIT_KEY, not PERMIT_API_KEY:

PERMIT_KEY=<your-permit-api-key>
PDP_URL=http://localhost:7766
ANTHROPIC_API_KEY=<your-anthropic-api-key>
VariableValue
PERMIT_KEYYour environment API key
PDP_URLYour Edge PDP. example/main.py falls back to http://localhost:7766 when PDP_URL isn't set.
ANTHROPIC_API_KEYYour Anthropic API key, for the agent model

The example loads a .env file with python-dotenv:

from dotenv import load_dotenv
load_dotenv()

Plan the access model

The financial advisor agent aims for four rules:

PerimeterRule
Prompt filteringGive AI-generated advice only to users who opted in
RAG data protectionShow confidential documents only to users with high clearance
Secure external accessAllow portfolio actions only for premium users
Response enforcementAdd a disclaimer to responses that contain advice

The policy that example/config.py creates does not reach the first two rules for a premium_user, because the role grants financial_advice:receive and financial_document:read on the whole resource type. See The premium_user role overrides the attribute rules.

Resources

example/config.py creates these resources:

ResourceActionsAttributes
financial_advicereceiveis_ai_generated (bool), risk_level (string)
financial_documentreaddoc_type, classification (public, restricted, confidential), clearance_required
portfolioupdate, read, analyzeowner_id, value_tier
financial_responserequires_disclaimercontains_advice (bool), risk_level (string)

User attributes

AttributeTypeDescription
clearance_levelstringThe user's clearance: low or high
ai_advice_opted_inboolWhether the user accepted AI-generated financial advice

Condition sets

example/config.py creates two user sets and two resource sets:

SetTypeCondition
opted_in_usersUser setuser.ai_advice_opted_in equals true
high_clearance_usersUser setuser.clearance_level equals "high"
confidential_docsResource set on financial_documentresource.classification equals "confidential"
finance_adviceResource set on financial_adviceresource.is_ai_generated equals true

Roles and rules

RolePermissions created by config.py
premium_userfinancial_advice:receive, financial_document:read, portfolio:update, portfolio:read, portfolio:analyze
restricted_userNone

The example defines two condition set rules: opted_in_users can receive on finance_advice, and high_clearance_users can read on confidential_docs. config.py lists these rules but doesn't create them, so grant them in the Policy Editor.

No role or rule in config.py grants requires_disclaimer on financial_response. The response enforcement tool adds a disclaimer only when that check is allowed, so grant requires_disclaimer to premium_user in the Policy Editor.

The premium_user role overrides the attribute rules

config.py builds premium_user from a flat permission list, so the role grants financial_advice:receive and financial_document:read on the whole resource type. Policy Editor rules are allow rules, and a check is allowed when any rule allows it (see Mix and Match Policies). Those two role grants therefore allow every advice check and every document check for a premium_user, whatever ai_advice_opted_in and clearance_level hold on the user and whatever classification the document carries.

Two consequences follow:

  • The opted_in_users and high_clearance_users rules change no decision for user@example.com. Setting ai_advice_opted_in to false or clearance_level to low on that user leaves every result in this tutorial the same.
  • restricted@example.com is denied everything because restricted_user has no permissions, not because of its attribute values.

To let the attributes decide, clear receive on financial_advice and read on financial_document for premium_user in the Policy Editor and keep the two condition set rules. Each permission then comes only from a condition set rule: receive needs ai_advice_opted_in to be true and is_ai_generated to be true, and read needs clearance_level to be high and classification to be confidential. Requests outside those two sets, such as a question that isn't advice or a public document, then have no rule that allows them. Add the resource sets and rules you want for those cases before you remove the role grants. See Build ABAC policies.

Run the Edge PDP

Pull and start the PDP. Replace <YOUR_API_KEY> with your environment API key:

docker pull permitio/pdp-v2:latest

docker run -it -p 7766:7000 \
--env PDP_API_KEY=<YOUR_API_KEY> \
--env PDP_DEBUG=True \
permitio/pdp-v2:latest

The PDP listens on http://localhost:7766.

Configure the policy in Permit

Create the policy with the setup script, or by hand in the Permit dashboard.

Create the policy with the setup script

example/config.py creates the resources, the user attributes, the premium_user and restricted_user roles, and the four condition sets. The script doesn't create users, role assignments, or the condition set rules. Run it from the repository root:

uv run python example/config.py

The script reads PERMIT_KEY from the environment.

Create the policy in the dashboard

  1. Open Policy > Resources and create the four resources with the actions and attributes listed in Resources.

    Resources tab with the financial_advice, financial_document, portfolio, and financial_response resources

  2. Open Directory > Users > Settings and add the two user attributes listed in User attributes.

    User attribute settings in the Directory screen

    User attributes list after adding the attributes

  3. Open Policy > ABAC Rules and create the user sets from Condition sets: opted-in users who can receive AI advice, and high-clearance users.

    User set form for opted-in users

    User set form for high-clearance users

  4. On the same tab, create the confidential_docs and finance_advice resource sets from Condition sets.

    Resource set form for confidential documents

    Resource set form for AI-generated advice

  5. In the Policy Editor, grant the role permissions and the condition set rules from Roles and rules.

    Policy Editor with the roles, user sets, and resource sets configured

  6. Confirm the policy in the Policy Editor: the premium_user row has receive, read, update, and analyze checked across the four resources, the restricted_user row has nothing checked, and the opted_in_users and high_clearance_users rows each have one action checked on their resource set.

Create the test users

In the Directory screen, create the users that config.py describes but doesn't create, and assign each role in the default tenant. See Sync users.

User keyRoleclearance_levelai_advice_opted_in
user@example.compremium_userhightrue
restricted@example.comrestricted_userlowfalse

example/main.py runs the agent as user@example.com. A user key that doesn't exist in Permit gets denied on every check, so create both users before you run the agent.

Perimeter 1: Filter prompts

Prompt filtering rejects a request before the agent acts on it. The validate_financial_query tool classifies whether the question asks for advice, then checks receive on financial_advice with is_ai_generated set to the classification result.

How prompt filtering works

  1. classify_prompt_for_advice detects whether the question asks for financial advice.
  2. permit.check() asks the PDP whether the user can receive financial_advice with that is_ai_generated value. Two rules can allow it: the premium_user role grant on financial_advice:receive, and the opted_in_users rule on the finance_advice resource set.
  3. When the PDP denies the check, the tool returns a denial string to the agent, which the agent relays in its answer. When the PDP allows the check, the tool returns True and the agent continues.
@financial_agent.tool
async def validate_financial_query(
ctx: RunContext[PermitDeps],
query: FinancialQuery,
) -> bool | str:
"""SECURITY PERIMETER 1: Prompt Filtering.

Validates whether users have explicitly consented to receive AI-generated financial advice.
"""
try:
# Classify the user's intent.
is_seeking_advice = classify_prompt_for_advice(query.question)

# Ask the PDP whether this user may receive this kind of advice.
permitted = await ctx.deps.permit.check(
user=ctx.deps.user_id,
action='receive',
resource={
'type': 'financial_advice',
'attributes': {'is_ai_generated': is_seeking_advice},
},
)

if not permitted:
if is_seeking_advice:
return 'User has not opted in to receive AI-generated financial advice'
else:
return 'User does not have permission to access this information'

return True

except PermitApiError as e:
raise SecurityError(f'Permission check failed: {str(e)}')

Classify financial prompts

The example classifies prompts with a keyword list:

def classify_prompt_for_advice(question: str) -> bool:
advice_keywords = [
'should i',
'recommend',
'advice',
'suggest',
'help me',
"what's best",
'what is best',
'better option',
]
question_lower = question.lower()
return any(keyword in question_lower for keyword in advice_keywords)

Expected prompt filtering results

UserPromptResultRule that decides
user@example.com (premium_user, opted in)"Can you recommend a bond fund?"AllowedThe premium_user role grant on financial_advice:receive, not the opt-in rule
restricted@example.com (restricted_user, not opted in)"Can you recommend a bond fund?"Denied. The tool returns the string User has not opted in to receive AI-generated financial advice.restricted_user has no permissions

The opt-in rule only changes the outcome after you clear the premium_user role grant on financial_advice. See The premium_user role overrides the attribute rules.

Perimeter 2: Filter documents

Retrieval-augmented generation (RAG) data protection limits the documents the agent can use to the ones the user can read. The access_financial_knowledge tool sends every candidate document, with its classification and doc_type attributes, to permit.filter_objects(), and returns the allowed documents only.

How document filtering works

  1. The agent passes a list of candidate documents to the tool.
  2. access_financial_knowledge builds one resource dictionary per document, with the document ID in key and the document attributes in attributes. permit.filter_objects() reads key, type, attributes, context, and tenant from each dictionary, so the ID goes in key.
  3. filter_objects runs one bulk check for read on financial_document, one entry per document.
  4. filter_objects returns the resource dictionaries the PDP allowed, and the tool maps their key values back to the documents it hands the agent.
@financial_agent.tool
async def access_financial_knowledge(
ctx: RunContext[PermitDeps],
usr: UserContext,
documents: list[FinancialDocument],
) -> list[FinancialDocument]:
"""SECURITY PERIMETER 2: Data Protection.

Filters knowledge base access based on document classification and user clearance.
"""
try:
# One resource dictionary per candidate document.
resources = [
{
'key': doc.id,
'type': 'financial_document',
'attributes': {
'doc_type': doc.type,
'classification': doc.classification,
},
}
for doc in documents
]

# filter_objects runs one check per resource and returns the allowed ones.
allowed_docs = await ctx.deps.permit.filter_objects(
user=ctx.deps.user_id,
action='read',
context={},
resources=resources,
)

allowed_ids = {doc['key'] for doc in allowed_docs}
return [doc for doc in documents if doc.id in allowed_ids]

except PermitApiError as e:
raise SecurityError(f'Failed to filter documents: {str(e)}')

Document model and classifications

Each document has a classification:

class FinancialDocument(BaseModel):
"""Model for financial documents with classification levels."""

id: str
type: str = Field(..., description="Document type (e.g., 'investment', 'tax', 'retirement')")
content: str
classification: str = Field(
...,
description='Document classification level (public, restricted, confidential)',
)

access_financial_knowledge sends classification as a resource attribute on every check, which is what the confidential_docs resource set matches on.

Expected document filtering results

UserDocument classificationResultRule that decides
user@example.com (premium_user, clearance_level: high)confidentialAllowedThe premium_user role grant on financial_document, not the clearance rule
user@example.com (premium_user, clearance_level: high)publicAllowedThe premium_user role grant on financial_document
restricted@example.com (restricted_user, clearance_level: low)confidentialDeniedrestricted_user has no permissions
restricted@example.com (restricted_user, clearance_level: low)publicDeniedrestricted_user has no permissions

The clearance rule only changes the outcome after you clear the premium_user role grant on financial_document. See The premium_user role overrides the attribute rules.

Perimeter 3: Authorize external actions

Secure external access checks permission before the agent changes data in another system, such as updating a portfolio, moving money, or calling a webhook. The check_action_permissions tool checks the requested action on portfolio:

@financial_agent.tool
async def check_action_permissions(
ctx: RunContext[PermitDeps],
action: str,
context: UserContext,
portfolio_id: str,
) -> bool:
"""SECURITY PERIMETER 3: Secure External Access.

Controls permissions for sensitive financial operations, such as modifying a portfolio.
"""
try:
return await ctx.deps.permit.check(
user=ctx.deps.user_id,
action=action,
resource={'type': 'portfolio', 'key': portfolio_id},
)
except PermitApiError as e:
raise SecurityError(f'Failed to check portfolio permission: {str(e)}')

The check runs before the agent calls the external system, so a denial stops the call instead of reverting it afterwards. The portfolio_id goes in the resource key, which puts the portfolio ID in the audit log entry for the check.

The version of check_action_permissions in the example repository takes the same arguments but ignores action and always checks update. Pass action through, as the block above does, if you want the agent to ask about read and analyze as well.

Expected portfolio results

UserActionResult
user@example.com (premium_user)update on portfolioAllowed
restricted@example.com (restricted_user)update on portfolioDenied

Call an external API after the check

Call the external API only when the check returns True:

import httpx
from permit import Permit


async def update_portfolio(
permit: Permit,
user_id: str,
portfolio_id: str,
payload: dict[str, str],
) -> dict[str, str]:
"""Update a portfolio through an external API, but only if Permit allows it."""
permitted = await permit.check(
user=user_id,
action='update',
resource={'type': 'portfolio', 'key': portfolio_id},
)

if not permitted:
raise SecurityError(f'{user_id} is not allowed to update portfolio {portfolio_id}')

async with httpx.AsyncClient(base_url='https://api.example.com') as client:
response = await client.put(f'/portfolios/{portfolio_id}', json=payload)
response.raise_for_status()
return response.json()

Perimeter 4: Enforce the response

Response enforcement checks the final answer before the user sees it. The validate_financial_response tool classifies whether the answer contains advice, checks requires_disclaimer on financial_response, and appends a disclaimer when the answer contains advice and the check allows it.

contains_advice is declared as a bool attribute in example/config.py, so pass the Python bool rather than a string. No role in config.py grants requires_disclaimer, so this check is denied and no disclaimer is added until you grant requires_disclaimer on financial_response to premium_user in the Policy Editor.

@financial_agent.tool
async def validate_financial_response(
ctx: RunContext[PermitDeps],
response: FinancialResponse,
) -> FinancialResponse:
"""SECURITY PERIMETER 4: Response Enforcement.

Ensures all financial advice responses meet regulatory requirements
and include necessary disclaimers.
"""
try:
contains_advice = classify_response_for_advice(response.answer)

permitted = await ctx.deps.permit.check(
user=ctx.deps.user_id,
action='requires_disclaimer',
resource={
'type': 'financial_response',
'attributes': {'contains_advice': contains_advice},
},
)

if contains_advice and permitted:
disclaimer = (
'\n\nIMPORTANT DISCLAIMER: This is AI-generated financial advice. '
'This information is for educational purposes only and should not be '
'considered as professional financial advice. Always consult with a '
'qualified financial advisor before making investment decisions.'
)
response.answer += disclaimer
response.disclaimer_added = True
response.includes_advice = True

return response

except PermitApiError as e:
raise SecurityError(f'Failed to check response content: {str(e)}')

Classify responses

The example detects advice in a response with a keyword list:

def classify_response_for_advice(response_text: str) -> bool:
advice_indicators = [
'recommend',
'should',
'consider',
'advise',
'suggest',
'better to',
'optimal',
'best option',
'strategy',
'allocation',
]
response_lower = response_text.lower()
return any(indicator in response_lower for indicator in advice_indicators)

A keyword list matches substrings, so it flags an answer that contains the word "should" in a sentence that gives no advice. Replace it with an intent classifier or a moderation API before you rely on it in production.

Run the agent and verify the perimeters

  1. Start the Edge PDP. Replace <YOUR_API_KEY> with your environment API key:

    docker pull permitio/pdp-v2:latest

    docker run -it -p 7766:7000 \
    --env PDP_DEBUG=True \
    --env PDP_API_KEY=<YOUR_API_KEY> \
    permitio/pdp-v2:latest
  2. Verify the PDP answers its health check:

    curl -s -o /dev/null -w '%{http_code}\n' http://localhost:7766/healthy

    A healthy PDP prints 200. See Verify the PDP is healthy.

  3. Clone the example and install its dependencies:

    git clone https://github.com/permitio/Permit-PydanticAI.git
    cd Permit-PydanticAI
    uv sync --all-extras
  4. Set the environment variables before you run the agent. Put them in a .env file in the repository root, which example/main.py loads with python-dotenv, or export them in the shell:

    PERMIT_KEY=your-permit-api-key
    PDP_URL=http://localhost:7766
    ANTHROPIC_API_KEY=your-anthropic-api-key

    example/main.py raises ValueError: PERMIT_KEY environment variable not set and exits if PERMIT_KEY is missing.

  5. Replace the model string in example/main.py.

    example/main.py builds the agent with Agent[PermitDeps, FinancialResponse]('anthropic:claude-3-5-sonnet-latest', ...). Anthropic retired the Claude Sonnet 3.5 models on October 28, 2025, and a request to a retired model fails, so the agent returns an error instead of an answer until you change the string. Set it to a model listed as Active on the Claude model deprecations page, which names claude-sonnet-4-6 as the replacement for Claude Sonnet 3.5:

    agent = Agent[PermitDeps, FinancialResponse]('anthropic:claude-sonnet-4-6', ...)

    Check the deprecations page for the current Active models before you run the agent.

  6. Run the agent from the repository root:

    uv run python example/main.py

    The script runs two queries as user@example.com and prints the FinancialResponse for each one:

    Secure response: answer='...' includes_advice=True disclaimer_added=True metadata=None
    Protected document access: answer='...' includes_advice=False disclaimer_added=False metadata=None

    The answer text comes from the model, so it differs between runs. The includes_advice and disclaimer_added flags come from the permission checks: disclaimer_added=True on the first response means validate_financial_response classified the answer as advice and the PDP allowed requires_disclaimer on financial_response.

  7. Run the agent as the restricted user. Change user_id in the PermitDeps(...) call in example/main.py from user@example.com to restricted@example.com, then run uv run python example/main.py again. validate_financial_query returns its denial string to the agent instead of True, so the first answer reports that the user has not opted in, and disclaimer_added stays False.

  8. Open audit logs in the Permit dashboard. Each run adds one entry per check, so you can see which tool ran which check and whether the PDP allowed it. Try these prompts and compare the entries between the two users:

    • "What's the best investment strategy in a recession?"
    • "Can I get a breakdown of my current portfolio?"
    • "What documents do I need to file taxes as a freelancer?"

Expected results

Requestuser@example.com (premium_user)restricted@example.com (restricted_user)
Ask for financial adviceAllowed. The answer includes the disclaimer once you grant requires_disclaimer on financial_response.Denied. validate_financial_query returns User has not opted in to receive AI-generated financial advice.
Read confidential documentsAllowed, through the premium_user role grant on financial_documentDenied. restricted_user has no permissions.
Read public documentsAllowedDenied
Update a portfolioAllowedDenied
Ask a question that isn't adviceAllowed, through the premium_user grant on financial_advice:receiveDenied. validate_financial_query returns User does not have permission to access this information.

Next steps