scopeblind-pydantic-ai
Ed25519 signed receipts and (optional) Cedar policy enforcement for Pydantic AI agents. Every tool call produces a cryptographic receipt that anyone can verify offline with npx @veritasacta/verify.
Why
Pydantic AI agents call tools. Most teams log those calls to stdout, a SaaS dashboard, or nothing at all. When an auditor or regulator asks "prove what the agent did on April 12", logs aren't proof — they're mutable text files held by the operator.
This package wraps every tool call as a Pydantic AI Capability. The capability:
- Evaluates a policy (optional — Cedar, custom function, or allow-all)
- Signs the decision with Ed25519 (RFC 8032)
- Canonicalizes with JCS (RFC 8785) so the signature verifies deterministically across implementations
- Hash-chains each receipt to the previous one so insertion/deletion is detectable
- Exports to JSONL compatible with
@veritasacta/verify— no ScopeBlind account, no network call, works air-gapped
Install
pip install scopeblind-pydantic-ai
Quick start
from pydantic_ai import Agent
from scopeblind_pydantic_ai import ScopeBlindSigning, ReceiptSigner
# Generate or load an Ed25519 signing key
signer = ReceiptSigner.generate()
signer.save_key("./keys/agent.json")
# Attach the capability to your agent
signing = ScopeBlindSigning(signer=signer, agent_name="weather-bot")
agent = Agent(
"anthropic:claude-sonnet-4-6",
capabilities=[signing],
tools=[get_weather, get_forecast],
)
result = await agent.run("What's the weather in Sydney?")
# Inspect and export receipts
print(f"{signing.receipt_count} tool calls signed")
print(f"Public key for verification: {signing.public_key_hex}")
signing.export_receipts("./receipts.jsonl")
Verify the output:
npx @veritasacta/verify receipts.jsonl --key $(jq -r .public_key keys/agent.json)
Exit 0 = all receipts valid. Exit 1 = tampered. Exit 2 = malformed.
With a policy
The policy argument is a callable (tool_name, args) -> (allowed, deny_reason):
def no_production_writes(tool_name: str, args: dict) -> tuple[bool, str | None]:
if tool_name == "database_write" and args.get("env") == "production":
return (False, "writes to production require human approval")
return (True, None)
signing = ScopeBlindSigning(signer=signer, policy=no_production_writes)
Denied calls raise SkipToolExecution, and a decision="deny" receipt is still signed — the record survives the block.
With Cedar
For declarative policy, run Cedar evaluation in your policy callable. Two options:
Option 1 — use protect-mcp's Cedar evaluator (shells out to the CLI):
import subprocess, json
def cedar_gate(tool_name: str, args: dict) -> tuple[bool, str | None]:
result = subprocess.run(
["npx", "protect-mcp", "evaluate",
"--policy", "./protect.cedar",
"--tool", tool_name,
"--input", json.dumps(args)],
capture_output=True,
)
if result.returncode == 0:
return (True, None)
return (False, result.stderr.decode().strip() or "Cedar deny")
Option 2 — use the Cedar WASM bindings from cedar-policy/cedar-for-agents (merged #64) through a Python binding. Native Python Cedar bindings are on the way.
Receipt format
Every receipt is a JSON object following IETF draft-farley-acta-signed-receipts:
{
"payload": {
"type": "protectmcp:decision",
"spec": "draft-farley-acta-signed-receipts-01",
"tool_name": "get_weather",
"tool_input_hash": "sha256:a3f8...",
"decision": "allow",
"issued_at": "2026-04-15T10:30:00.000Z",
"issuer_id": "scopeblind-pydantic-ai",
"session_id": "sess_3a8b2c",
"sequence": 1,
"previousReceiptHash": null,
"agent_name": "weather-bot",
"output_hash": "sha256:b7e2..."
},
"signature": {
"alg": "EdDSA",
"kid": "sb:pydai:bd84c36b620c",
"sig": "4cde814b..."
}
}
The signature covers the JCS-canonicalized payload, so any edit to any payload field breaks verification.
Ecosystem
Cross-compatible with the rest of the ScopeBlind / Veritas Acta stack. A receipt signed by this package verifies identically to one signed by:
protect-mcp(Node, Claude Code hooks)protect-mcp-adk(Google ADK)- The Rust gateway (APS ProxyGateway)
- The Microsoft Agent Governance Toolkit adapter (PR #667, merged)
Compatibility
| Pydantic AI | Status |
|---|---|
>= 1.0.0 |
Supported — uses the Capability API (wrap_tool_execute hook) |
< 1.0 |
Not supported — the hook API landed in the 1.0 release |
Development
git clone https://github.com/ScopeBlind/scopeblind-gateway
cd scopeblind-gateway/packages/scopeblind-pydantic-ai
pip install -e ".[dev]"
pytest tests/ -v
Standards
- Ed25519 — RFC 8032
- JCS — RFC 8785
- Cedar — AWS's open authorization engine
- IETF Internet-Draft — draft-farley-acta-signed-receipts
License
MIT. See LICENSE.
Release files for scopeblind-pydantic-ai 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| scopeblind_pydantic_ai-0.1.0.tar.gz | 11.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| scopeblind_pydantic_ai-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 22.8 kB
Release files / scopeblind_pydantic_ai-0.1.0.tar.gz
| Download URL | scopeblind_pydantic_ai-0.1.0.tar.gz |
|---|---|
| Size | 11.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
af20953e07ad39575166a505344ee4eecf0991590d76cad5c1dee556bb97697c
|
|
BLAKE2b-256 checksum How to use checksums |
d4403aac7c877ddb8f78a5b63dce5ae6f0be452fee9856f9e834b36b450cefea
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.6
|
Release files / scopeblind_pydantic_ai-0.1.0-py3-none-any.whl
| Download URL | scopeblind_pydantic_ai-0.1.0-py3-none-any.whl |
|---|---|
| Size | 11.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1607383a065d07ddd2737c6c73a81841caf8f79708992f133273ab33b773170c
|
|
BLAKE2b-256 checksum How to use checksums |
6bba7d0e4efebe623c052659c3447409cb9e032bf28882fb4a7e5ac476c7dd26
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/6.2.0 CPython/3.13.6
|