From 79b0d85adb62fba9a42d8ceb5645f8427fa16ff6 Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Sun, 2 Aug 2026 07:55:07 -0400 Subject: [PATCH 1/2] Add exact-registry Pydantic AI policy toolset --- .github/workflows/ci.yml | 37 +- CHANGELOG.md | 3 + CONTRIBUTING.md | 7 +- README.md | 40 +- RELEASING.md | 24 +- ROADMAP.md | 6 +- SECURITY.md | 10 + docs/ADOPTION.md | 34 +- docs/API.md | 19 +- docs/ARCHITECTURE.md | 13 + docs/PRODUCTIZATION.md | 21 +- docs/PYDANTIC_AI.md | 126 ++++ examples/pydantic_ai_policy_toolset_demo.py | 101 ++++ integration_tests/test_pydantic_ai_sdk.py | 173 ++++++ pyproject.toml | 3 + requirements-pydantic-ai.lock | 227 ++++++++ requirements-pydantic-ai.txt | 2 + src/samsarix_ethics/__init__.py | 14 + src/samsarix_ethics/pydantic_ai.py | 473 ++++++++++++++++ tests/test_public_api.py | 6 + tests/test_pydantic_ai.py | 599 ++++++++++++++++++++ 21 files changed, 1906 insertions(+), 32 deletions(-) create mode 100644 docs/PYDANTIC_AI.md create mode 100644 examples/pydantic_ai_policy_toolset_demo.py create mode 100644 integration_tests/test_pydantic_ai_sdk.py create mode 100644 requirements-pydantic-ai.lock create mode 100644 requirements-pydantic-ai.txt create mode 100644 src/samsarix_ethics/pydantic_ai.py create mode 100644 tests/test_pydantic_ai.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a7bc62..a3f630c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -503,6 +503,34 @@ jobs: - name: Smoke-test the LangChain example run: python examples/langchain_policy_middleware_demo.py + pydantic-ai-contract: + name: Pydantic AI 2.22.0 contract + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + requirements-dev.lock + requirements-pydantic-ai.lock + pyproject.toml + - name: Install locked development and Pydantic AI contract dependencies + run: | + python -m pip install --require-hashes \ + -r requirements-dev.lock \ + -r requirements-pydantic-ai.lock + python -m pip install --no-build-isolation --no-deps -e . + - name: Test exact Pydantic AI agent contract + run: python -m pytest --no-cov integration_tests/test_pydantic_ai_sdk.py + - name: Smoke-test the Pydantic AI example + run: python examples/pydantic_ai_policy_toolset_demo.py + opentelemetry-contract: name: OpenTelemetry 1.44.0 contract runs-on: ubuntu-latest @@ -534,7 +562,14 @@ jobs: attest: name: Attest distributions if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [test, openai-agents-contract, langchain-contract, opentelemetry-contract] + needs: + [ + test, + openai-agents-contract, + langchain-contract, + pydantic-ai-contract, + opentelemetry-contract, + ] runs-on: ubuntu-latest timeout-minutes: 5 permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 348a033..ba70311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,9 @@ All notable product changes are recorded here. - Optional exact-registry LangChain `1.3.14` sync/async tool middleware with final raw-argument enforcement, native LangGraph interrupts, strict fingerprint-bound approval resume, generic rejection results, a no-network real-agent example, and a dedicated hashed CI contract. +- Optional exact-registry Pydantic AI `2.22.0` wrapper toolset with native deferred approvals, + strict Samsarix evidence on resume, fresh current-policy enforcement, a no-network real-agent + example, adversarial tests, and a dedicated slim hash-locked CI contract. ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9418cd0..c43ad0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,9 +27,10 @@ tool inside the hash-verified dependency boundary. Optional integrations have separate exact contract inputs and locks. Regenerate them with the same universal Python 3.11 flags and run their dedicated integration tests and examples in environments -that install exactly one of `requirements-openai-agents.lock`, `requirements-langchain.lock`, or -`requirements-opentelemetry.lock` together with the development lock. Keep optional packages out -of the base development environment so the dependency-free import contract remains testable. +that install exactly one of `requirements-openai-agents.lock`, `requirements-langchain.lock`, +`requirements-pydantic-ai.lock`, or `requirements-opentelemetry.lock` together with the development +lock. Keep optional packages out of the base development environment so the dependency-free import +contract remains testable. ## Required checks diff --git a/README.md b/README.md index 35fe538..d58035f 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ review?** It is for Python developers who need a small policy-as-code boundary in front of tool calls, workflows, or other consequential operations. Policies and inputs are JSON, decisions are explainable, and the optional audit log excludes raw input by design. The package makes no -network calls and its core has no runtime dependencies. An optional OpenAI Agents SDK adapter is -isolated behind one install extra, an optional LangChain middleware protects exact tool registries -with fingerprint-bound review interrupts, and an optional OpenTelemetry API extra emits -metadata-only decision events into caller-owned traces. Other Samsarix repositories can embed the -core, but none is required; the package and its release lifecycle stand on their own. +network calls and its core has no runtime dependencies. Optional exact-version adapters protect +OpenAI Agents SDK function tools, LangChain tool registries, and Pydantic AI toolsets with +fingerprint-bound native review flows. An optional OpenTelemetry API extra emits metadata-only +decision events into caller-owned traces. Other Samsarix repositories can embed the core, but none +is required; the package and its release lifecycle stand on their own. Within the Samsarix portfolio, this repository owns agent-action safety policy, human-review outcomes, exact-call enforcement, privacy-minimized decision evidence, and the policy lifecycle. @@ -604,6 +604,29 @@ the reviewer, so production checkpointers need sensitive-data controls and an au surface. See the [LangChain middleware guide](docs/LANGCHAIN.md) for ordering, rejection, audit, parallel-call, persistence, and unsupported-path boundaries. +## Pydantic AI integration + +Install the slim optional runtime and run its deterministic no-network agent: + +```bash +python -m pip install -e '.[pydantic-ai]' +python examples/pydantic_ai_policy_toolset_demo.py +``` + +`create_pydantic_ai_tool_policy(bound_catalog, toolset)` returns a policy object whose `toolset` +wraps Pydantic AI's public execution seam. Every run step must expose the complete exact catalog +as real `ToolsetTool` objects, and each call must resolve to the snapshotted object. Allow delegates +once, deny never delegates, and review becomes native `DeferredToolRequests` metadata bound to the +exact call fingerprint. + +After authenticating the reviewer, use `tool_policy.build_results(requests, decisions)` to create +resume evidence. A plain Pydantic AI boolean approval is not enough: resume requires Samsarix +metadata, fresh actor/context providers, and current-policy re-enforcement. Pydantic AI performs +schema validation before the wrapper, so this adapter authorizes validated JSON-native arguments; +custom argument validators must have no side effects. See the +[Pydantic AI toolset guide](docs/PYDANTIC_AI.md) for multi-call resolution, persistence, sensitive +metadata, and unsupported-path boundaries. + ## Downstream adoption Samsarix Agent Framework is the first verified downstream consumer. Its optional policy registry @@ -612,9 +635,10 @@ capabilities outside model arguments, re-reads authentication/approval facts for blocks execution on every non-allow outcome or gate failure. The consumer contract runs on Python 3.11-3.14 while the framework's dependency-free core retains Python 3.10 support. -The repository also carries public, reproducible OpenAI Agents SDK and LangChain adapters with -exact-version contract tests. The consumer repository remains private as of 2026-08-01, so none of -these items is a public third-party case study or production deployment. Exact commits, +The repository also carries public, reproducible OpenAI Agents SDK, LangChain, and Pydantic AI +adapters with exact-version contract tests. The consumer repository remains private as of +2026-08-01, so none of these items is a public third-party case study or production deployment. +Exact commits, compatibility, rollback, support level, and evidence limits are recorded in [adoption and compatibility evidence](docs/ADOPTION.md). diff --git a/RELEASING.md b/RELEASING.md index cd195fe..dbb400c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -8,9 +8,9 @@ attested artifact, publishing a release, and claiming adopter evidence are disti The Python 3.11 CI job builds the wheel and source distribution once, validates both files, installs the wheel into a clean virtual environment, and uploads the exact files as `python-distributions-` for 14 days. The same workflow exercises the source package across -Python 3.11-3.14. Dedicated hash-locked lanes exercise the exact OpenAI Agents SDK, LangChain, and -OpenTelemetry API/SDK contracts plus their no-network examples; release candidates are valid only -when the complete matrix and all optional-integration lanes are green. +Python 3.11-3.14. Dedicated hash-locked lanes exercise the exact OpenAI Agents SDK, LangChain, +Pydantic AI, and OpenTelemetry API/SDK contracts plus their no-network examples; release candidates +are valid only when the complete matrix and all optional-integration lanes are green. For pushes to `main`, a separate least-privilege job waits for the complete matrix, downloads those already-verified files, and creates GitHub build-provenance attestations. The attestation links each @@ -55,7 +55,17 @@ Nothing in this repository currently uploads to PyPI, creates a GitHub release, python examples/langchain_policy_middleware_demo.py ``` -6. In a third fresh virtual environment, validate only the OpenTelemetry optional contract: +6. In a third fresh virtual environment, validate only the Pydantic AI optional contract: + + ```bash + python -m pip install --require-hashes \ + -r requirements-dev.lock \ + -r requirements-pydantic-ai.lock + python -m pytest --no-cov integration_tests/test_pydantic_ai_sdk.py + python examples/pydantic_ai_policy_toolset_demo.py + ``` + +7. In a fourth fresh virtual environment, validate only the OpenTelemetry optional contract: ```bash python -m pip install --require-hashes \ @@ -65,7 +75,7 @@ Nothing in this repository currently uploads to PyPI, creates a GitHub release, python examples/opentelemetry_decision_event_demo.py ``` -7. Download the exact CI distributions for the commit, then verify their provenance: +8. Download the exact CI distributions for the commit, then verify their provenance: ```bash gh run download RUN_ID \ @@ -77,9 +87,9 @@ Nothing in this repository currently uploads to PyPI, creates a GitHub release, --repo Deathcharge/samsarix-agent-ethics ``` -8. Install the downloaded wheel with `--no-deps` in a new virtual environment and run +9. Install the downloaded wheel with `--no-deps` in a new virtual environment and run `samsarix-ethics --version`, schema export, policy validation, and one allow/deny walkthrough. -9. Record the commit, CI run, distribution SHA-256 digests, attestation verification, and rollback +10. Record the commit, CI run, distribution SHA-256 digests, attestation verification, and rollback ref in the release notes. ## Registry publication prerequisites diff --git a/ROADMAP.md b/ROADMAP.md index eb2cb68..ceda514 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,6 +25,9 @@ adoption remain separate evidence-based decisions. - [x] Add exact-registry LangChain sync/async middleware with final-argument enforcement, native LangGraph review interrupts, fingerprint-bound resume, exact-version locking, and a real no-network agent contract. +- [x] Add an exact-registry Pydantic AI wrapper toolset with native deferred review, + fingerprint-bound Samsarix resume evidence, exact slim-version locking, and a real no-network + agent contract. - [x] Expose one immutable metadata-only audit record to a caller-supplied sink while preserving the existing JSONL API and fail-closed behavior. - [x] Correlate metadata-only decisions with a caller-owned OpenTelemetry trace through an optional @@ -79,7 +82,8 @@ Current hardening backlog: - No published package/release, public third-party adopter, or production deployment evidence. - The first verified consumer is a private Samsarix repository; its evidence is maintainer-visible. - The public OpenAI, LangChain, and OpenTelemetry adapters are reproducible integration evidence, + The public OpenAI, LangChain, Pydantic AI, and OpenTelemetry adapters are reproducible + integration evidence, not external adopter case studies. - LangChain review checkpoints intentionally contain proposed tool arguments. Reviewer identity, checkpoint confidentiality, expiry, one-time resume, and multi-call transactionality remain diff --git a/SECURITY.md b/SECURITY.md index dc8e948..3d754f1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -64,6 +64,16 @@ one-time resume, and treat direct `BaseTool` calls or side effects performed by outside this adapter. Parallel tool nodes are not a transaction and may produce partial side effects. A rejected interrupt returns a generic tool error but is not an authorization audit record. +When using the optional Pydantic AI adapter, register only `tool_policy.toolset` for the protected +tools and require the complete run-step registry to remain equal to the trusted catalog. Pydantic +schema and custom argument validators run before the wrapper; validators must not perform side +effects, and policy sees their validated JSON-native result rather than the model's original +spelling. A native Pydantic approval boolean is not Samsarix authorization: approved resume must +carry adapter-built exact-call evidence and still pass current-policy enforcement. Protect +message/deferred state and conversation IDs, authenticate reviewers, enforce expiry and atomic +one-time consumption, and treat other toolsets, direct calls, provider tools, and pre-delegation +side effects as outside this adapter. Parallel calls remain non-transactional. + `ToolGate` invokes only the explicit callback supplied by the embedding application and only after an allow decision; it is not a sandbox. The package makes no network requests, executes no policy code, loads no plugins, and stores no raw evaluation input in its built-in audit record. diff --git a/docs/ADOPTION.md b/docs/ADOPTION.md index bdba243..b549316 100644 --- a/docs/ADOPTION.md +++ b/docs/ADOPTION.md @@ -6,14 +6,16 @@ deployment. It is intentionally specific enough for maintainers to reproduce and ## Public runtime contract The repository includes optional exact-version adapters for strict top-level OpenAI Agents SDK -`FunctionTool` objects and exact LangChain `BaseTool` registries. Dedicated CI jobs install hashed -dependency graphs for `openai-agents==0.18.3` and `langchain==1.3.14`, exercise real framework -types, and run no-network examples. The OpenAI contract verifies guardrail execution, callback -compatibility, and fail-closed handling before schema coercion. The LangChain contract verifies a -real checkpointed interrupt/resume and proves that a final Samsarix middleware sees an earlier -middleware's argument transformation before allowing execution. The [OpenAI guide](OPENAI_AGENTS.md) -and [LangChain guide](LANGCHAIN.md) make this evidence reproducible from a public checkout. These -are maintained compatibility contracts, not evidence of a third-party adopter, live model call, +`FunctionTool` objects, exact LangChain `BaseTool` registries, and exact Pydantic AI `ToolsetTool` +registries. Dedicated CI jobs install hashed dependency graphs for `openai-agents==0.18.3`, +`langchain==1.3.14`, and `pydantic-ai-slim==2.22.0`, exercise real framework types, and run +no-network examples. The OpenAI contract verifies guardrail execution and fail-closed handling +before schema coercion. The LangChain contract verifies a real checkpointed interrupt/resume and +final middleware ordering. The Pydantic AI contract verifies native deferred approval/rejection, +requires Samsarix evidence beyond a native boolean, and detects registry drift. The +[OpenAI guide](OPENAI_AGENTS.md), [LangChain guide](LANGCHAIN.md), and +[Pydantic AI guide](PYDANTIC_AI.md) make this reproducible from a public checkout. These are +maintained compatibility contracts, not evidence of a third-party adopter, live model call, production traffic, or hosted deployment. ## Implemented gap: exact-registry LangChain enforcement @@ -35,6 +37,22 @@ and calls made outside the protected agent bypass this boundary. Parallel tool c transaction and may still produce partial side effects. This is exact public runtime evidence, not an adopter or production claim. +## Implemented gap: native Pydantic AI deferred authorization + +Pydantic AI exposes `WrapperToolset.call_tool` as the public tool-execution wrapping seam and +`DeferredToolRequests`/`DeferredToolResults` as its pause-and-resume contract. Its documentation +also states that approval is not itself an authorization boundary. Samsarix therefore exact-matches +the full run-step registry, raises native `ApprovalRequired` for policy review, and requires +adapter-built fingerprint evidence in metadata before accepting a native approved resume. Current +policy and fresh application facts remain authoritative. + +Pydantic schema and custom argument validation precedes the wrapper, so the boundary protects +validated arguments and requires side-effect-free validators. Other toolsets, direct calls, and +provider-side tools bypass the wrapper. Conversation and deferred state may retain proposed +arguments, while reviewer authentication, decision expiry, one-time consumption, and durable +storage remain application-owned. This is exact public runtime evidence, not adopter or production +evidence. + ## Samsarix Agent Framework Samsarix Agent Framework is the first consumer-owned integration. Its optional diff --git a/docs/API.md b/docs/API.md index df9b3eb..92243a0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -442,6 +442,23 @@ strict `ToolCallApproval` dictionary matching the current call fingerprint befor enforcement. Rejection returns a generic error `ToolMessage` without invoking the tool. The adapter contract version is `LANGCHAIN_ADAPTER_VERSION = 1`. See [LANGCHAIN.md](LANGCHAIN.md). +### `create_pydantic_ai_tool_policy(bindings, toolset, *, actor_provider=None, context_provider=None)` + +Creates an optional `PydanticAIToolPolicy` for one exact `BoundToolCatalog` and one real Pydantic +AI `AbstractToolset`. Construction imports Pydantic AI only when called. `toolset` returns a public +`WrapperToolset` subclass suitable for `Agent(toolsets=[...])`; every run step must expose an exact +catalog-matching dictionary of real `ToolsetTool` objects, and execution must resolve to the +snapshotted object. + +Providers are synchronous callbacks from `RunContext.deps` to fresh application-owned JSON facts. +Allow delegates once after audited enforcement. Deny raises the typed gate error. Review raises +native `ApprovalRequired` with `PYDANTIC_AI_REVIEW_METADATA_KEY` metadata. +`build_results(requests, decisions)` validates selected deferred calls and creates either +fingerprint-bound `PYDANTIC_AI_APPROVAL_METADATA_KEY` evidence or a generic `ToolDenied` result. +A native boolean approval without this evidence fails closed, and approved resume re-enforces the +current call and policy. The adapter contract version is `PYDANTIC_AI_ADAPTER_VERSION = 1`. See +[PYDANTIC_AI.md](PYDANTIC_AI.md). + ### `BoundToolCatalog` The immutable mapping returned by `ToolGate.bind_catalog(...)`. It exposes `gate`, `catalog`, @@ -716,7 +733,7 @@ records. See [AUDIT_CHAINS.md](AUDIT_CHAINS.md) for the format and complete thre `PolicyValidationError`, `PolicyDeploymentValidationError`, `PolicyActivationError`, `PolicyCompositionError`, `PolicyTestValidationError`, `InputValidationError`, `EvaluationError`, `AuditLogError`, `AuditChainError`, `OpenAIAgentsIntegrationError`, `LangChainIntegrationError`, -and the tool-call enforcement errors derive from +`PydanticAIIntegrationError`, and the tool-call enforcement errors derive from `SamsarixEthicsError`. `AuditChainError` also derives from `AuditLogError`, preserving fail-closed gate handling. The base class and specialized errors are exported from `samsarix_ethics` and defined in diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 51cb557..45a37f8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,6 +20,8 @@ locked policy deployment + fingerprinted catalog ─> ToolGateDeployment ─> ve verified bindings + final callback objects ─> ToolDispatcher ─> authorized sequential dispatch verified bindings + exact LangChain BaseTools ─> final middleware ─> allow / interrupt / block +verified bindings + exact Pydantic AI ToolsetTools ─> wrapper toolset ─> allow / defer / block + validated policy + optional contract/lock ─> PolicyRuntime generation N ─> live gates validated complete candidate ─> compare-and-swap atomic activation ──────┘ @@ -68,6 +70,8 @@ the legacy `helix-unified` repository. without importing the SDK at core package import time. - `langchain.py`: optional exact-registry sync/async middleware, final-argument enforcement, and fingerprint-bound LangGraph interrupt resume without importing LangChain at core package import. +- `pydantic_ai.py`: optional exact-registry wrapper toolset, validated-argument enforcement, and + fingerprint-bound native deferred approval without importing Pydantic AI until construction. - `cli.py`: non-interactive commands, rendering, stderr discipline, and exit codes. - `__init__.py`: deliberate public Python API. @@ -270,6 +274,15 @@ passes raw JSON to guardrails before Pydantic callback conversion, so the adapte bounded duplicate-safe parser and policy types to raw values. It deliberately rejects namespaces and agent-as-tool wrappers and cannot intercept hosted, built-in, MCP-hosted, or handoff paths. +The optional Pydantic AI adapter wraps one public `AbstractToolset` and exact-matches the complete +real `ToolsetTool` map every run step. The wrapper snapshots resolved tool identities, evaluates +Pydantic-validated detached JSON arguments, and delegates once only after final enforcement. +Review raises native `ApprovalRequired`; the application converts selected requests through the +adapter so resume metadata carries exact-call Samsarix evidence. Native approval alone cannot +authorize a reviewed call. Resume refreshes actor/context facts and re-enforces current policy. +Pydantic schema and custom argument validation occur before the wrapper, while other toolsets, +provider-side tools, and direct calls remain outside this boundary. + ## Trust boundaries - **Policy authors/operators** are trusted to define correct rules and secure policy files. diff --git a/docs/PRODUCTIZATION.md b/docs/PRODUCTIZATION.md index faa3a53..bf0a5c7 100644 --- a/docs/PRODUCTIZATION.md +++ b/docs/PRODUCTIZATION.md @@ -98,6 +98,10 @@ Bounded review used current primary sources: tool guardrails after approval and immediately before execution. - [Pydantic AI deferred tools](https://pydantic.dev/docs/ai/tools-toolsets/deferred-tools/) model paused tool calls while warning that human approval is not itself application authorization. +- [Pydantic AI toolsets](https://pydantic.dev/docs/ai/tools-toolsets/toolsets/) expose + `WrapperToolset.call_tool` as the public execution-wrapping seam. A safe adapter can therefore + exact-match the real per-step tool registry and defer natively, but it receives schema-validated + arguments after custom validators and cannot cover other toolsets or provider-side tools. - [MCP client security guidance](https://modelcontextprotocol.io/docs/develop/clients/client-best-practices) emphasizes per-call confirmation and keeping authorization decisions outside model control. - [OpenAI Agents SDK tools](https://openai.github.io/openai-agents-python/tools/) and @@ -266,6 +270,8 @@ certification or ethics truth. contract, and explicit unsupported execution paths. - [x] Add exact-registry LangChain sync/async middleware with final raw-argument authorization, fingerprint-bound native LangGraph interrupt/resume, and an exact-version real-agent CI contract. +- [x] Add exact-registry Pydantic AI wrapper enforcement with native deferred review, strict + Samsarix approval metadata, and an exact-version slim real-agent CI contract. - [ ] Add policy-format version migration only after a second format and adopter need exist. - [ ] Add benchmark thresholds once representative policy sizes are known. @@ -303,10 +309,10 @@ certification or ethics truth. ## Completed work - Established the `samsarix_ethics` public API and `samsarix-ethics` console command. -- Added 540 real core tests; latest pinned local `python -m pytest` pytest-cov terminal report: 540 - passed and 95.76% total branch-aware coverage under the configured `--cov-branch` gate. Separate +- Added 552 real core tests; latest pinned local `python -m pytest` pytest-cov terminal report: 552 + passed and 95.05% total branch-aware coverage under the configured `--cov-branch` gate. Separate real-SDK contract tests run against exact hashed `openai-agents==0.18.3`, `langchain==1.3.14`, - and OpenTelemetry 1.44.0 dependency graphs. + `pydantic-ai-slim==2.22.0`, and OpenTelemetry 1.44.0 dependency graphs. - Rebuilt the wheel and source distribution, passed `twine check`, and verified the wheel in an isolated no-dependency environment: install/import/version/schema/deployment verification and a deployed allow decision all succeeded, and runtime construction used the packaged API. @@ -390,6 +396,11 @@ certification or ethics truth. final raw arguments after outer middleware, and binds native LangGraph resume to the interrupted call. A real checkpointed-agent contract, no-network example, focused guide, and adversarial sync/async suite preserve the dependency-free base package boundary. +- Added an optional Pydantic AI wrapper toolset that exact-matches every real run-step registry, + authorizes validated detached arguments, and converts native deferred review into + fingerprint-bound Samsarix resume evidence. A real no-network agent contract proves approval, + rejection, forged native-approval blocking, and registry-drift failure against exact + `pydantic-ai-slim==2.22.0` without changing the dependency-free base install. - Added a versioned metadata-only OpenTelemetry event sink, bounded ordered audit-sink composition, exact API/SDK contract test, and an in-memory no-network example. The application retains SDK, exporter, sampling, collector, trace access, durable audit, and partial-delivery recovery. @@ -462,6 +473,10 @@ External validation gates: encryption, access control, retention, reviewer/thread authorization, CSRF protection, expiry, and one-time resume. Direct tool invocation, server-side tools, and pre-handler side effects can bypass or precede middleware, while parallel calls remain non-transactional. +- Pydantic AI schema and custom argument validation occur before the wrapper; validators must be + side-effect-free and policy sees validated values. Deferred state contains proposed arguments, + reviewer authentication/expiry/consumption remains application-owned, other execution paths can + bypass the wrapped toolset, and parallel calls are not transactional. - File permissions and retention vary by operating system and are caller responsibilities. - Python dependency tooling resolves transitive development dependencies; exact direct pins reduce drift but do not constitute a fully hashed supply-chain lock. diff --git a/docs/PYDANTIC_AI.md b/docs/PYDANTIC_AI.md new file mode 100644 index 0000000..1e3d68e --- /dev/null +++ b/docs/PYDANTIC_AI.md @@ -0,0 +1,126 @@ +# Pydantic AI toolset policy + +Samsarix Agent Ethics can protect one exact Pydantic AI toolset registry with deterministic +allow, deny, and native deferred-review behavior. The adapter wraps the public `WrapperToolset` +execution seam and checks the complete real `ToolsetTool` registry on every agent run step. + +Install the exact optional contract and run the no-network demo: + +```bash +python -m pip install -e '.[pydantic-ai]' +python examples/pydantic_ai_policy_toolset_demo.py +``` + +The extra pins `pydantic-ai-slim==2.22.0`, which provides the agent and toolset runtime without the +full provider, CLI, and MCP metapackage. CI installs the complete graph from +`requirements-pydantic-ai.lock`, runs a real `Agent` with `TestModel`, and executes the example. +The dependency-free base package imports successfully without Pydantic AI; the optional runtime is +loaded only when `create_pydantic_ai_tool_policy` is called. + +## Protect an exact toolset + +Create and populate the toolset, then bind the same complete name set to a trusted Samsarix +catalog: + +```python +from pydantic_ai import Agent, FunctionToolset +from samsarix_ethics import ToolGate, create_pydantic_ai_tool_policy + +tools = FunctionToolset() + +@tools.tool_plain +def read_ticket(ticket_id: str) -> str: + return ticket_store.read(ticket_id) + +@tools.tool_plain +def send_message(recipient: str, body: str) -> str: + return messenger.send(recipient, body) + +bindings = ToolGate(policy, audit_sink=audit_sink).bind_catalog( + catalog, + registered_tools=["read_ticket", "send_message"], +) +tool_policy = create_pydantic_ai_tool_policy( + bindings, + tools, + actor_provider=lambda deps: {"id": deps.user_id}, + context_provider=lambda deps: {"tenant": deps.tenant_id}, +) +agent = Agent(model, toolsets=[tool_policy.toolset], deps_type=ApplicationContext) +``` + +The wrapped registry must contain only real Pydantic AI `ToolsetTool` values, each registry key +must equal its `ToolDefinition.name`, and its complete name set must exactly equal the bound +catalog. A missing, added, renamed, malformed, or dynamically different tool fails closed before +execution. Each resolved execution must also use the exact `ToolsetTool` object snapshotted for +that run step. Populate dynamic toolsets before binding, or ensure every run-step variant has the +same cataloged names. + +Actor and context providers are synchronous application callbacks. They receive `RunContext.deps`, +not messages or model arguments, and run again on resume. Keep dependencies authenticated and +application-owned. Provider programming errors remain visible; malformed provider results never +call the tool. + +## Native review and exact resume + +Allow is audited and delegates once. Deny is audited and raises the ordinary typed Samsarix gate +error. Review raises Pydantic AI's native `ApprovalRequired` before delegation. The resulting +`DeferredToolRequests.metadata[tool_call_id]["samsarix.tool_call.review"]` contains the exact call +binding, proposed tool name and arguments, policy identity and fingerprint, and decisive rule IDs. + +After separately authenticating and authorizing the reviewer, resolve any chosen subset of pending +calls through the adapter: + +```python +from pydantic_ai import DeferredToolRequests + +first = agent.run_sync(prompt) +assert isinstance(first.output, DeferredToolRequests) + +decisions = {call.tool_call_id: reviewer_approved(call) for call in first.output.approvals} +results = tool_policy.build_results(first.output, decisions) +completed = agent.run_sync( + "Continue.", + message_history=first.all_messages(), + deferred_tool_results=results, +) +``` + +`build_results` validates every selected pending call against the saved Samsarix review metadata. +An approval adds strict `samsarix.tool_call.approval` metadata bound to the exact call ID and +fingerprint; a rejection becomes a generic Pydantic AI `ToolDenied` result and never delegates. +Pydantic AI's plain native `True` approval is deliberately insufficient because its documentation +does not define approval as an application authorization boundary. Forging or omitting Samsarix +metadata fails closed. + +On approved resume, the adapter recomputes the fingerprint from the current name, validated +arguments, trusted catalog capabilities, and freshly supplied actor. It then re-evaluates the +current policy and context facts. Changed arguments or actor, a different call ID, missing or +malformed evidence, a replay against another call, or a current deny/review never invokes the +tool. Approval evidence is ordinary unsigned application data: the reviewer system must own +identity, authorization, expiry, revocation, persistence, and atomic one-time consumption. + +For multiple deferred calls, an application can resolve a subset and later combine or supply the +result objects according to Pydantic AI's workflow contract. Samsarix does not make parallel tool +execution transactional and cannot roll back a tool that already produced a side effect. + +## Validation timing and execution boundary + +Pydantic AI validates the tool schema and runs any custom argument validator before +`WrapperToolset.call_tool`. Samsarix therefore authorizes Pydantic AI's schema-validated argument +dictionary, not the model's original JSON spelling. Keep validators deterministic and free of side +effects: a validator can run before this gate. The adapter then applies Samsarix's bounded +JSON-native validation and passes a detached copy to the wrapped toolset. Non-JSON values and +non-finite numbers fail closed. + +The adapter covers calls routed through the wrapped toolset registered on that agent. It does not +cover another unwrapped toolset, direct toolset calls, provider/server-side or hosted tools, +built-in tools, model activity, output functions, or application code that performs a side effect +before delegating to the wrapper. Keep every consequential local tool in a protected exact +toolset, and enforce separate boundaries for other execution paths. + +Pydantic AI message history and deferred results may contain proposed arguments and authorization +metadata. Production workflows must protect storage and conversation identifiers, authenticate +review endpoints, apply retention and encryption appropriate to the data, prevent cross-tenant +resume, and consume a decision once. The no-network example uses only in-memory state and is not a +durable workflow design. diff --git a/examples/pydantic_ai_policy_toolset_demo.py b/examples/pydantic_ai_policy_toolset_demo.py new file mode 100644 index 0000000..000a5a4 --- /dev/null +++ b/examples/pydantic_ai_policy_toolset_demo.py @@ -0,0 +1,101 @@ +# Copyright 2024-2026 Samsarix LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Run a no-network Pydantic AI policy review and exact approved resume.""" + +from __future__ import annotations + +from pydantic_ai import Agent, DeferredToolRequests, FunctionToolset +from pydantic_ai.models.test import TestModel + +from samsarix_ethics import ( + PYDANTIC_AI_REVIEW_METADATA_KEY, + Policy, + ToolCatalog, + ToolGate, + create_pydantic_ai_tool_policy, +) + + +def main() -> None: + """Review and execute one exact Pydantic AI tool call without a model API.""" + + policy = Policy.from_dict( + { + "schema_version": 1, + "id": "pydantic-ai-demo", + "version": "1", + "default_effect": "deny", + "rules": [ + { + "id": "allow-approved", + "effect": "allow", + "priority": 0, + "message": "An exact approved call may run.", + "conditions": [ + { + "field": "context.approval.approved", + "operator": "eq", + "value": True, + } + ], + }, + { + "id": "review-message", + "effect": "review", + "priority": 10, + "message": "External messages need review.", + "conditions": [ + {"field": "action.operation", "operator": "eq", "value": "send_message"}, + {"field": "context.approval", "operator": "not_exists"}, + ], + }, + ], + } + ) + catalog = ToolCatalog.from_dict( + { + "tool_catalog_version": 1, + "id": "pydantic-ai-demo-tools", + "version": "1", + "description": "No-network Pydantic AI demo registry.", + "tools": [{"name": "send_message", "capabilities": ["external:write"]}], + } + ) + bindings = ToolGate(policy).bind_catalog(catalog, registered_tools=["send_message"]) + tools = FunctionToolset() + executed: list[str] = [] + + @tools.tool_plain + def send_message(recipient: str) -> str: + """Send one demonstration message.""" + + executed.append(recipient) + return "sent" + + tool_policy = create_pydantic_ai_tool_policy(bindings, tools) + agent = Agent( + TestModel(call_tools=["send_message"]), + toolsets=[tool_policy.toolset], + output_type=[str, DeferredToolRequests], + ) + first = agent.run_sync("Send the demonstration message.", conversation_id="demo") + if not isinstance(first.output, DeferredToolRequests): + raise RuntimeError("expected one deferred tool review") + pending = first.output.approvals[0] + review = first.output.metadata[pending.tool_call_id][PYDANTIC_AI_REVIEW_METADATA_KEY] + print(f"{review['type']}: {review['tool']['name']}") + + # Authenticate and authorize the reviewer before making this application-owned decision. + results = tool_policy.build_results(first.output, {pending.tool_call_id: True}) + agent.run_sync( + "Continue.", + message_history=first.all_messages(), + deferred_tool_results=results, + conversation_id="demo", + ) + print(f"send_message: {executed[0]}") + + +if __name__ == "__main__": + main() diff --git a/integration_tests/test_pydantic_ai_sdk.py b/integration_tests/test_pydantic_ai_sdk.py new file mode 100644 index 0000000..3a1228b --- /dev/null +++ b/integration_tests/test_pydantic_ai_sdk.py @@ -0,0 +1,173 @@ +# Copyright 2024-2026 Samsarix LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Exact-version contract against a real no-network Pydantic AI agent.""" + +from __future__ import annotations + +from importlib.metadata import version +from typing import Any + +import pytest +from pydantic_ai import Agent, DeferredToolRequests, FunctionToolset +from pydantic_ai.models.test import TestModel + +from samsarix_ethics import ( + PYDANTIC_AI_REVIEW_METADATA_KEY, + Policy, + PydanticAIIntegrationError, + ToolCatalog, + ToolCatalogValidationError, + ToolGate, + create_pydantic_ai_tool_policy, +) + + +def _bindings() -> Any: + policy = Policy.from_dict( + { + "schema_version": 1, + "id": "pydantic-ai-contract", + "version": "1", + "default_effect": "deny", + "rules": [ + { + "id": "allow-approved", + "effect": "allow", + "priority": 0, + "message": "An exact approved call may run.", + "conditions": [ + { + "field": "context.approval.approved", + "operator": "eq", + "value": True, + } + ], + }, + { + "id": "review-send", + "effect": "review", + "priority": 10, + "message": "External messages need review.", + "conditions": [ + {"field": "action.operation", "operator": "eq", "value": "send_message"}, + {"field": "context.approval", "operator": "not_exists"}, + ], + }, + ], + } + ) + catalog = ToolCatalog.from_dict( + { + "tool_catalog_version": 1, + "id": "pydantic-ai-contract-tools", + "version": "1", + "description": "Exact Pydantic AI contract registry.", + "tools": [{"name": "send_message", "capabilities": ["external:write"]}], + } + ) + return ToolGate(policy).bind_catalog(catalog, registered_tools=["send_message"]) + + +def _agent(calls: list[str]) -> tuple[Agent[Any, Any], Any]: + tools = FunctionToolset() + + @tools.tool_plain + def send_message(recipient: str) -> str: + calls.append(recipient) + return "sent" + + policy = create_pydantic_ai_tool_policy(_bindings(), tools) + agent = Agent( + TestModel(call_tools=["send_message"]), + toolsets=[policy.toolset], + output_type=[str, DeferredToolRequests], + ) + return agent, policy + + +def test_real_agent_approves_exact_deferred_call_once() -> None: + assert version("pydantic-ai-slim") == "2.22.0" + calls: list[str] = [] + agent, policy = _agent(calls) + + first = agent.run_sync("Send the update.", conversation_id="approval-contract") + + assert isinstance(first.output, DeferredToolRequests) + assert calls == [] + assert len(first.output.approvals) == 1 + pending = first.output.approvals[0] + review = first.output.metadata[pending.tool_call_id][PYDANTIC_AI_REVIEW_METADATA_KEY] + assert review["tool"] == { + "name": "send_message", + "arguments": {"recipient": "a"}, + } + results = policy.build_results(first.output, {pending.tool_call_id: True}) + + completed = agent.run_sync( + "Continue.", + message_history=first.all_messages(), + deferred_tool_results=results, + conversation_id="approval-contract", + ) + + assert not isinstance(completed.output, DeferredToolRequests) + assert calls == ["a"] + + +def test_real_agent_rejection_never_calls_tool() -> None: + calls: list[str] = [] + agent, policy = _agent(calls) + first = agent.run_sync("Send the update.", conversation_id="rejection-contract") + assert isinstance(first.output, DeferredToolRequests) + pending = first.output.approvals[0] + results = policy.build_results(first.output, {pending.tool_call_id: False}) + + completed = agent.run_sync( + "Continue.", + message_history=first.all_messages(), + deferred_tool_results=results, + conversation_id="rejection-contract", + ) + + assert not isinstance(completed.output, DeferredToolRequests) + assert calls == [] + + +def test_real_agent_rejects_native_approval_without_samsarix_evidence() -> None: + calls: list[str] = [] + agent, _policy = _agent(calls) + first = agent.run_sync("Send the update.", conversation_id="forged-contract") + assert isinstance(first.output, DeferredToolRequests) + pending = first.output.approvals[0] + forged = first.output.build_results(approvals={pending.tool_call_id: True}) + + with pytest.raises(PydanticAIIntegrationError, match="approval metadata"): + agent.run_sync( + "Continue.", + message_history=first.all_messages(), + deferred_tool_results=forged, + conversation_id="forged-contract", + ) + assert calls == [] + + +def test_real_agent_rejects_registry_added_after_binding() -> None: + calls: list[str] = [] + tools = FunctionToolset() + + @tools.tool_plain + def send_message(recipient: str) -> str: + calls.append(recipient) + return "sent" + + policy = create_pydantic_ai_tool_policy(_bindings(), tools) + + @tools.tool_plain + def uncataloged_tool(value: str) -> str: + return value + + agent = Agent(TestModel(call_tools=["send_message"]), toolsets=[policy.toolset]) + with pytest.raises(ToolCatalogValidationError, match="missing from catalog"): + agent.run_sync("Send the update.") + assert calls == [] diff --git a/pyproject.toml b/pyproject.toml index 97f8879..a178e2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,9 @@ langchain = [ openai-agents = [ "openai-agents==0.18.3", ] +pydantic-ai = [ + "pydantic-ai-slim==2.22.0", +] opentelemetry = [ "opentelemetry-api==1.44.0", ] diff --git a/requirements-pydantic-ai.lock b/requirements-pydantic-ai.lock new file mode 100644 index 0000000..addc473 --- /dev/null +++ b/requirements-pydantic-ai.lock @@ -0,0 +1,227 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile --universal --python-version 3.11 --generate-hashes requirements-pydantic-ai.txt -o requirements-pydantic-ai.lock +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.14.2 \ + --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ + --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f + # via + # httpx + # httpx2 + # pydantic-ai-slim + # pydantic-graph +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +genai-prices==0.1.1 \ + --hash=sha256:54a2237691e0aaefb057d10a0c3c20160accc9fc09521c64c03fcdb7a4a69f68 \ + --hash=sha256:de2e3d8ea3ca1d0d292025995c598da447a74e94f22cd3342df46941aeb5416b + # via pydantic-ai-slim +griffelib==2.1.0 \ + --hash=sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813 \ + --hash=sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00 + # via pydantic-ai-slim +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # httpcore2 +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpcore2==2.9.1 \ + --hash=sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2 \ + --hash=sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26 + # via httpx2 +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via + # pydantic-ai-slim + # pydantic-graph +httpx2==2.9.1 \ + --hash=sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a \ + --hash=sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a + # via genai-prices +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx + # httpx2 +logfire-api==4.39.0 \ + --hash=sha256:1e885f95c37d58cdb927bbc6baea4f4a7c13066f6b3019758627d4dc442643d0 \ + --hash=sha256:20057bbd2898dec2eed02e2559bd73f4e10bc4b108987821df55e9c762da3ba8 + # via pydantic-graph +opentelemetry-api==1.44.0 \ + --hash=sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a \ + --hash=sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef + # via pydantic-ai-slim +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via + # genai-prices + # pydantic-ai-slim + # pydantic-graph +pydantic-ai-slim==2.22.0 \ + --hash=sha256:00a09316951ba4587348a5233a5d0b395ed7cf97ee3991666841bd55e41ddc85 \ + --hash=sha256:156e772b1a4a568c65d779ae1e1012dd58bc255e5355067981a490e9acd7cb45 + # via -r requirements-pydantic-ai.txt +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pydantic-graph==2.22.0 \ + --hash=sha256:1350e63b1af5cea421aba7ed996af5d853b5b1da3c92b7cf7439d558bda4c8dd \ + --hash=sha256:26663839b426114834e3b9047ae6620c2a0a88cdefe67e63a3074d378ff9ed8a + # via pydantic-ai-slim +truststore==0.10.4 \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # httpx2 + # opentelemetry-api + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # pydantic + # pydantic-ai-slim + # pydantic-graph diff --git a/requirements-pydantic-ai.txt b/requirements-pydantic-ai.txt new file mode 100644 index 0000000..7a9163f --- /dev/null +++ b/requirements-pydantic-ai.txt @@ -0,0 +1,2 @@ +# Exact optional Pydantic AI contract used by CI and the no-network example. +pydantic-ai-slim==2.22.0 diff --git a/src/samsarix_ethics/__init__.py b/src/samsarix_ethics/__init__.py index 8dc22c2..3af6a13 100644 --- a/src/samsarix_ethics/__init__.py +++ b/src/samsarix_ethics/__init__.py @@ -198,6 +198,14 @@ fingerprint_tool_catalog, fingerprint_tool_gate_deployment, ) +from .pydantic_ai import ( + PYDANTIC_AI_ADAPTER_VERSION, + PYDANTIC_AI_APPROVAL_METADATA_KEY, + PYDANTIC_AI_REVIEW_METADATA_KEY, + PydanticAIIntegrationError, + PydanticAIToolPolicy, + create_pydantic_ai_tool_policy, +) from .runtime import POLICY_RUNTIME_STATUS_VERSION, PolicyRuntime, PolicyRuntimeStatus from .schema import ( get_audit_chain_entry_schema, @@ -298,6 +306,9 @@ "POLICY_LINT_VERSION", "POLICY_RUNTIME_STATUS_VERSION", "POLICY_SHADOW_VERSION", + "PYDANTIC_AI_ADAPTER_VERSION", + "PYDANTIC_AI_APPROVAL_METADATA_KEY", + "PYDANTIC_AI_REVIEW_METADATA_KEY", "TOOL_CALL_APPROVAL_VERSION", "TOOL_CALL_FINGERPRINT_VERSION", "TOOL_CATALOG_FINGERPRINT_VERSION", @@ -376,6 +387,8 @@ "PolicyTestValidationError", "PolicyValidationError", "PreparedToolCall", + "PydanticAIIntegrationError", + "PydanticAIToolPolicy", "RuleExplanation", "SamsarixEthicsError", "ToolCallApproval", @@ -402,6 +415,7 @@ "create_langchain_tool_policy", "create_openai_agents_tool_policy", "create_policy_deployment", + "create_pydantic_ai_tool_policy", "create_tool_gate_deployment", "fingerprint_context_contract", "fingerprint_policy", diff --git a/src/samsarix_ethics/pydantic_ai.py b/src/samsarix_ethics/pydantic_ai.py new file mode 100644 index 0000000..884dddb --- /dev/null +++ b/src/samsarix_ethics/pydantic_ai.py @@ -0,0 +1,473 @@ +# Copyright 2024-2026 Samsarix LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Fail-closed Pydantic AI toolset policy and exact-call review bridge.""" + +from __future__ import annotations + +import hmac +import inspect +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from importlib import import_module +from typing import Any, cast + +from .approval import TOOL_CALL_APPROVAL_VERSION, ToolCallApproval +from .catalog import MAX_TOOL_CATALOG_TOOLS, validate_tool_catalog_registration +from .errors import SamsarixEthicsError +from .explanation import PolicyExplanation +from .gate import BoundToolCatalog, BoundToolGate +from .models import Outcome +from .validation import freeze_json_value, thaw_json_value, validate_context + +PYDANTIC_AI_ADAPTER_VERSION = 1 +PYDANTIC_AI_REVIEW_METADATA_KEY = "samsarix.tool_call.review" +PYDANTIC_AI_APPROVAL_METADATA_KEY = "samsarix.tool_call.approval" +_REJECTION_MESSAGE = "Tool call rejected by human review." + +_FactsProvider = Callable[[Any], Mapping[str, Any] | None] + + +class PydanticAIIntegrationError(SamsarixEthicsError): + """Raised when Pydantic AI cannot enforce a tool policy safely.""" + + +def _empty_facts(_application_context: Any) -> Mapping[str, Any]: + return {} + + +def _validate_provider(provider: _FactsProvider | None, *, label: str) -> _FactsProvider: + if provider is None: + return _empty_facts + if not callable(provider) or inspect.iscoroutinefunction(provider): + raise TypeError(f"{label} must be a synchronous callable") + return provider + + +@dataclass(frozen=True, slots=True) +class _PydanticAICall: + binding: BoundToolGate + tool_name: str + tool_call_id: str + arguments: dict[str, Any] + actor: Mapping[str, Any] | None + context: Mapping[str, Any] | None + fingerprint: str + + +@dataclass(frozen=True, slots=True) +class PydanticAIToolPolicy: + """Protect one exact Pydantic AI toolset registry with Samsarix policy.""" + + _bindings: BoundToolCatalog + _actor_provider: _FactsProvider + _context_provider: _FactsProvider + _abstract_toolset_type: type[Any] + _toolset_tool_type: type[Any] + _run_context_type: type[Any] + _approval_required_type: type[Any] + _deferred_requests_type: type[Any] + _tool_denied_type: type[Any] + _toolset: Any + + @property + def bindings(self) -> BoundToolCatalog: + """Return the exact trusted Samsarix catalog bindings.""" + + return self._bindings + + @property + def toolset(self) -> Any: + """Return the Pydantic AI wrapper toolset to register on an agent.""" + + return self._toolset + + @staticmethod + def _provider_value( + provider: _FactsProvider, + application_context: Any, + *, + label: str, + ) -> Mapping[str, Any] | None: + value = provider(application_context) + if value is not None and not isinstance(value, Mapping): + raise PydanticAIIntegrationError(f"{label} must return a mapping or None") + return value + + async def _get_tools(self, wrapper: Any, ctx: Any) -> dict[str, Any]: + if not isinstance(ctx, self._run_context_type): + raise TypeError("ctx must be a Pydantic AI RunContext") + tools = await wrapper.wrapped.get_tools(ctx) + if not isinstance(tools, dict): + raise PydanticAIIntegrationError("Pydantic AI get_tools must return a dictionary") + if len(tools) > MAX_TOOL_CATALOG_TOOLS: + raise PydanticAIIntegrationError( + f"Pydantic AI tools exceed the limit of {MAX_TOOL_CATALOG_TOOLS}" + ) + + names: list[str] = [] + for name, tool in tools.items(): + if not isinstance(name, str): + raise PydanticAIIntegrationError("Pydantic AI tool name must be a string") + if not isinstance(tool, self._toolset_tool_type): + raise PydanticAIIntegrationError(f"Pydantic AI tool {name!r} is not a ToolsetTool") + tool_definition = getattr(tool, "tool_def", None) + if getattr(tool_definition, "name", None) != name: + raise PydanticAIIntegrationError( + "Pydantic AI tool definition name does not match its registry key" + ) + names.append(name) + + validate_tool_catalog_registration(self._bindings.catalog, names) + wrapper._samsarix_verified_tools = dict(tools) + return tools + + def _call( + self, wrapper: Any, name: Any, tool_args: Any, ctx: Any, tool: Any + ) -> _PydanticAICall: + if not isinstance(ctx, self._run_context_type): + raise TypeError("ctx must be a Pydantic AI RunContext") + if not isinstance(name, str): + raise PydanticAIIntegrationError("Pydantic AI tool call name must be a string") + if not isinstance(tool_args, dict): + raise PydanticAIIntegrationError("Pydantic AI tool arguments must be a dictionary") + if not isinstance(tool, self._toolset_tool_type): + raise PydanticAIIntegrationError("Pydantic AI tool call has no ToolsetTool") + verified_tools = getattr(wrapper, "_samsarix_verified_tools", None) + if not isinstance(verified_tools, dict) or verified_tools.get(name) is not tool: + raise PydanticAIIntegrationError( + "Pydantic AI tool call does not match the verified run-step registry" + ) + tool_definition = getattr(tool, "tool_def", None) + if getattr(tool_definition, "name", None) != name: + raise PydanticAIIntegrationError( + "Pydantic AI resolved tool name does not match the requested tool" + ) + try: + binding = self._bindings[name] + except KeyError as exc: + raise PydanticAIIntegrationError( + "Pydantic AI tool call is not present in the trusted catalog" + ) from exc + + tool_call_id = getattr(ctx, "tool_call_id", None) + if not isinstance(tool_call_id, str): + raise PydanticAIIntegrationError("Pydantic AI RunContext has no string tool_call_id") + application_context = getattr(ctx, "deps", None) + actor = self._provider_value( + self._actor_provider, + application_context, + label="actor_provider", + ) + context = self._provider_value( + self._context_provider, + application_context, + label="context_provider", + ) + validated_arguments = validate_context(tool_args, label="Pydantic AI tool arguments") + detached_arguments = cast( + dict[str, Any], thaw_json_value(freeze_json_value(validated_arguments)) + ) + fingerprint = binding.fingerprint(tool_call_id, detached_arguments, actor=actor) + return _PydanticAICall( + binding=binding, + tool_name=name, + tool_call_id=tool_call_id, + arguments=detached_arguments, + actor=actor, + context=context, + fingerprint=fingerprint, + ) + + @staticmethod + def _review_payload( + value: _PydanticAICall, + explanation: PolicyExplanation, + ) -> dict[str, Any]: + return { + "type": PYDANTIC_AI_REVIEW_METADATA_KEY, + "adapter_version": PYDANTIC_AI_ADAPTER_VERSION, + "approval_binding": { + "approval_version": TOOL_CALL_APPROVAL_VERSION, + "tool_call_id": value.tool_call_id, + "tool_call_fingerprint": value.fingerprint, + }, + "tool": { + "name": value.tool_name, + "arguments": value.arguments, + }, + "policy": { + "id": explanation.policy_id, + "version": explanation.policy_version, + "fingerprint": explanation.policy_fingerprint, + "decisive_rule_ids": list(explanation.decisive_rule_ids), + }, + } + + @staticmethod + def _verify_approval(value: _PydanticAICall, metadata: Any) -> ToolCallApproval: + if not isinstance(metadata, Mapping): + raise PydanticAIIntegrationError( + "Pydantic AI approved call has no Samsarix approval metadata" + ) + approval = ToolCallApproval.from_dict(metadata.get(PYDANTIC_AI_APPROVAL_METADATA_KEY)) + if not approval.approved: + raise PydanticAIIntegrationError( + "Pydantic AI approved call carries rejected Samsarix evidence" + ) + if not hmac.compare_digest( + value.tool_call_id.encode("utf-8"), approval.tool_call_id.encode("utf-8") + ) or not hmac.compare_digest( + value.fingerprint.encode("utf-8"), + approval.tool_call_fingerprint.encode("utf-8"), + ): + raise PydanticAIIntegrationError( + "Pydantic AI approval does not match the current tool call" + ) + return approval + + async def _call_tool( + self, + wrapper: Any, + name: Any, + tool_args: Any, + ctx: Any, + tool: Any, + ) -> Any: + value = self._call(wrapper, name, tool_args, ctx, tool) + explanation = value.binding.explain( + value.arguments, + actor=value.actor, + context=value.context, + ) + approval: ToolCallApproval | None = None + if explanation.outcome is Outcome.REVIEW: + approved = getattr(ctx, "tool_call_approved", None) + if not isinstance(approved, bool): + raise PydanticAIIntegrationError( + "Pydantic AI RunContext tool_call_approved must be a boolean" + ) + if not approved: + raise self._approval_required_type( + metadata={ + PYDANTIC_AI_REVIEW_METADATA_KEY: self._review_payload(value, explanation) + } + ) + approval = self._verify_approval(value, getattr(ctx, "tool_call_metadata", None)) + + value.binding.enforce( + value.arguments, + actor=value.actor, + context=value.context, + tool_call_id=value.tool_call_id if approval is not None else None, + approval=approval, + ) + return await wrapper.wrapped.call_tool( + value.tool_name, + value.arguments, + ctx, + tool, + ) + + def build_results(self, requests: Any, decisions: Mapping[str, bool]) -> Any: + """Build exact-call Pydantic AI results after caller-owned reviewer authentication.""" + + if not isinstance(requests, self._deferred_requests_type): + raise TypeError("requests must be Pydantic AI DeferredToolRequests") + if not isinstance(decisions, Mapping): + raise TypeError("decisions must be a mapping of tool call IDs to booleans") + if len(decisions) > MAX_TOOL_CATALOG_TOOLS: + raise PydanticAIIntegrationError( + f"Pydantic AI decisions exceed the limit of {MAX_TOOL_CATALOG_TOOLS}" + ) + + approvals = getattr(requests, "approvals", None) + if not isinstance(approvals, list): + raise PydanticAIIntegrationError("Pydantic AI deferred approvals must be a list") + if len(approvals) > MAX_TOOL_CATALOG_TOOLS: + raise PydanticAIIntegrationError( + f"Pydantic AI deferred approvals exceed the limit of {MAX_TOOL_CATALOG_TOOLS}" + ) + pending: dict[str, Any] = {} + for call in approvals: + call_id = getattr(call, "tool_call_id", None) + if not isinstance(call_id, str): + raise PydanticAIIntegrationError( + "Pydantic AI deferred approval has no string tool_call_id" + ) + if call_id in pending: + raise PydanticAIIntegrationError( + "Pydantic AI deferred approvals contain a duplicate tool call ID" + ) + pending[call_id] = call + + results: dict[str, Any] = {} + metadata: dict[str, dict[str, Any]] = {} + request_metadata = getattr(requests, "metadata", None) + if not isinstance(request_metadata, Mapping): + raise PydanticAIIntegrationError( + "Pydantic AI deferred requests metadata must be a mapping" + ) + for call_id, approved in decisions.items(): + if not isinstance(call_id, str): + raise PydanticAIIntegrationError( + "Pydantic AI decision tool call ID must be a string" + ) + if type(approved) is not bool: + raise PydanticAIIntegrationError("Pydantic AI review decision must be a boolean") + try: + call = pending[call_id] + except KeyError as exc: + raise PydanticAIIntegrationError( + "Pydantic AI decision does not identify a pending approval" + ) from exc + per_call_metadata = request_metadata.get(call_id) + if not isinstance(per_call_metadata, Mapping): + raise PydanticAIIntegrationError( + "Pydantic AI pending approval has no Samsarix review metadata" + ) + payload = per_call_metadata.get(PYDANTIC_AI_REVIEW_METADATA_KEY) + approval = self._approval_from_payload(call, payload, approved=approved) + if approved: + results[call_id] = True + metadata[call_id] = {PYDANTIC_AI_APPROVAL_METADATA_KEY: approval.to_dict()} + else: + results[call_id] = self._tool_denied_type(_REJECTION_MESSAGE) + + build_results = getattr(requests, "build_results", None) + if not callable(build_results): + raise PydanticAIIntegrationError( + "Pydantic AI DeferredToolRequests has no build_results method" + ) + return build_results(approvals=results, metadata=metadata) + + def _approval_from_payload( + self, call: Any, payload: Any, *, approved: bool + ) -> ToolCallApproval: + if not isinstance(payload, Mapping): + raise PydanticAIIntegrationError( + "Pydantic AI pending approval has malformed Samsarix review metadata" + ) + if payload.get("type") != PYDANTIC_AI_REVIEW_METADATA_KEY: + raise PydanticAIIntegrationError("Pydantic AI review metadata type is incompatible") + if payload.get("adapter_version") != PYDANTIC_AI_ADAPTER_VERSION: + raise PydanticAIIntegrationError("Pydantic AI review metadata version is incompatible") + binding = payload.get("approval_binding") + tool_payload = payload.get("tool") + if not isinstance(binding, Mapping) or not isinstance(tool_payload, Mapping): + raise PydanticAIIntegrationError("Pydantic AI review metadata is malformed") + call_id = getattr(call, "tool_call_id", None) + tool_name = getattr(call, "tool_name", None) + arguments = getattr(call, "args", None) + if binding.get("tool_call_id") != call_id: + raise PydanticAIIntegrationError( + "Pydantic AI review metadata call ID does not match the pending call" + ) + if tool_payload.get("name") != tool_name or tool_payload.get("arguments") != arguments: + raise PydanticAIIntegrationError( + "Pydantic AI review metadata tool call does not match the pending call" + ) + try: + self._bindings[cast(str, tool_name)] + except (KeyError, TypeError) as exc: + raise PydanticAIIntegrationError( + "Pydantic AI pending approval is not present in the trusted catalog" + ) from exc + return ToolCallApproval.from_dict({**binding, "approved": approved}) + + +def create_pydantic_ai_tool_policy( + bindings: BoundToolCatalog, + toolset: Any, + *, + actor_provider: _FactsProvider | None = None, + context_provider: _FactsProvider | None = None, +) -> PydanticAIToolPolicy: + """Wrap one exact Pydantic AI toolset without adding a core dependency.""" + + if not isinstance(bindings, BoundToolCatalog): + raise TypeError("bindings must be a BoundToolCatalog") + actor = _validate_provider(actor_provider, label="actor_provider") + context = _validate_provider(context_provider, label="context_provider") + try: + pydantic_ai = import_module("pydantic_ai") + abstract_toolset_type = pydantic_ai.AbstractToolset + wrapper_toolset_type = pydantic_ai.WrapperToolset + toolset_tool_type = pydantic_ai.ToolsetTool + run_context_type = pydantic_ai.RunContext + approval_required_type = pydantic_ai.ApprovalRequired + deferred_requests_type = pydantic_ai.DeferredToolRequests + tool_denied_type = pydantic_ai.ToolDenied + except (AttributeError, ImportError) as exc: + raise PydanticAIIntegrationError( + "install the compatible Pydantic AI runtime with 'samsarix-agent-ethics[pydantic-ai]'" + ) from exc + runtime_types = ( + abstract_toolset_type, + wrapper_toolset_type, + toolset_tool_type, + run_context_type, + approval_required_type, + deferred_requests_type, + tool_denied_type, + ) + if not all(isinstance(value, type) for value in runtime_types): + raise PydanticAIIntegrationError("Pydantic AI runtime has an incompatible API shape") + if not issubclass(approval_required_type, BaseException): + raise PydanticAIIntegrationError("Pydantic AI ApprovalRequired is not an exception type") + if not isinstance(toolset, abstract_toolset_type): + raise TypeError("toolset must be a Pydantic AI AbstractToolset") + + policy: PydanticAIToolPolicy + + def initialize(wrapper: Any, wrapped: Any) -> None: + wrapper_toolset_type.__init__(wrapper, wrapped) + wrapper._samsarix_verified_tools = {} + + async def for_run(wrapper: Any, ctx: Any) -> Any: + if not isinstance(ctx, run_context_type): + raise TypeError("ctx must be a Pydantic AI RunContext") + wrapped = await wrapper.wrapped.for_run(ctx) + if not isinstance(wrapped, abstract_toolset_type): + raise PydanticAIIntegrationError( + "Pydantic AI toolset for_run returned an incompatible value" + ) + return type(wrapper)(wrapped) + + async def get_tools(wrapper: Any, ctx: Any) -> dict[str, Any]: + return await policy._get_tools(wrapper, ctx) + + async def call_tool( + wrapper: Any, + name: Any, + tool_args: Any, + ctx: Any, + tool: Any, + ) -> Any: + return await policy._call_tool(wrapper, name, tool_args, ctx, tool) + + concrete_toolset_type = type( + "SamsarixPydanticAIToolset", + (wrapper_toolset_type,), + { + "__init__": initialize, + "for_run": for_run, + "get_tools": get_tools, + "call_tool": call_tool, + "__module__": __name__, + }, + ) + protected_toolset = concrete_toolset_type(toolset) + policy = PydanticAIToolPolicy( + _bindings=bindings, + _actor_provider=actor, + _context_provider=context, + _abstract_toolset_type=cast(type[Any], abstract_toolset_type), + _toolset_tool_type=cast(type[Any], toolset_tool_type), + _run_context_type=cast(type[Any], run_context_type), + _approval_required_type=cast(type[Any], approval_required_type), + _deferred_requests_type=cast(type[Any], deferred_requests_type), + _tool_denied_type=cast(type[Any], tool_denied_type), + _toolset=protected_toolset, + ) + return policy diff --git a/tests/test_public_api.py b/tests/test_public_api.py index db5ebb5..3884c41 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -31,6 +31,9 @@ def test_public_api_is_importable() -> None: assert "OpenAIAgentsIntegrationError" in samsarix_ethics.__all__ assert "OpenAIAgentsToolPolicy" in samsarix_ethics.__all__ assert "create_openai_agents_tool_policy" in samsarix_ethics.__all__ + assert "PydanticAIIntegrationError" in samsarix_ethics.__all__ + assert "PydanticAIToolPolicy" in samsarix_ethics.__all__ + assert "create_pydantic_ai_tool_policy" in samsarix_ethics.__all__ assert "OpenTelemetryDecisionEventSink" in samsarix_ethics.__all__ assert "OpenTelemetryIntegrationError" in samsarix_ethics.__all__ assert "CompositeAuditSink" in samsarix_ethics.__all__ @@ -118,6 +121,9 @@ def test_public_api_is_importable() -> None: assert samsarix_ethics.LANGCHAIN_ADAPTER_VERSION == 1 assert samsarix_ethics.LANGCHAIN_REVIEW_INTERRUPT_TYPE == "samsarix.tool_call.review" assert samsarix_ethics.OPENAI_AGENTS_ADAPTER_VERSION == 1 + assert samsarix_ethics.PYDANTIC_AI_ADAPTER_VERSION == 1 + assert samsarix_ethics.PYDANTIC_AI_APPROVAL_METADATA_KEY == "samsarix.tool_call.approval" + assert samsarix_ethics.PYDANTIC_AI_REVIEW_METADATA_KEY == "samsarix.tool_call.review" assert samsarix_ethics.OPENTELEMETRY_DECISION_EVENT_NAME == "samsarix.policy.decision" assert samsarix_ethics.OPENTELEMETRY_DECISION_EVENT_VERSION == 1 assert samsarix_ethics.MAX_COMPOSITE_AUDIT_SINKS == 32 diff --git a/tests/test_pydantic_ai.py b/tests/test_pydantic_ai.py new file mode 100644 index 0000000..336a580 --- /dev/null +++ b/tests/test_pydantic_ai.py @@ -0,0 +1,599 @@ +# Copyright 2024-2026 Samsarix LLC +# SPDX-License-Identifier: Apache-2.0 + +"""Fail-closed Pydantic AI toolset behavior without an optional dependency.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass, field +from types import SimpleNamespace +from typing import Any + +import pytest + +import samsarix_ethics.pydantic_ai as adapter_module +from samsarix_ethics.catalog import ToolCatalog +from samsarix_ethics.errors import ( + InputValidationError, + ToolCallDeniedError, + ToolCatalogValidationError, +) +from samsarix_ethics.gate import BoundToolCatalog, ToolGate +from samsarix_ethics.models import Policy +from samsarix_ethics.pydantic_ai import ( + PYDANTIC_AI_ADAPTER_VERSION, + PYDANTIC_AI_APPROVAL_METADATA_KEY, + PYDANTIC_AI_REVIEW_METADATA_KEY, + PydanticAIIntegrationError, + create_pydantic_ai_tool_policy, +) + + +class _AbstractToolset: + async def for_run(self, _ctx: Any) -> _AbstractToolset: + return self + + +class _WrapperToolset(_AbstractToolset): + def __init__(self, wrapped: _AbstractToolset) -> None: + self.wrapped = wrapped + + +@dataclass +class _ToolDefinition: + name: Any + + +@dataclass +class _ToolsetTool: + toolset: Any + tool_def: Any + + +@dataclass +class _RunContext: + deps: Any = None + tool_call_id: Any = "call-1" + tool_call_approved: Any = False + tool_call_metadata: Any = None + + +class _ApprovalRequired(Exception): + def __init__(self, metadata: dict[str, Any] | None = None) -> None: + self.metadata = metadata + + +@dataclass +class _DeferredCall: + tool_name: Any + args: Any + tool_call_id: Any + + +@dataclass +class _ToolDenied: + message: str + + +@dataclass +class _DeferredToolResults: + approvals: dict[str, Any] + metadata: dict[str, dict[str, Any]] + + +@dataclass +class _DeferredToolRequests: + approvals: list[Any] = field(default_factory=list) + metadata: Any = field(default_factory=dict) + + def build_results( + self, + *, + approvals: dict[str, Any], + metadata: dict[str, dict[str, Any]], + ) -> _DeferredToolResults: + return _DeferredToolResults(approvals=approvals, metadata=metadata) + + +class _InnerToolset(_AbstractToolset): + def __init__(self, names: tuple[str, ...] = ("read_file", "send_message")) -> None: + self.names = names + self.calls: list[tuple[str, dict[str, Any], Any]] = [] + + async def get_tools(self, _ctx: Any) -> dict[str, _ToolsetTool]: + return { + name: _ToolsetTool(toolset=self, tool_def=_ToolDefinition(name)) for name in self.names + } + + async def call_tool( + self, + name: str, + arguments: dict[str, Any], + _ctx: Any, + tool: Any, + ) -> str: + self.calls.append((name, arguments, tool)) + return "executed" + + +@pytest.fixture +def fake_pydantic_ai(monkeypatch: pytest.MonkeyPatch) -> None: + runtime = SimpleNamespace( + AbstractToolset=_AbstractToolset, + WrapperToolset=_WrapperToolset, + ToolsetTool=_ToolsetTool, + RunContext=_RunContext, + ApprovalRequired=_ApprovalRequired, + DeferredToolRequests=_DeferredToolRequests, + ToolDenied=_ToolDenied, + ) + + def fake_import(name: str) -> Any: + if name == "pydantic_ai": + return runtime + raise ImportError(name) + + monkeypatch.setattr(adapter_module, "import_module", fake_import) + + +@pytest.fixture +def bindings() -> BoundToolCatalog: + policy = Policy.from_dict( + { + "schema_version": 1, + "id": "pydantic-ai-test", + "version": "1", + "default_effect": "deny", + "rules": [ + { + "id": "deny-delete", + "effect": "deny", + "priority": 0, + "message": "Delete is forbidden.", + "conditions": [ + {"field": "action.arguments.mode", "operator": "eq", "value": "delete"} + ], + }, + { + "id": "allow-approved", + "effect": "allow", + "priority": 1, + "message": "An exact approved call may run.", + "conditions": [ + { + "field": "context.approval.approved", + "operator": "eq", + "value": True, + } + ], + }, + { + "id": "allow-read", + "effect": "allow", + "priority": 10, + "message": "Read mode may run.", + "conditions": [ + {"field": "action.arguments.mode", "operator": "eq", "value": "read"} + ], + }, + { + "id": "review-send", + "effect": "review", + "priority": 20, + "message": "Send mode needs review.", + "conditions": [ + {"field": "action.arguments.mode", "operator": "eq", "value": "send"}, + {"field": "context.approval", "operator": "not_exists"}, + ], + }, + ], + } + ) + catalog = ToolCatalog.from_dict( + { + "tool_catalog_version": 1, + "id": "pydantic-ai-tools", + "version": "1", + "description": "Trusted Pydantic AI test tools.", + "tools": [ + {"name": "read_file", "capabilities": ["workspace:read"]}, + {"name": "send_message", "capabilities": ["external:write"]}, + ], + } + ) + return ToolGate(policy).bind_catalog( + catalog, + registered_tools=["read_file", "send_message"], + ) + + +async def _run_wrapper(adapter: Any, ctx: _RunContext) -> tuple[Any, dict[str, Any]]: + wrapper = await adapter.toolset.for_run(ctx) + return wrapper, await wrapper.get_tools(ctx) + + +def _request_from_error( + error: _ApprovalRequired, + *, + arguments: dict[str, Any], + name: str = "send_message", + call_id: str = "call-1", +) -> _DeferredToolRequests: + assert error.metadata is not None + return _DeferredToolRequests( + approvals=[_DeferredCall(name, arguments, call_id)], + metadata={call_id: error.metadata}, + ) + + +def test_factory_is_optional_and_validates_runtime( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, + monkeypatch: pytest.MonkeyPatch, +) -> None: + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner) + assert adapter.bindings is bindings + assert isinstance(adapter.toolset, _WrapperToolset) + assert adapter.toolset.wrapped is inner + assert PYDANTIC_AI_ADAPTER_VERSION == 1 + assert PYDANTIC_AI_REVIEW_METADATA_KEY == "samsarix.tool_call.review" + assert PYDANTIC_AI_APPROVAL_METADATA_KEY == "samsarix.tool_call.approval" + + with pytest.raises(TypeError, match="BoundToolCatalog"): + create_pydantic_ai_tool_policy(object(), inner) # type: ignore[arg-type] + with pytest.raises(TypeError, match="AbstractToolset"): + create_pydantic_ai_tool_policy(bindings, object()) + + async def async_provider(_context: Any) -> dict[str, Any]: + return {} + + with pytest.raises(TypeError, match="synchronous callable"): + create_pydantic_ai_tool_policy(bindings, inner, actor_provider=async_provider) + + monkeypatch.setattr( + adapter_module, + "import_module", + lambda _name: (_ for _ in ()).throw(ImportError("missing")), + ) + with pytest.raises(PydanticAIIntegrationError, match=r"\[pydantic-ai\]"): + create_pydantic_ai_tool_policy(bindings, inner) + + +def test_registry_is_exact_and_snapshotted_per_run_step( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner) + ctx = _RunContext() + wrapper, tools = asyncio.run(_run_wrapper(adapter, ctx)) + assert wrapper is not adapter.toolset + assert set(tools) == {"read_file", "send_message"} + + different = _ToolsetTool(inner, _ToolDefinition("read_file")) + with pytest.raises(PydanticAIIntegrationError, match="verified run-step"): + asyncio.run(wrapper.call_tool("read_file", {"mode": "read"}, ctx, different)) + + inner.names = ("read_file",) + with pytest.raises(ToolCatalogValidationError, match="missing from registry"): + asyncio.run(wrapper.get_tools(ctx)) + + +def test_allow_executes_detached_arguments_once_and_deny_never_executes( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner) + ctx = _RunContext() + wrapper, tools = asyncio.run(_run_wrapper(adapter, ctx)) + arguments = {"mode": "read", "nested": {"value": 1}} + + assert ( + asyncio.run(wrapper.call_tool("read_file", arguments, ctx, tools["read_file"])) + == "executed" + ) + executed = inner.calls[0][1] + assert executed == arguments + assert executed is not arguments + assert executed["nested"] is not arguments["nested"] + + with pytest.raises(ToolCallDeniedError): + asyncio.run( + wrapper.call_tool("send_message", {"mode": "delete"}, ctx, tools["send_message"]) + ) + assert len(inner.calls) == 1 + + +def test_review_builds_exact_results_and_resumes_or_rejects( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner) + arguments = {"mode": "send", "recipient": "person@example.com"} + initial_ctx = _RunContext() + wrapper, tools = asyncio.run(_run_wrapper(adapter, initial_ctx)) + + with pytest.raises(_ApprovalRequired) as captured: + asyncio.run( + wrapper.call_tool("send_message", arguments, initial_ctx, tools["send_message"]) + ) + request = _request_from_error(captured.value, arguments=arguments) + payload = request.metadata["call-1"][PYDANTIC_AI_REVIEW_METADATA_KEY] + assert payload == { + "type": "samsarix.tool_call.review", + "adapter_version": 1, + "approval_binding": { + "approval_version": 1, + "tool_call_id": "call-1", + "tool_call_fingerprint": bindings["send_message"].fingerprint("call-1", arguments), + }, + "tool": {"name": "send_message", "arguments": arguments}, + "policy": { + "id": "pydantic-ai-test", + "version": "1", + "fingerprint": bindings.gate.policy_fingerprint, + "decisive_rule_ids": ["review-send"], + }, + } + assert inner.calls == [] + + approved = adapter.build_results(request, {"call-1": True}) + assert approved.approvals == {"call-1": True} + resumed_ctx = _RunContext( + tool_call_approved=True, + tool_call_metadata=approved.metadata["call-1"], + ) + resumed_wrapper, resumed_tools = asyncio.run(_run_wrapper(adapter, resumed_ctx)) + assert ( + asyncio.run( + resumed_wrapper.call_tool( + "send_message", arguments, resumed_ctx, resumed_tools["send_message"] + ) + ) + == "executed" + ) + assert len(inner.calls) == 1 + + rejected = adapter.build_results(request, {"call-1": False}) + assert rejected.approvals == {"call-1": _ToolDenied("Tool call rejected by human review.")} + assert rejected.metadata == {} + + +def test_approved_resume_rejects_mutated_arguments_actor_and_evidence( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + def actor_provider(deps: Any) -> dict[str, Any]: + return {"id": deps["actor_id"]} + + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner, actor_provider=actor_provider) + deps = {"actor_id": "reviewed"} + arguments = {"mode": "send", "recipient": "original@example.com"} + initial_ctx = _RunContext(deps=deps) + wrapper, tools = asyncio.run(_run_wrapper(adapter, initial_ctx)) + with pytest.raises(_ApprovalRequired) as captured: + asyncio.run( + wrapper.call_tool("send_message", arguments, initial_ctx, tools["send_message"]) + ) + request = _request_from_error(captured.value, arguments=arguments) + result = adapter.build_results(request, {"call-1": True}) + resumed_ctx = _RunContext( + deps=deps, + tool_call_approved=True, + tool_call_metadata=result.metadata["call-1"], + ) + resumed_wrapper, resumed_tools = asyncio.run(_run_wrapper(adapter, resumed_ctx)) + + with pytest.raises(PydanticAIIntegrationError, match="does not match"): + asyncio.run( + resumed_wrapper.call_tool( + "send_message", + {"mode": "send", "recipient": "changed@example.com"}, + resumed_ctx, + resumed_tools["send_message"], + ) + ) + deps["actor_id"] = "changed" + with pytest.raises(PydanticAIIntegrationError, match="does not match"): + asyncio.run( + resumed_wrapper.call_tool( + "send_message", arguments, resumed_ctx, resumed_tools["send_message"] + ) + ) + + invalid_evidence = ( + (None, PydanticAIIntegrationError), + ({}, InputValidationError), + ({PYDANTIC_AI_APPROVAL_METADATA_KEY: {"approved": True}}, InputValidationError), + ) + for metadata, error_type in invalid_evidence: + missing_ctx = _RunContext( + deps={"actor_id": "reviewed"}, + tool_call_approved=True, + tool_call_metadata=metadata, + ) + missing_wrapper, missing_tools = asyncio.run(_run_wrapper(adapter, missing_ctx)) + with pytest.raises(error_type): + asyncio.run( + missing_wrapper.call_tool( + "send_message", arguments, missing_ctx, missing_tools["send_message"] + ) + ) + assert inner.calls == [] + + +@pytest.mark.parametrize( + ("decisions", "match"), + [ + ({"other": True}, "pending approval"), + ({"call-1": 1}, "must be a boolean"), + ({1: True}, "ID must be a string"), + ], + ids=["unknown", "non-boolean", "non-string-id"], +) +def test_build_results_validates_decisions( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, + decisions: Any, + match: str, +) -> None: + adapter = create_pydantic_ai_tool_policy(bindings, _InnerToolset()) + request = _valid_deferred_request() + with pytest.raises(PydanticAIIntegrationError, match=match): + adapter.build_results(request, decisions) + + +def test_build_results_bounds_deferred_approvals( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + adapter = create_pydantic_ai_tool_policy(bindings, _InnerToolset()) + malformed = _DeferredToolRequests() + malformed.approvals = None # type: ignore[assignment] + with pytest.raises(PydanticAIIntegrationError, match="must be a list"): + adapter.build_results(malformed, {}) + + oversized = _DeferredToolRequests( + approvals=[_DeferredCall("send_message", {}, f"call-{index}") for index in range(257)] + ) + with pytest.raises(PydanticAIIntegrationError, match="exceed the limit"): + adapter.build_results(oversized, {}) + + +def _valid_deferred_request() -> _DeferredToolRequests: + arguments = {"mode": "send"} + return _DeferredToolRequests( + approvals=[_DeferredCall("send_message", arguments, "call-1")], + metadata={ + "call-1": { + PYDANTIC_AI_REVIEW_METADATA_KEY: { + "type": PYDANTIC_AI_REVIEW_METADATA_KEY, + "adapter_version": 1, + "approval_binding": { + "approval_version": 1, + "tool_call_id": "call-1", + "tool_call_fingerprint": "v1:sha256:" + "0" * 64, + }, + "tool": {"name": "send_message", "arguments": arguments}, + } + } + }, + ) + + +def test_build_results_rejects_mutated_request_metadata( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + adapter = create_pydantic_ai_tool_policy(bindings, _InnerToolset()) + request = _valid_deferred_request() + valid_payload = request.metadata["call-1"][PYDANTIC_AI_REVIEW_METADATA_KEY] + mutations = ( + (None, PydanticAIIntegrationError), + ({}, PydanticAIIntegrationError), + ({**valid_payload, "type": "other"}, PydanticAIIntegrationError), + ({**valid_payload, "adapter_version": 2}, PydanticAIIntegrationError), + ({**valid_payload, "approval_binding": {}}, PydanticAIIntegrationError), + ( + { + **valid_payload, + "tool": {"name": "read_file", "arguments": {"mode": "send"}}, + }, + PydanticAIIntegrationError, + ), + ( + { + **valid_payload, + "tool": {"name": "send_message", "arguments": {"mode": "changed"}}, + }, + PydanticAIIntegrationError, + ), + ) + for payload, error_type in mutations: + request.metadata["call-1"][PYDANTIC_AI_REVIEW_METADATA_KEY] = payload + with pytest.raises(error_type): + adapter.build_results(request, {"call-1": True}) + + +def test_facts_are_fresh_and_programming_errors_propagate( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + seen: list[Any] = [] + + def context_provider(deps: Any) -> dict[str, Any]: + seen.append(deps) + return {"tenant": deps["tenant"]} + + inner = _InnerToolset() + adapter = create_pydantic_ai_tool_policy(bindings, inner, context_provider=context_provider) + deps = {"tenant": "acme"} + ctx = _RunContext(deps=deps) + wrapper, tools = asyncio.run(_run_wrapper(adapter, ctx)) + assert ( + asyncio.run(wrapper.call_tool("read_file", {"mode": "read"}, ctx, tools["read_file"])) + == "executed" + ) + assert seen == [deps] + + malformed = create_pydantic_ai_tool_policy( + bindings, + _InnerToolset(), + context_provider=lambda _deps: [], # type: ignore[arg-type,return-value] + ) + malformed_ctx = _RunContext() + malformed_wrapper, malformed_tools = asyncio.run(_run_wrapper(malformed, malformed_ctx)) + with pytest.raises(PydanticAIIntegrationError, match="mapping or None"): + asyncio.run( + malformed_wrapper.call_tool( + "read_file", + {"mode": "read"}, + malformed_ctx, + malformed_tools["read_file"], + ) + ) + + def broken(_deps: Any) -> dict[str, Any]: + raise RuntimeError("application bug") + + broken_adapter = create_pydantic_ai_tool_policy( + bindings, _InnerToolset(), context_provider=broken + ) + broken_ctx = _RunContext() + broken_wrapper, broken_tools = asyncio.run(_run_wrapper(broken_adapter, broken_ctx)) + with pytest.raises(RuntimeError, match="application bug"): + asyncio.run( + broken_wrapper.call_tool( + "read_file", {"mode": "read"}, broken_ctx, broken_tools["read_file"] + ) + ) + + +def test_non_json_arguments_and_inner_errors_are_not_hidden( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, +) -> None: + class BrokenInner(_InnerToolset): + async def call_tool(self, *args: Any, **kwargs: Any) -> Any: + raise RuntimeError("callback failed") + + inner = BrokenInner() + adapter = create_pydantic_ai_tool_policy(bindings, inner) + ctx = _RunContext() + wrapper, tools = asyncio.run(_run_wrapper(adapter, ctx)) + with pytest.raises(InputValidationError, match="non-JSON"): + asyncio.run( + wrapper.call_tool( + "read_file", + {"mode": "read", "value": object()}, + ctx, + tools["read_file"], + ) + ) + with pytest.raises(RuntimeError, match="callback failed"): + asyncio.run(wrapper.call_tool("read_file", {"mode": "read"}, ctx, tools["read_file"])) From 1e70189683ffea5a41f72a8db03fa136faaed28e Mon Sep 17 00:00:00 2001 From: Deathcharge Date: Sun, 2 Aug 2026 08:21:20 -0400 Subject: [PATCH 2/2] Harden Pydantic AI deferred approval resume --- CHANGELOG.md | 5 +- README.md | 3 +- SECURITY.md | 2 + docs/API.md | 6 +- docs/ARCHITECTURE.md | 6 +- docs/PRODUCTIZATION.md | 14 +-- docs/PYDANTIC_AI.md | 30 ++++-- integration_tests/test_pydantic_ai_sdk.py | 51 +++++++++- src/samsarix_ethics/__init__.py | 4 + src/samsarix_ethics/pydantic_ai.py | 117 +++++++++++++++++++++- tests/test_public_api.py | 2 + tests/test_pydantic_ai.py | 37 +++++++ 12 files changed, 251 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba70311..42ce63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,8 +106,9 @@ All notable product changes are recorded here. enforcement, native LangGraph interrupts, strict fingerprint-bound approval resume, generic rejection results, a no-network real-agent example, and a dedicated hashed CI contract. - Optional exact-registry Pydantic AI `2.22.0` wrapper toolset with native deferred approvals, - strict Samsarix evidence on resume, fresh current-policy enforcement, a no-network real-agent - example, adversarial tests, and a dedicated slim hash-locked CI contract. + strict Samsarix evidence on resume, atomic single-use approval consumption, fresh current-policy + enforcement, a no-network real-agent example, adversarial tests, and a dedicated slim + hash-locked CI contract. ### Changed diff --git a/README.md b/README.md index d58035f..12ef757 100644 --- a/README.md +++ b/README.md @@ -623,7 +623,8 @@ After authenticating the reviewer, use `tool_policy.build_results(requests, deci resume evidence. A plain Pydantic AI boolean approval is not enough: resume requires Samsarix metadata, fresh actor/context providers, and current-policy re-enforcement. Pydantic AI performs schema validation before the wrapper, so this adapter authorizes validated JSON-native arguments; -custom argument validators must have no side effects. See the +custom argument validators must have no side effects. Approved results are first-write recorded +and atomically consumed; durable reconstruction supplies an application-owned approval store. See the [Pydantic AI toolset guide](docs/PYDANTIC_AI.md) for multi-call resolution, persistence, sensitive metadata, and unsupported-path boundaries. diff --git a/SECURITY.md b/SECURITY.md index 3d754f1..050030d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -73,6 +73,8 @@ carry adapter-built exact-call evidence and still pass current-policy enforcemen message/deferred state and conversation IDs, authenticate reviewers, enforce expiry and atomic one-time consumption, and treat other toolsets, direct calls, provider tools, and pre-delegation side effects as outside this adapter. Parallel calls remain non-transactional. +The default first-write/consume store blocks result replay only inside one live adapter instance; +durable reconstruction requires an application-owned implementation stored with workflow state. `ToolGate` invokes only the explicit callback supplied by the embedding application and only after an allow decision; it is not a sandbox. The package makes no network requests, executes no policy diff --git a/docs/API.md b/docs/API.md index 92243a0..a7d3de8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -442,7 +442,7 @@ strict `ToolCallApproval` dictionary matching the current call fingerprint befor enforcement. Rejection returns a generic error `ToolMessage` without invoking the tool. The adapter contract version is `LANGCHAIN_ADAPTER_VERSION = 1`. See [LANGCHAIN.md](LANGCHAIN.md). -### `create_pydantic_ai_tool_policy(bindings, toolset, *, actor_provider=None, context_provider=None)` +### `create_pydantic_ai_tool_policy(bindings, toolset, *, actor_provider=None, context_provider=None, approval_store=None)` Creates an optional `PydanticAIToolPolicy` for one exact `BoundToolCatalog` and one real Pydantic AI `AbstractToolset`. Construction imports Pydantic AI only when called. `toolset` returns a public @@ -451,6 +451,10 @@ catalog-matching dictionary of real `ToolsetTool` objects, and execution must re snapshotted object. Providers are synchronous callbacks from `RunContext.deps` to fresh application-owned JSON facts. +`approval_store` implements `PydanticAIApprovalStore.remember(...)` and atomic `.consume(...)`. +The bounded thread-safe process-local default retains at most +`MAX_PENDING_PYDANTIC_AI_APPROVALS` (4,096) pending calls and fails closed after reconstruction; +durable workflows supply protected application-owned state. Allow delegates once after audited enforcement. Deny raises the typed gate error. Review raises native `ApprovalRequired` with `PYDANTIC_AI_REVIEW_METADATA_KEY` metadata. `build_results(requests, decisions)` validates selected deferred calls and creates either diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 45a37f8..47b6004 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -20,7 +20,7 @@ locked policy deployment + fingerprinted catalog ─> ToolGateDeployment ─> ve verified bindings + final callback objects ─> ToolDispatcher ─> authorized sequential dispatch verified bindings + exact LangChain BaseTools ─> final middleware ─> allow / interrupt / block -verified bindings + exact Pydantic AI ToolsetTools ─> wrapper toolset ─> allow / defer / block +verified bindings + exact Pydantic AI ToolsetTool ─> wrapper toolset ─> allow / defer / block validated policy + optional contract/lock ─> PolicyRuntime generation N ─> live gates validated complete candidate ─> compare-and-swap atomic activation ──────┘ @@ -279,7 +279,9 @@ real `ToolsetTool` map every run step. The wrapper snapshots resolved tool ident Pydantic-validated detached JSON arguments, and delegates once only after final enforcement. Review raises native `ApprovalRequired`; the application converts selected requests through the adapter so resume metadata carries exact-call Samsarix evidence. Native approval alone cannot -authorize a reviewed call. Resume refreshes actor/context facts and re-enforces current policy. +authorize a reviewed call. A first-write store atomically consumes approved evidence so the same +result cannot execute twice; durable reconstruction supplies that store with protected workflow +state. Resume refreshes actor/context facts and re-enforces current policy. Pydantic schema and custom argument validation occur before the wrapper, while other toolsets, provider-side tools, and direct calls remain outside this boundary. diff --git a/docs/PRODUCTIZATION.md b/docs/PRODUCTIZATION.md index bf0a5c7..b39cd6d 100644 --- a/docs/PRODUCTIZATION.md +++ b/docs/PRODUCTIZATION.md @@ -309,8 +309,8 @@ certification or ethics truth. ## Completed work - Established the `samsarix_ethics` public API and `samsarix-ethics` console command. -- Added 552 real core tests; latest pinned local `python -m pytest` pytest-cov terminal report: 552 - passed and 95.05% total branch-aware coverage under the configured `--cov-branch` gate. Separate +- Added 554 real core tests; latest pinned local `python -m pytest` pytest-cov terminal report: 554 + passed and 94.90% total branch-aware coverage under the configured `--cov-branch` gate. Separate real-SDK contract tests run against exact hashed `openai-agents==0.18.3`, `langchain==1.3.14`, `pydantic-ai-slim==2.22.0`, and OpenTelemetry 1.44.0 dependency graphs. - Rebuilt the wheel and source distribution, passed `twine check`, and verified the wheel in an @@ -398,8 +398,9 @@ certification or ethics truth. sync/async suite preserve the dependency-free base package boundary. - Added an optional Pydantic AI wrapper toolset that exact-matches every real run-step registry, authorizes validated detached arguments, and converts native deferred review into - fingerprint-bound Samsarix resume evidence. A real no-network agent contract proves approval, - rejection, forged native-approval blocking, and registry-drift failure against exact + fingerprint-bound, atomically consumed Samsarix resume evidence. A real no-network agent + contract proves approval, replay blocking, rejection, deny, serialized-history resume, forged + native-approval blocking, and registry-drift failure against exact `pydantic-ai-slim==2.22.0` without changing the dependency-free base install. - Added a versioned metadata-only OpenTelemetry event sink, bounded ordered audit-sink composition, exact API/SDK contract test, and an in-memory no-network example. The application retains SDK, @@ -475,8 +476,9 @@ External validation gates: bypass or precede middleware, while parallel calls remain non-transactional. - Pydantic AI schema and custom argument validation occur before the wrapper; validators must be side-effect-free and policy sees validated values. Deferred state contains proposed arguments, - reviewer authentication/expiry/consumption remains application-owned, other execution paths can - bypass the wrapped toolset, and parallel calls are not transactional. + reviewer authentication/expiry remains application-owned, and durable reconstruction needs an + application-owned first-write/consume store. Other execution paths can bypass the wrapped + toolset, and parallel calls are not transactional. - File permissions and retention vary by operating system and are caller responsibilities. - Python dependency tooling resolves transitive development dependencies; exact direct pins reduce drift but do not constitute a fully hashed supply-chain lock. diff --git a/docs/PYDANTIC_AI.md b/docs/PYDANTIC_AI.md index 1e3d68e..7a2e563 100644 --- a/docs/PYDANTIC_AI.md +++ b/docs/PYDANTIC_AI.md @@ -23,7 +23,7 @@ Create and populate the toolset, then bind the same complete name set to a trust catalog: ```python -from pydantic_ai import Agent, FunctionToolset +from pydantic_ai import Agent, DeferredToolRequests, FunctionToolset from samsarix_ethics import ToolGate, create_pydantic_ai_tool_policy tools = FunctionToolset() @@ -46,7 +46,12 @@ tool_policy = create_pydantic_ai_tool_policy( actor_provider=lambda deps: {"id": deps.user_id}, context_provider=lambda deps: {"tenant": deps.tenant_id}, ) -agent = Agent(model, toolsets=[tool_policy.toolset], deps_type=ApplicationContext) +agent = Agent( + model, + toolsets=[tool_policy.toolset], + deps_type=ApplicationContext, + output_type=[str, DeferredToolRequests], +) ``` The wrapped registry must contain only real Pydantic AI `ToolsetTool` values, each registry key @@ -93,12 +98,21 @@ Pydantic AI's plain native `True` approval is deliberately insufficient because does not define approval as an application authorization boundary. Forging or omitting Samsarix metadata fails closed. -On approved resume, the adapter recomputes the fingerprint from the current name, validated -arguments, trusted catalog capabilities, and freshly supplied actor. It then re-evaluates the -current policy and context facts. Changed arguments or actor, a different call ID, missing or -malformed evidence, a replay against another call, or a current deny/review never invokes the -tool. Approval evidence is ordinary unsigned application data: the reviewer system must own -identity, authorization, expiry, revocation, persistence, and atomic one-time consumption. +On approved resume, the adapter recomputes the fingerprint from the tool-context format version, +current call ID, name, validated arguments, trusted catalog capabilities, and freshly supplied +actor. General context and the optional context-contract identity/version are deliberately not +fingerprint fields; current policy and context facts are re-evaluated instead. Changed bound fields, +missing or malformed evidence, replay against another call, or a current deny/review never invokes +the tool. + +Approved `build_results` also records the fingerprint in a first-write approval store, and resume +atomically consumes it before final enforcement. Replaying the same result fails closed. The +bounded thread-safe default retains up to 4,096 calls in one process and fails closed after +reconstruction. Durable workflows supply `approval_store=` implementing synchronous `remember` +and atomic `consume` methods alongside protected Pydantic state. The store is trusted security +state; it does not authenticate a reviewer. The reviewer system still owns identity, +authorization, expiry, revocation, durable persistence, and prevention of repeatedly minting new +results from the same pending review. For multiple deferred calls, an application can resolve a subset and later combine or supply the result objects according to Pydantic AI's workflow contract. Samsarix does not make parallel tool diff --git a/integration_tests/test_pydantic_ai_sdk.py b/integration_tests/test_pydantic_ai_sdk.py index 3a1228b..6e9a5cf 100644 --- a/integration_tests/test_pydantic_ai_sdk.py +++ b/integration_tests/test_pydantic_ai_sdk.py @@ -10,12 +10,14 @@ import pytest from pydantic_ai import Agent, DeferredToolRequests, FunctionToolset +from pydantic_ai.messages import ModelMessagesTypeAdapter from pydantic_ai.models.test import TestModel from samsarix_ethics import ( PYDANTIC_AI_REVIEW_METADATA_KEY, Policy, PydanticAIIntegrationError, + ToolCallDeniedError, ToolCatalog, ToolCatalogValidationError, ToolGate, @@ -23,7 +25,26 @@ ) -def _bindings() -> Any: +def _bindings(*, deny: bool = False) -> Any: + deny_rules = ( + [ + { + "id": "deny-recipient", + "effect": "deny", + "priority": -1, + "message": "This recipient is blocked.", + "conditions": [ + { + "field": "action.arguments.recipient", + "operator": "eq", + "value": "a", + } + ], + } + ] + if deny + else [] + ) policy = Policy.from_dict( { "schema_version": 1, @@ -31,6 +52,7 @@ def _bindings() -> Any: "version": "1", "default_effect": "deny", "rules": [ + *deny_rules, { "id": "allow-approved", "effect": "allow", @@ -69,7 +91,7 @@ def _bindings() -> Any: return ToolGate(policy).bind_catalog(catalog, registered_tools=["send_message"]) -def _agent(calls: list[str]) -> tuple[Agent[Any, Any], Any]: +def _agent(calls: list[str], *, deny: bool = False) -> tuple[Agent[Any, Any], Any]: tools = FunctionToolset() @tools.tool_plain @@ -77,7 +99,7 @@ def send_message(recipient: str) -> str: calls.append(recipient) return "sent" - policy = create_pydantic_ai_tool_policy(_bindings(), tools) + policy = create_pydantic_ai_tool_policy(_bindings(deny=deny), tools) agent = Agent( TestModel(call_tools=["send_message"]), toolsets=[policy.toolset], @@ -103,10 +125,12 @@ def test_real_agent_approves_exact_deferred_call_once() -> None: "arguments": {"recipient": "a"}, } results = policy.build_results(first.output, {pending.tool_call_id: True}) + serialized_history = ModelMessagesTypeAdapter.dump_json(first.all_messages()) + restored_history = ModelMessagesTypeAdapter.validate_json(serialized_history) completed = agent.run_sync( "Continue.", - message_history=first.all_messages(), + message_history=restored_history, deferred_tool_results=results, conversation_id="approval-contract", ) @@ -114,6 +138,25 @@ def test_real_agent_approves_exact_deferred_call_once() -> None: assert not isinstance(completed.output, DeferredToolRequests) assert calls == ["a"] + with pytest.raises(PydanticAIIntegrationError, match="already consumed"): + agent.run_sync( + "Continue.", + message_history=restored_history, + deferred_tool_results=results, + conversation_id="approval-contract-replay", + ) + assert calls == ["a"] + + +def test_real_agent_deny_never_calls_tool() -> None: + calls: list[str] = [] + agent, _policy = _agent(calls, deny=True) + + with pytest.raises(ToolCallDeniedError): + agent.run_sync("Send the blocked update.", conversation_id="deny-contract") + + assert calls == [] + def test_real_agent_rejection_never_calls_tool() -> None: calls: list[str] = [] diff --git a/src/samsarix_ethics/__init__.py b/src/samsarix_ethics/__init__.py index 3af6a13..bfa2f4d 100644 --- a/src/samsarix_ethics/__init__.py +++ b/src/samsarix_ethics/__init__.py @@ -199,9 +199,11 @@ fingerprint_tool_gate_deployment, ) from .pydantic_ai import ( + MAX_PENDING_PYDANTIC_AI_APPROVALS, PYDANTIC_AI_ADAPTER_VERSION, PYDANTIC_AI_APPROVAL_METADATA_KEY, PYDANTIC_AI_REVIEW_METADATA_KEY, + PydanticAIApprovalStore, PydanticAIIntegrationError, PydanticAIToolPolicy, create_pydantic_ai_tool_policy, @@ -281,6 +283,7 @@ "MAX_DEPLOYMENT_AUTH_SEQUENCE", "MAX_DEPLOYMENT_LOCK_BYTES", "MAX_PENDING_OPENAI_APPROVALS", + "MAX_PENDING_PYDANTIC_AI_APPROVALS", "MAX_POLICY_DEPLOYMENT_BYTES", "MAX_POLICY_RULES", "MAX_POLICY_TEST_BYTES", @@ -387,6 +390,7 @@ "PolicyTestValidationError", "PolicyValidationError", "PreparedToolCall", + "PydanticAIApprovalStore", "PydanticAIIntegrationError", "PydanticAIToolPolicy", "RuleExplanation", diff --git a/src/samsarix_ethics/pydantic_ai.py b/src/samsarix_ethics/pydantic_ai.py index 884dddb..cdbc2e8 100644 --- a/src/samsarix_ethics/pydantic_ai.py +++ b/src/samsarix_ethics/pydantic_ai.py @@ -10,7 +10,8 @@ from collections.abc import Callable, Mapping from dataclasses import dataclass from importlib import import_module -from typing import Any, cast +from threading import Lock +from typing import Any, Protocol, cast from .approval import TOOL_CALL_APPROVAL_VERSION, ToolCallApproval from .catalog import MAX_TOOL_CATALOG_TOOLS, validate_tool_catalog_registration @@ -23,6 +24,7 @@ PYDANTIC_AI_ADAPTER_VERSION = 1 PYDANTIC_AI_REVIEW_METADATA_KEY = "samsarix.tool_call.review" PYDANTIC_AI_APPROVAL_METADATA_KEY = "samsarix.tool_call.approval" +MAX_PENDING_PYDANTIC_AI_APPROVALS = 4096 _REJECTION_MESSAGE = "Tool call rejected by human review." _FactsProvider = Callable[[Any], Mapping[str, Any] | None] @@ -32,6 +34,64 @@ class PydanticAIIntegrationError(SamsarixEthicsError): """Raised when Pydantic AI cannot enforce a tool policy safely.""" +class PydanticAIApprovalStore(Protocol): + """Application-owned first-write and atomic-consume approval state.""" + + def remember( + self, + tool_name: str, + tool_call_id: str, + tool_call_fingerprint: str, + ) -> str: + """Atomically retain and return the first fingerprint for this call.""" + + def consume( + self, + tool_name: str, + tool_call_id: str, + tool_call_fingerprint: str, + ) -> bool: + """Atomically remove matching state, returning whether it existed.""" + + +class _InMemoryApprovalStore: + def __init__(self) -> None: + self._lock = Lock() + self._fingerprints: dict[tuple[str, str], str] = {} + + def remember( + self, + tool_name: str, + tool_call_id: str, + tool_call_fingerprint: str, + ) -> str: + key = (tool_name, tool_call_id) + with self._lock: + existing = self._fingerprints.get(key) + if existing is not None: + return existing + if len(self._fingerprints) >= MAX_PENDING_PYDANTIC_AI_APPROVALS: + raise PydanticAIIntegrationError("in-memory Pydantic AI approval store is full") + self._fingerprints[key] = tool_call_fingerprint + return tool_call_fingerprint + + def consume( + self, + tool_name: str, + tool_call_id: str, + tool_call_fingerprint: str, + ) -> bool: + key = (tool_name, tool_call_id) + with self._lock: + existing = self._fingerprints.get(key) + if existing is None or not hmac.compare_digest( + existing.encode("utf-8"), tool_call_fingerprint.encode("utf-8") + ): + return False + del self._fingerprints[key] + return True + + def _empty_facts(_application_context: Any) -> Mapping[str, Any]: return {} @@ -62,6 +122,7 @@ class PydanticAIToolPolicy: _bindings: BoundToolCatalog _actor_provider: _FactsProvider _context_provider: _FactsProvider + _approval_store: PydanticAIApprovalStore _abstract_toolset_type: type[Any] _toolset_tool_type: type[Any] _run_context_type: type[Any] @@ -254,6 +315,14 @@ async def _call_tool( } ) approval = self._verify_approval(value, getattr(ctx, "tool_call_metadata", None)) + if not self._approval_store.consume( + value.tool_name, + value.tool_call_id, + value.fingerprint, + ): + raise PydanticAIIntegrationError( + "Pydantic AI approval is missing, already consumed, or does not match" + ) value.binding.enforce( value.arguments, @@ -329,6 +398,18 @@ def build_results(self, requests: Any, decisions: Mapping[str, bool]) -> Any: payload = per_call_metadata.get(PYDANTIC_AI_REVIEW_METADATA_KEY) approval = self._approval_from_payload(call, payload, approved=approved) if approved: + remembered = self._approval_store.remember( + cast(str, getattr(call, "tool_name", None)), + call_id, + approval.tool_call_fingerprint, + ) + if not hmac.compare_digest( + remembered.encode("utf-8"), + approval.tool_call_fingerprint.encode("utf-8"), + ): + raise PydanticAIIntegrationError( + "Pydantic AI approval store contains different call evidence" + ) results[call_id] = True metadata[call_id] = {PYDANTIC_AI_APPROVAL_METADATA_KEY: approval.to_dict()} else: @@ -358,7 +439,25 @@ def _approval_from_payload( raise PydanticAIIntegrationError("Pydantic AI review metadata is malformed") call_id = getattr(call, "tool_call_id", None) tool_name = getattr(call, "tool_name", None) - arguments = getattr(call, "args", None) + args_as_dict = getattr(call, "args_as_dict", None) + if not callable(args_as_dict): + raise PydanticAIIntegrationError( + "Pydantic AI deferred approval has no args_as_dict method" + ) + try: + arguments = args_as_dict() + except (TypeError, ValueError) as exc: + raise PydanticAIIntegrationError( + "Pydantic AI deferred approval arguments are malformed" + ) from exc + if not isinstance(arguments, dict): + raise PydanticAIIntegrationError( + "Pydantic AI deferred approval arguments must be a dictionary" + ) + arguments = validate_context( + arguments, + label="Pydantic AI deferred approval arguments", + ) if binding.get("tool_call_id") != call_id: raise PydanticAIIntegrationError( "Pydantic AI review metadata call ID does not match the pending call" @@ -382,6 +481,7 @@ def create_pydantic_ai_tool_policy( *, actor_provider: _FactsProvider | None = None, context_provider: _FactsProvider | None = None, + approval_store: PydanticAIApprovalStore | None = None, ) -> PydanticAIToolPolicy: """Wrap one exact Pydantic AI toolset without adding a core dependency.""" @@ -389,6 +489,18 @@ def create_pydantic_ai_tool_policy( raise TypeError("bindings must be a BoundToolCatalog") actor = _validate_provider(actor_provider, label="actor_provider") context = _validate_provider(context_provider, label="context_provider") + selected_store: PydanticAIApprovalStore = ( + _InMemoryApprovalStore() if approval_store is None else approval_store + ) + remember = getattr(selected_store, "remember", None) + consume = getattr(selected_store, "consume", None) + if ( + not callable(remember) + or inspect.iscoroutinefunction(remember) + or not callable(consume) + or inspect.iscoroutinefunction(consume) + ): + raise TypeError("approval_store must define synchronous remember and consume methods") try: pydantic_ai = import_module("pydantic_ai") abstract_toolset_type = pydantic_ai.AbstractToolset @@ -462,6 +574,7 @@ async def call_tool( _bindings=bindings, _actor_provider=actor, _context_provider=context, + _approval_store=selected_store, _abstract_toolset_type=cast(type[Any], abstract_toolset_type), _toolset_tool_type=cast(type[Any], toolset_tool_type), _run_context_type=cast(type[Any], run_context_type), diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 3884c41..e626d47 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -32,6 +32,7 @@ def test_public_api_is_importable() -> None: assert "OpenAIAgentsToolPolicy" in samsarix_ethics.__all__ assert "create_openai_agents_tool_policy" in samsarix_ethics.__all__ assert "PydanticAIIntegrationError" in samsarix_ethics.__all__ + assert "PydanticAIApprovalStore" in samsarix_ethics.__all__ assert "PydanticAIToolPolicy" in samsarix_ethics.__all__ assert "create_pydantic_ai_tool_policy" in samsarix_ethics.__all__ assert "OpenTelemetryDecisionEventSink" in samsarix_ethics.__all__ @@ -122,6 +123,7 @@ def test_public_api_is_importable() -> None: assert samsarix_ethics.LANGCHAIN_REVIEW_INTERRUPT_TYPE == "samsarix.tool_call.review" assert samsarix_ethics.OPENAI_AGENTS_ADAPTER_VERSION == 1 assert samsarix_ethics.PYDANTIC_AI_ADAPTER_VERSION == 1 + assert samsarix_ethics.MAX_PENDING_PYDANTIC_AI_APPROVALS == 4096 assert samsarix_ethics.PYDANTIC_AI_APPROVAL_METADATA_KEY == "samsarix.tool_call.approval" assert samsarix_ethics.PYDANTIC_AI_REVIEW_METADATA_KEY == "samsarix.tool_call.review" assert samsarix_ethics.OPENTELEMETRY_DECISION_EVENT_NAME == "samsarix.policy.decision" diff --git a/tests/test_pydantic_ai.py b/tests/test_pydantic_ai.py index 336a580..7c58b3a 100644 --- a/tests/test_pydantic_ai.py +++ b/tests/test_pydantic_ai.py @@ -6,6 +6,7 @@ from __future__ import annotations import asyncio +import json from dataclasses import dataclass, field from types import SimpleNamespace from typing import Any @@ -70,6 +71,11 @@ class _DeferredCall: args: Any tool_call_id: Any + def args_as_dict(self) -> Any: + if isinstance(self.args, str): + return json.loads(self.args) + return self.args + @dataclass class _ToolDenied: @@ -251,6 +257,8 @@ async def async_provider(_context: Any) -> dict[str, Any]: with pytest.raises(TypeError, match="synchronous callable"): create_pydantic_ai_tool_policy(bindings, inner, actor_provider=async_provider) + with pytest.raises(TypeError, match="remember and consume"): + create_pydantic_ai_tool_policy(bindings, inner, approval_store=object()) monkeypatch.setattr( adapter_module, @@ -358,6 +366,15 @@ def test_review_builds_exact_results_and_resumes_or_rejects( ) assert len(inner.calls) == 1 + replay_wrapper, replay_tools = asyncio.run(_run_wrapper(adapter, resumed_ctx)) + with pytest.raises(PydanticAIIntegrationError, match="already consumed"): + asyncio.run( + replay_wrapper.call_tool( + "send_message", arguments, resumed_ctx, replay_tools["send_message"] + ) + ) + assert len(inner.calls) == 1 + rejected = adapter.build_results(request, {"call-1": False}) assert rejected.approvals == {"call-1": _ToolDenied("Tool call rejected by human review.")} assert rejected.metadata == {} @@ -486,6 +503,26 @@ def _valid_deferred_request() -> _DeferredToolRequests: ) +@pytest.mark.parametrize( + "pending_arguments", + [{"mode": "send"}, '{"mode":"send"}'], + ids=["mapping", "json-string"], +) +def test_build_results_normalizes_pending_arguments( + fake_pydantic_ai: None, + bindings: BoundToolCatalog, + pending_arguments: Any, +) -> None: + adapter = create_pydantic_ai_tool_policy(bindings, _InnerToolset()) + request = _valid_deferred_request() + request.approvals[0].args = pending_arguments + + results = adapter.build_results(request, {"call-1": True}) + + assert results.approvals == {"call-1": True} + assert PYDANTIC_AI_APPROVAL_METADATA_KEY in results.metadata["call-1"] + + def test_build_results_rejects_mutated_request_metadata( fake_pydantic_ai: None, bindings: BoundToolCatalog,