Deny.sh vault entries as Pydantic AI tools.
Pydantic AI's @agent.tool decorator and first-class dependency injection make it the cleanest Python framework for the deniable pattern. Register a tool, resolve the credential inside its scope, return a narrowed DTO. For multi-tenant platforms, inject per-tenant vault passwords through deps_type — Pydantic AI passes them to every tool call automatically, with no global state.
Prerequisites
pip install pydantic-ai deny-sh requests- A deny.sh tenant API key + a vault entry under it (see /dashboard/vault).
- A vault wrap password (
VAULT_PW) in your server environment — never the agent prompt.
Simple tool: @agent.tool_plain
Use @agent.tool_plain when the tool needs no dependency injection — just args from the model and the environment. This is the minimal deniable shape.
Show code — ~26 lines, python
# pip install pydantic-ai deny-sh requests
import asyncio, os, requests, json
from pydantic_ai import Agent
from deny_sh import vault_get
agent = Agent(
'openai:gpt-5.4',
system_prompt="You are a billing assistant.",
)
@agent.tool_plain
def get_invoice(invoice_id: str) -> str:
"""Look up a Stripe invoice by id. Returns status and amount due."""
# resolve credential INSIDE the tool boundary
stripe_key = vault_get("stripe-prod", os.environ["VAULT_PW"])
r = requests.get(
f"https://api.stripe.com/v1/invoices/{invoice_id}",
headers={"Authorization": f"Bearer {stripe_key}"},
)
body = r.json()
# narrowed DTO: key never reaches the LLM context
return json.dumps({"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")})
async def main():
result = await agent.run("Look up invoice in_1Abc123XYZ")
print(result.output)
asyncio.run(main())Why this is the deniable shape
The Stripe key is resolved and consumed entirely inside get_invoice's scope. Pydantic AI passes the model-provided args (invoice_id) in and receives only the narrowed return string. The key never appears in the agent's message history, the model's context window, or any upstream wire. The narrowed DTO prevents leaking raw response headers or error stacks to the model.
Multi-tenant: @agent.tool + dependency injection
Pydantic AI's deps_type is purpose-built for this. Define a @dataclass Deps holding the per-tenant vault password and deny.sh API key, declare it on the Agent, and Pydantic AI injects it into every @agent.tool call via RunContext. No global state. No thread-local hacks. Each request carries exactly its tenant's credentials.
Show code — ~38 lines, python
import asyncio, requests, json
from dataclasses import dataclass
from pydantic_ai import Agent, RunContext
from deny_sh import vault_get
@dataclass
class Deps:
tenant_pw: str # per-tenant vault wrap password
deny_api_key: str # per-tenant deny.sh API key
agent = Agent(
'openai:gpt-5.4',
deps_type=Deps,
system_prompt="You are a billing assistant.",
)
@agent.tool
async def get_invoice(ctx: RunContext[Deps], invoice_id: str) -> str:
"""Look up a Stripe invoice by id. Returns status and amount due."""
# deps carry per-tenant credentials; no global fallback possible
stripe_key = vault_get(
"stripe-prod",
ctx.deps.tenant_pw,
api_key=ctx.deps.deny_api_key,
)
r = requests.get(
f"https://api.stripe.com/v1/invoices/{invoice_id}",
headers={"Authorization": f"Bearer {stripe_key}"},
)
body = r.json()
# narrowed DTO: only the fields the model needs
return json.dumps({"id": body.get("id"), "amount_due": body.get("amount_due"),
"status": body.get("status")})
async def handle_request(user_msg: str, tenant_pw: str, deny_api_key: str):
deps = Deps(tenant_pw=tenant_pw, deny_api_key=deny_api_key)
result = await agent.run(user_msg, deps=deps)
return result.output
# per-request invocation — each tenant gets its own deps
asyncio.run(handle_request(
"Look up invoice in_1Abc123XYZ",
tenant_pw="tenant-vault-password",
deny_api_key="dk_live_...",
))Deps scopes credentials to a single agent.run() call. A leaked vault password for tenant A cannot decrypt tenant B's entries — cryptographic isolation is per-tenant. Pydantic AI's RunContext makes this the natural, idiomatic path rather than a workaround.
Production-scale notes
For high-throughput deployments, a few patterns apply directly on top of the multi-tenant shape above.
- The
Agentinstance is safe to share across requests — it holds no credential state. Instantiate once at module level; build a freshDepsper request. - For FastAPI / Starlette, extract the tenant's
deny_api_keyandtenant_pwfrom your auth middleware and pass them asDepsintoagent.run()inside the route handler. - Each tenant's vault entries are independently encrypted. A compromise of one tenant's vault password does not affect other tenants' entries.
- For parallel tool calls (Pydantic AI supports concurrent tool execution), each concurrent
@agent.toolinvocation receives the sameRunContext— credentials are injected correctly regardless of concurrency. - Use
agent.run_sync()as a convenience wrapper in synchronous contexts, but preferawait agent.run()inside an async framework to avoid blocking the event loop during the vault round-trip.
Troubleshooting
The first five errors a launch-day developer hits with this stack. Click to expand the fix.
AttributeError: 'RunResult' object has no attribute 'data'
result.data was the output accessor in older Pydantic AI releases. It was renamed to result.output in a later release to better reflect that the value is the agent's final output, not raw data.
Fix: replace every result.data with result.output. If you need to support both old and new versions temporarily: getattr(result, 'output', result.data). Pin pydantic-ai>=0.0.36 and use result.output consistently.
TypeError: get_invoice() missing 1 required positional argument: 'ctx'
You decorated the function with @agent.tool but defined it without a RunContext first argument, or vice versa. The two decorators have different calling conventions: @agent.tool always passes RunContext as the first positional arg; @agent.tool_plain passes only the model-provided args.
Fix: if you don't need deps, switch to @agent.tool_plain and remove the ctx parameter. If you do need deps, keep @agent.tool and ensure the signature is def get_invoice(ctx: RunContext[Deps], invoice_id: str).
UserError: deps_type is not set but deps were passed to run() or opposite
Pydantic AI enforces that deps_type on the Agent and the deps= argument to agent.run() agree. If you pass deps=Deps(...) but forgot deps_type=Deps on the agent, or declared deps_type but called agent.run(msg) without deps=, you get a runtime error.
Fix: declare Agent('openai:gpt-5.4', deps_type=Deps, ...) and always pass agent.run(msg, deps=Deps(...)). If you intentionally have no deps, use neither: no deps_type, no deps= argument, and @agent.tool_plain for all tools.
ValueError: Unknown model: 'gpt-5.4' or similar model-string error
Pydantic AI model strings follow the format <provider>:<model-name>, e.g. openai:gpt-5.4, anthropic:claude-opus-4, google-gla:gemini-2.0-flash. Passing a bare model name like 'gpt-5.4' or using a provider prefix that doesn't match the installed provider package will raise a validation error.
Fix: ensure the string includes the provider prefix and that the corresponding provider extra is installed — e.g. pip install 'pydantic-ai[openai]'. Check the Pydantic AI docs for the exact provider string for your model.
vault_get error: label_not_found even when label and password look correct
deny.sh returns label_not_found for both a missing label AND a wrong vault password — deliberately. The wire shape is identical so an attacker cannot enumerate which labels exist by probing passwords.
Fix: confirm the vault password in Deps.tenant_pw (or os.environ["VAULT_PW"]) exactly matches what was used in vault_store. Also confirm DENY_API_KEY is set — the Python client reads it from the environment. If you've rotated the password, every label must be re-stored under the new password; old ciphertexts cannot be decrypted with a new password.
Production checklist
- Set up /dashboard/decoy-alerts with a SHA-256 of a decoy controlData. Get paged when a prompt-injection extracts the decoy.
- Wire /dashboard/webhooks to your existing Datadog / PagerDuty / Slack.
- Pull audit-chain receipts via
GET /v1/audit/receipt/<id>or browse them at /dashboard/audit for regulator-grade evidence. - Use /dashboard/byok if you need customer-managed KMS keys.