From b011f149497bf473dce4be867a28d6dfa94a2b4f Mon Sep 17 00:00:00 2001 From: Ernest Provo Date: Sun, 19 Jul 2026 00:32:35 -0400 Subject: [PATCH] feat: add fail-closed policy enforcement (DSE-716) --- DOCUMENTATION_INDEX.md | 7 +- README.md | 8 + SYSTEM_CONTEXT_DIAGRAM.md | 17 +- docs/AGENT_TRUST_KERNEL.md | 19 + docs/POLICY_ENFORCEMENT.md | 240 ++++++ docs/plans/2026-07-19-pdp-pep-design.md | 59 ++ src/mcp_warden/adapter_conformance.py | 475 +++++++++++ src/mcp_warden/decision_models.py | 496 +++++++++++ src/mcp_warden/executable_bundle.py | 365 ++++++++ src/mcp_warden/handler_identity.py | 155 ++++ src/mcp_warden/policy_decision.py | 815 ++++++++++++++++++ src/mcp_warden/policy_enforcement.py | 1043 +++++++++++++++++++++++ tests/fuzz/test_fuzz_policy_decision.py | 100 +++ tests/test_adapter_conformance.py | 207 +++++ tests/test_policy_decision.py | 696 +++++++++++++++ tests/test_policy_enforcement.py | 1041 ++++++++++++++++++++++ 16 files changed, 5735 insertions(+), 8 deletions(-) create mode 100644 docs/POLICY_ENFORCEMENT.md create mode 100644 docs/plans/2026-07-19-pdp-pep-design.md create mode 100644 src/mcp_warden/adapter_conformance.py create mode 100644 src/mcp_warden/decision_models.py create mode 100644 src/mcp_warden/executable_bundle.py create mode 100644 src/mcp_warden/handler_identity.py create mode 100644 src/mcp_warden/policy_decision.py create mode 100644 src/mcp_warden/policy_enforcement.py create mode 100644 tests/fuzz/test_fuzz_policy_decision.py create mode 100644 tests/test_adapter_conformance.py create mode 100644 tests/test_policy_decision.py create mode 100644 tests/test_policy_enforcement.py diff --git a/DOCUMENTATION_INDEX.md b/DOCUMENTATION_INDEX.md index 4e6e123..1463429 100644 --- a/DOCUMENTATION_INDEX.md +++ b/DOCUMENTATION_INDEX.md @@ -84,6 +84,7 @@ scope-honesty box and makes no compliance/regulatory claim. | [`docs/THREAT_MODEL_V2.md`](docs/THREAT_MODEL_V2.md) | **(v0.2)** Addendum extending the v0.1 model: T-RESULT vectors, the defends (BLOCK) / monitors (fuzzy) / still-does-NOT-defend (T-BEHAVE) table, runtime trust-model notes, retained + added cuts, shadow-default positioning | | [`docs/AGENT_TRUST_KERNEL.md`](docs/AGENT_TRUST_KERNEL.md) | **(DSE-714, design contract)** Normative invariants for the future deterministic Agent Trust Kernel: trust boundaries, complete mediation, default deny, non-overridable critical classes, evidence-before-effect, offline operation, residual risks, and bindings for DSE-715 through DSE-717. MCP-Warden v1.1 is explicitly not yet ATK-conformant | | [`docs/CONTENT_ENVELOPE.md`](docs/CONTENT_ENVELOPE.md) | **(DSE-715, implemented foundation)** Strict immutable V1 content envelope, domain-separated exact-byte digests, canonical metadata boundary, bounded one-hop lineage, monotonic taint, stable code-only errors, and secret-safe public projection. Evidence only; no authority or whole-ATK conformance claim | +| [`docs/POLICY_ENFORCEMENT.md`](docs/POLICY_ENFORCEMENT.md) | **(DSE-716, implemented foundation)** Versioned signed policy/runtime/adapter/executable-bundle activation, exact adapter/bundle-bound leases, mechanically derived frozen handler identity, deterministic default-deny PDP, evidence-gated structural PEP, stable reason/recovery matrix, caps, and non-optional fixed-corpus adapter harness. DSE-717 durable evidence remains required | | [`docs/WARDEN_LOCK_SCHEMA.md`](docs/WARDEN_LOCK_SCHEMA.md) | **mcp-warden implementation of [`docs/SPEC.md`](docs/SPEC.md) (MCP Lock Format v1).** `warden.lock` format, RFC 8785 canonicalization + SHA-256 hashing, field/entry/overall digests, the normative drift definition + severities; **§5.1/§6.2 structural schema diff** (normalized per-tool `schema_skeleton`, `schema_version` 3 — skeleton added at v2, in-document `$ref` resolution at v3 (#29), granular `WRD-DRIFT-SCHEMA-*` taxonomy + severities, v1 fallback); **§8.1/§8.2 (v0.3, #19)** structured out-of-digest provenance (`pinner` / `attestations` / `rotation_count`, `PROVENANCE_VERSION`, B4 `bound_digest` format) + `lock rotate` digest-invariant semantics + the #16 signing implication; **§11 (v0.2)** optional per-tool inspection policy (`expected_output_charset` / `may_return_urls` / `secret_echo_applies`, fail-safe defaults, digest impact) | | [`docs/WARDEN_LOCK_EXAMPLE.md`](docs/WARDEN_LOCK_EXAMPLE.md) | Illustrative full `warden.lock` + a post-`lock rotate` `pin` block (archived from WARDEN_LOCK_SCHEMA §9 to keep that core doc under the line cap) | | [`docs/CHECKS.md`](docs/CHECKS.md) | The deterministic `WRD-*` static-check catalog (capability/secret/supply/robustness), the shared tokenizer, severity→SARIF mapping, redaction rule, CUT list. **Reused by v0.2** `WRD-RES-SECRET-ECHO` (the `WRD-SEC-*` patterns + redaction) | @@ -99,6 +100,7 @@ scope-honesty box and makes no compliance/regulatory claim. |---|---| | [`docs/plans/2026-07-18-agent-trust-kernel-design.md`](docs/plans/2026-07-18-agent-trust-kernel-design.md) | **Non-normative execution record.** Records the DSE-714 design decision and verification plan; binding requirements live in `docs/AGENT_TRUST_KERNEL.md` | | [`docs/plans/2026-07-18-content-envelope-design.md`](docs/plans/2026-07-18-content-envelope-design.md) | **Non-normative DSE-715 execution record.** Records the reviewed strict-TDD plan; verified behavior is documented in `docs/CONTENT_ENVELOPE.md` | +| [`docs/plans/2026-07-19-pdp-pep-design.md`](docs/plans/2026-07-19-pdp-pep-design.md) | **Non-normative DSE-716 execution record.** Records the activated-snapshot, structural-PEP, TDD, and review plan; verified behavior is documented in `docs/POLICY_ENFORCEMENT.md` | --- @@ -133,6 +135,8 @@ scope-honesty box and makes no compliance/regulatory claim. |--------|----------------|-------------| | `src/mcp_warden/hashing.py` | `canon()` (RFC 8785) + `hash()` + field hashes | WARDEN_LOCK_SCHEMA §3 | | `src/mcp_warden/content_models.py` · `content_envelope.py` | **(DSE-715)** Strict immutable evidence models; typed domain-separated byte hashing call surface; canonical constructors/parser; bounded derivation and one-hop lineage; explicit secret-safe projection | CONTENT_ENVELOPE.md / AGENT_TRUST_KERNEL ATK-01/02/05/06/09/12 | +| `src/mcp_warden/decision_models.py` · `policy_decision.py` | **(DSE-716)** Strict V1 decision models; signed policy/runtime activation; exact lease/request binding; deterministic default-deny PDP; canonical digest-only decisions and stable recovery codes | POLICY_ENFORCEMENT.md / AGENT_TRUST_KERNEL ATK-03/05/06/07/08/09 | +| `src/mcp_warden/policy_enforcement.py` · `handler_identity.py` · `executable_bundle.py` · `adapter_conformance.py` | **(DSE-716)** Signed adapter and executable-bundle closure; exact manifest-bound leases; closure-free mechanically code/dependency-derived frozen handlers; canonical effect binding; evidence-gated PEP; PEP-owned execution trace and multiple output-channel scans; mandatory versioned malformed-input corpus | POLICY_ENFORCEMENT.md / AGENT_TRUST_KERNEL ATK-03/04/06/08 | | `src/mcp_warden/tokenizer.py` | Shared tokenizer + capability derivation (single source of truth) | CHECKS §3 / WARDEN_LOCK_SCHEMA §5.4 | | `src/mcp_warden/capture.py` | Declared-surface capture over stdio (argv array, no shell) or Streamable HTTP (`--url`); shared list normalization, timeouts, and errors | THREAT_MODEL §3.3 / WARDEN_LOCK_SCHEMA §4.1 | | `src/mcp_warden/models.py` | Pydantic models for captured surface + lock (incl. `Pinner`/`Attestation` provenance) | WARDEN_LOCK_SCHEMA §2–§8 | @@ -173,6 +177,7 @@ scope-honesty box and makes no compliance/regulatory claim. |------|--------| | `tests/test_hashing.py` | JCS+SHA-256 reproducibility, canonical-form pins, null handling | | `tests/test_content_envelope.py` | **(DSE-715)** Strict/frozen models, typed digest domains, nine ingress kinds, canonical/cap boundary, deterministic lineage, parser rejection matrix, atomic digest verification, and planted-secret output/error/log scans | +| `tests/test_policy_decision.py` · `test_policy_enforcement.py` · `test_adapter_conformance.py` | **(DSE-716)** Exact policy/runtime/adapter/bundle activation, deterministic decisions, every subject/effect/lease/manifest binding, trusted-time/rollback/revocation matrix, critical floor, hostile nested construction, callable drift resistance, evidence/sink ordering, fixed-corpus enforcement, cap boundaries, and deterministic reports | | `tests/test_tokenizer.py` | Segment-exact tokenization + capability derivation | | `tests/test_checks.py` | Capability/secret/supply/robustness checks + redaction | | `tests/test_drift.py` | Drift per class (added/removed/modified/server-identity/unapproved) | @@ -192,7 +197,7 @@ scope-honesty box and makes no compliance/regulatory claim. | `tests/test_guard_v3.py` | **(v0.3)** opt-out demotes to shadow (`--no-block-*`/`--allow-exfil-domain`/`--no-block-deterministic`), `tools/list_changed` gate block+shadow, policy deny block+shadow, audit-only override, cancel/progress passthrough, **server-crash → `-32002` for every pending id**, client-disconnect child reap (no orphan), truncated + oversized frame fail-open | | `tests/test_guard_strict.py` | **(#21)** `--strict` fail-CLOSED: 4 terminate sites (request-policy / result-inspect / list-gate / nested-hash re-raise) → exit `3` + one `strict_abort` stderr line + `-32003` client frame + child reaped; negatives (truncated/over-cap/unparseable/clean) do NOT abort; default `--no-strict` byte-identical fail-open regression; secret-leak redaction; CLI threading; double-emission single line; `StrictInspectionAbort` is `BaseException`-not-`Exception` + anyio `ExceptionGroup` unwrap | | `tests/test_inspect_parity.py` | **(v0.2)** guard↔inspect finding parity on the same recorded frames + inspect exit codes | -| `tests/fuzz/` (`test_fuzz_framing.py` · `test_fuzz_ansi.py` · `test_fuzz_domain.py` · `test_fuzz_redact.py` · `test_fuzz_content_envelope.py`) | **(#17 / DSE-715)** `hypothesis` property-fuzzing of the live runtime attack surface plus content-envelope repeatability, round-trip, parent permutation, taint monotonicity, mutation rejection, malformed-byte bounded termination, flat-cycle non-recursion, registry drift, and planted-secret absence. `ci`/`fuzz` profiles in `tests/fuzz/conftest.py`; deep soak via `make fuzz` | +| `tests/fuzz/` (`test_fuzz_framing.py` · `test_fuzz_ansi.py` · `test_fuzz_domain.py` · `test_fuzz_redact.py` · `test_fuzz_content_envelope.py` · `test_fuzz_policy_decision.py`) | **(#17 / DSE-715 / DSE-716)** `hypothesis` property-fuzzing of the live runtime attack surface, content envelopes, identity substitution, arbitrary effect bytes, explicit trusted-time boundaries, deterministic decisions, and planted-secret absence. `ci`/`fuzz` profiles in `tests/fuzz/conftest.py`; deep soak via `make fuzz` | | `tests/fixtures/clean_server.py` · `mutated_server.py` | Real MCP SDK stdio fixtures | | `tests/fixtures/poison_server.py` | **(v0.2)** result-poisoning fixture server (ANSI/secret-echo/exfil/inject/clean tools) | | `tests/fixtures/crash_server.py` · `listchange_server.py` · `clean_listchange.warden.lock` | **(v0.3)** raw-stdio lifecycle fixtures: crash-mid-call (`-32002`) and `tools/list_changed` rug-pull + its pinned clean lock | diff --git a/README.md b/README.md index 7026640..eea1bfb 100644 --- a/README.md +++ b/README.md @@ -462,6 +462,14 @@ empty `allow_paths` = deny-all. See [`docs/POLICY_MODEL.md`](docs/POLICY_MODEL.m ## Documentation +Agent Trust Kernel development is intentionally isolated from the shipped `guard` path: +DSE-715's content envelope and DSE-716's deterministic PDP/PEP, exact signed adapter/bundle load +gates, frozen handler identity, and fixed-corpus adapter harness are implemented foundations, but +the default evidence gate denies effects. Durable signed receipts, +fallback evidence, rollback-resistant state, the recovery latch, and any whole-kernel conformance +claim remain DSE-717. See [`docs/POLICY_ENFORCEMENT.md`](docs/POLICY_ENFORCEMENT.md) and +[`docs/AGENT_TRUST_KERNEL.md`](docs/AGENT_TRUST_KERNEL.md). + See [`DOCUMENTATION_INDEX.md`](DOCUMENTATION_INDEX.md). The security-contract specs under `docs/` (including [`GUARD_PROXY_V3.md`](docs/GUARD_PROXY_V3.md) for the v0.3 default-block + lifecycle contract) are the source of truth for every algorithm; the diff --git a/SYSTEM_CONTEXT_DIAGRAM.md b/SYSTEM_CONTEXT_DIAGRAM.md index 0ebe8ea..2490225 100644 --- a/SYSTEM_CONTEXT_DIAGRAM.md +++ b/SYSTEM_CONTEXT_DIAGRAM.md @@ -20,11 +20,12 @@ logic) plus a separate informational provenance section. It never prints raw `server.command`/`args` (secret-safe); default exit 0, `--exit-code` → 1 on integrity drift only. > **Agent Trust Kernel status:** [`docs/AGENT_TRUST_KERNEL.md`](docs/AGENT_TRUST_KERNEL.md) -> is the normative contract. DSE-715 implements the isolated content-envelope foundation -> (untrusted ingress, deterministic lineage, monotonic taint, secret-safe projection), but it -> is not wired into `guard` and grants no authority. DSE-716/717 must still deliver PDP/PEP -> complete mediation and signed evidence-before-effect. The current `guard` path is not -> represented as ATK-conformant. +> is the normative contract. DSE-715 implements the isolated content-envelope foundation. +> DSE-716 implements isolated signed policy/runtime/adapter/bundle activation, exact +> adapter/bundle lease binding, deterministic PDP, evidence-gated PEP, frozen handler identity, +> and fixed-corpus adapter-conformance APIs. None is wired into `guard`; the default evidence gate denies +> effects. DSE-717 must still deliver durable signed evidence, fallback, rollback-resistant +> state, and the recovery latch. The current `guard` path is not represented as ATK-conformant. > `conclave` (the 4-model adversarial council referenced in `docs/THREAT_MODEL.md`) > is a **dev-time design reviewer** that shaped this contract. It is **NOT** a @@ -66,9 +67,11 @@ flowchart TB end envelope["Content Envelope V1\nDSE-715 · implemented evidence foundation\nNOT wired to guard · grants no authority"] - future["Future PDP/PEP + signed evidence\nDSE-716–717 · NOT IMPLEMENTED"] + decision["Deterministic PDP/PEP V1\nDSE-716 · signed adapter/bundle gates + fixed corpus\nNOT wired to guard"] + evidence["Durable evidence + recovery state\nDSE-717 · NOT IMPLEMENTED\ndefault gate denies effects"] atk -. "governs partial foundation" .-> envelope - envelope -. "required input" .-> future + envelope -. "required input" .-> decision + decision -. "requires production gate" .-> evidence subgraph ci["CI pipeline (GitHub Actions / local)"] warden["mcp-warden CLI\npin · check · policy · lock rotate · diff"] diff --git a/docs/AGENT_TRUST_KERNEL.md b/docs/AGENT_TRUST_KERNEL.md index 10aa1e0..2a1c4c9 100644 --- a/docs/AGENT_TRUST_KERNEL.md +++ b/docs/AGENT_TRUST_KERNEL.md @@ -440,6 +440,25 @@ This partial implementation does not change the current-product boundary: no shi may claim ATK conformance until DSE-716/717 integrate the envelope through the complete mediation and durable evidence paths and every conformance gate below passes. +### DSE-716 implementation status + +The versioned DSE-716 modules now implement the isolated decision/enforcement foundation for +ATK-03 through ATK-09: exact signed policy/runtime/adapter/executable-bundle activation, adapter and +bundle dependency closure, leases bound to exact adapter and executable-bundle manifests, +deterministic default-deny PDP decisions, a non-overridable V1 critical floor, closure-free, +mutable-global-free mechanically code/dependency-derived frozen handler registration, a PEP whose supported path +orders exact binding then decision then bound evidence then sink, and a PEP-owned instrumented +foundation harness with registration evidence, multiple serialized output-channel scans, and a +non-optional versioned malformed-input corpus. The default evidence gate denies every otherwise +allowed effect. + +This is still a partial implementation. It is not wired into the historical `guard`, supplies no +built-in production verifier or live protocol adapter, and does not implement DSE-717's durable +signed receipts, independent fallback evidence, rollback-resistant state, or persistent recovery +latch. A custom evidence gate becomes TCB code and does not create an ATK-conformance claim. No +production effect or whole-kernel claim is valid until DSE-717 closes ATK-10 through ATK-12 and +the full §11 platform/restart/rollback gates pass. + ## 11. Conformance gates A component or adapter may claim ATK conformance only through a mechanically executable suite. diff --git a/docs/POLICY_ENFORCEMENT.md b/docs/POLICY_ENFORCEMENT.md new file mode 100644 index 0000000..8cc7a0b --- /dev/null +++ b/docs/POLICY_ENFORCEMENT.md @@ -0,0 +1,240 @@ +# Deterministic policy decision and enforcement V1 + +**Status:** DSE-716 implemented foundation. These APIs are not wired into the historical +`guard` proxy and do not by themselves make MCP-Warden Agent Trust Kernel conformant. +`docs/AGENT_TRUST_KERNEL.md` remains the governing security contract. + +## Security claim + +The V1 policy decision point (PDP) is a total deterministic function over an exact canonical +request, a verified activated policy snapshot, and a verified explicit trusted-runtime snapshot. +It returns allow only for one exact signed lease grant after every user, agent, device, session, +data, operation, capability, argument, destination, purpose, policy, time, freshness, generation, +and revocation binding passes. Every missing, malformed, stale, revoked, rolled-back, unknown, or +internally errored condition denies or quarantines. + +The V1 policy enforcement point (PEP) is the only supported route from an activated adapter +registry to its registered sinks. It validates exact canonical effect bytes and the lease-bound +adapter-manifest digest, requires a matching activated signed bundle for `execute`, evaluates +through the PDP internally, obtains evidence bound to the request/decision/manifest/policy tuple, +and only then invokes the registered sink. Callers cannot submit a decision. The default evidence +gate denies every otherwise allowed operation. + +The implementation does **not** claim that arbitrary Python code cannot retain and call an +external raw function reference. A conforming adapter must use the signed manifest and one-shot +registry and expose no alternate sink path. An adapter that retains or exposes a bypass is +nonconformant. The conformance harness is executable evidence for the supported registry/PEP path, +not a proof about code outside that boundary. + +## Public modules + +| Module | Responsibility | +|---|---| +| `decision_models.py` | Closed V1 digest, capability, verdict, reason, recovery, identity, operation, lease, policy, runtime, request, and decision models | +| `policy_decision.py` | Canonical activation, exact request/lease construction, deterministic PDP, decision serialization | +| `policy_enforcement.py` | Signed adapter manifest, artifact/dependency closure, one-shot handler registry, canonical effect input, bound evidence result, structural PEP | +| `handler_identity.py` | Mechanically derived Python function-code evidence and frozen callable snapshots with pre-invocation drift checks | +| `executable_bundle.py` | Exact bundle evidence closure, signed publisher/policy/adapter-bound manifest activation, immutable activated bundle | +| `adapter_conformance.py` | Mandatory versioned foundation corpus plus instrumented operation coverage, planted-secret scans, deterministic reports | + +All public failure exceptions are stable code-only `DecisionError`, `EnforcementError`, or +`ConformanceError` values. Public decisions, enforcement results, and conformance reports contain +only bounded identifiers, digests, registered codes, and digest-only content envelopes. + +## Activation boundary + +Policy, trusted-runtime, adapter, and executable-bundle candidates are activated outside decision +evaluation. +Activation: + +1. requires exact V1 candidate/model types; +2. validates closed schemas, registries, sorting, uniqueness, and byte/count caps; +3. canonicalizes the candidate with RFC 8785; +4. calls the configured verifier exactly once with artifact kind, closed algorithm identifier, + signer identity digest, exact canonical payload bytes, and detached signature bytes; +5. rejects verifier false, non-boolean, exception, or malformed output with a code-only error; +6. returns an immutable privately marked activated snapshot only after success. + +The verifier and the issuer of the signed trusted-runtime snapshot are part of the TCB. They may +perform authenticated synchronization before activation, but evaluation itself performs no +network, clock, environment, filesystem, dynamic import, or verifier call. Candidate activation +returns a new object and mutates no current PDP/PEP, so a rejected candidate cannot displace a +fresh active snapshot. + +Adapter activation additionally recomputes the exact adapter implementation digest, the complete +sorted unique dependency closure, and every registered handler digest from the actual Python code +object before it verifies the signed manifest. V1 accepts closure-free, default-free exact Python +functions only. Direct global function dependencies are recursively included in the handler digest +and cloned into the activated snapshot. Referenced data globals must be recursively immutable +scalars, tuples, or frozensets and are also digest bound; mutable globals, modules, classes, +callable builtins, imports, global/nonlocal writes, closure-controlled delegates, and dunder +introspection are rejected. Nested code objects (lambdas, comprehensions, generators, and nested +functions) are also rejected so they cannot conceal a second global or bytecode surface. The PEP +rechecks the frozen snapshot immediately before invocation. +Manifest operations and registrations must be a bijection. Missing, extra, duplicate, +digest-mismatched, drifted, or post-activation registration fails closed. + +Executable-bundle activation recomputes the artifact, supply-chain signature evidence, version +claims, publisher claims, canonical dependency closure, and policy-binding claim digests from +exact bounded bytes. Its signed manifest also binds a verified publisher identity, explicit bundle +ID/version, policy ID/generation, and the exact activated adapter-manifest digest. The PEP accepts +an `execute` request only when its envelope evidence and lease-bound bundle-manifest digest match +that privately marked activated bundle exactly. Attacker-authored bundle metadata alone is never +load authority. + +## Canonical request and lease binding + +`DecisionRequestV1` contains: + +- user, agent, device, and session identity digests; +- the complete `ContentEnvelopeV1` public projection and a data-scope digest; +- adapter ID, exact adapter-manifest digest, operation ID, closed capability, arguments digest, + destination digest, and an exact executable-bundle manifest digest for `execute` only; +- purpose digest; +- an exact `CapabilityLeaseV1` and request self digest. + +`CapabilityLeaseV1` binds the canonical request-binding digest, policy ID/generation, validity +interval, revocation generation, and its own digest. The signed policy grants only exact lease +digests. There are no wildcards, implicit grants, model-confidence grants, human per-decision +overrides, audit-only modes, or category opt-outs. + +All time windows are half-open. Policy and lease `valid_from` / `not_before` values are inclusive; +their `valid_until` / `expires_at` values are exclusive. A trusted-runtime snapshot is stale when +`trusted_time == trusted_time_valid_until`, so an authority boundary can never gain an extra tick. + +Raw canonical effect arguments live only in `EffectInputV1`. The PEP reparses them, requires exact +RFC 8785 bytes, recomputes the domain-separated digest, and compares that digest with the bound +request before evaluation. Raw arguments never enter a public decision, result, error, or report. + +## Fixed decision precedence + +For a representable exact request, the PDP evaluates in this order: + +1. activated policy/runtime integrity and content-envelope self integrity; +2. request self digest; +3. recovery-only state; +4. trusted-time freshness; +5. policy validity; +6. policy and revocation generation floors, including equal-generation digest match; +7. mandatory critical floor; +8. lease self digest and policy/revocation binding; +9. lease not-before, expiry, and revocation; +10. complete multidimensional request-binding digest; +11. exact signed lease grant; +12. allow. + +The first applicable condition selects the reason and recovery code. Unrepresentable input uses a +fixed invalid-input digest rather than serializing attacker-controlled values. Unexpected internal +exceptions return `PDP-INTERNAL-ERROR`, deny, and `recovery-only`. + +## Decision matrix + +| Condition | Verdict | Reason | Recovery | +|---|---|---|---| +| Exact fresh signed grant and every binding matches | allow | `PDP-ALLOW-EXACT-GRANT` | `none` | +| No exact grant | deny | `PDP-DENY-DEFAULT` | `obtain-new-lease` | +| Nonexact/hostile/unrepresentable request | deny | `PDP-INPUT-MALFORMED` | `reauthenticate` | +| Request self digest changed | deny | `PDP-REQUEST-INTEGRITY` | `reauthenticate` | +| Activated trusted runtime absent/invalid | deny | `PDP-AUTHORITY-UNAVAILABLE` | `refresh-authority` | +| Recovery-only runtime | deny | `PDP-RECOVERY-ONLY` | `recovery-only` | +| Trusted-time snapshot stale | deny | `PDP-TRUSTED-TIME-STALE` | `refresh-authority` | +| Policy outside signed validity | deny | `PDP-POLICY-STALE` | `refresh-authority` | +| Policy/revocation below floor or equal-generation digest mismatch | deny | `PDP-POLICY-ROLLBACK` | `recovery-only` | +| Envelope integrity invalid | quarantine | `PDP-ENVELOPE-INVALID` | `quarantine-input` | +| `core:critical` or `core:authority-injection` | quarantine | `PDP-CRITICAL-TAINT` | `quarantine-input` | +| `core:malformed` or `core:uninspectable` | quarantine | `PDP-UNINSPECTABLE-DATA` | `quarantine-input` | +| Policy administration or recovery repair through the normal lane | deny | `PDP-PRIVILEGED-PATH-REQUIRED` | `reauthenticate` | +| `core:executable` content requested for execution | quarantine | `PDP-EXECUTABLE-CONTENT` | `quarantine-input` | +| Lease too early / expired / revoked | deny | `PDP-LEASE-NOT-YET-VALID` / `PDP-LEASE-EXPIRED` / `PDP-LEASE-REVOKED` | `obtain-new-lease` | +| Lease/request dimension mismatch | deny | `PDP-LEASE-BINDING` | `reauthenticate` | +| Lease policy/generation mismatch | deny | `PDP-POLICY-BINDING` | `obtain-new-lease` or `refresh-authority` | +| Unexpected internal failure | deny | `PDP-INTERNAL-ERROR` | `recovery-only` | + +V1 deliberately does not emit a `limit` verdict. A constraints digest without mechanically +enforced sink semantics would create false authority. A future interface version may add `limit` +only with an executable constraint contract. + +## PEP ordering and result semantics + +The PEP order is fixed: + +```text +canonical effect bytes -> request/adapter/bundle match -> PDP -> bound evidence -> registered sink +``` + +- A deny or quarantine never calls the evidence gate or sink. +- Missing/failed evidence returns `PEP-EVIDENCE-UNAVAILABLE` and never calls the sink. +- Substituted request, decision, manifest, policy, or evidence digest returns + `PEP-EVIDENCE-MISMATCH` and never calls the sink. +- The sink receives exact canonical effect bytes only after evidence success. +- A sink may return `None` or a validated `ContentEnvelopeV1`; raw output is rejected. +- A sink exception means invocation occurred and the effect outcome is indeterminate. The PEP + returns `PEP-SINK-FAILED`, includes no exception text, and never retries automatically. + +The shipped `FailClosedEvidenceGateV1` permits nothing. Test-only/instrumented gates can exercise +the ordering contract. DSE-717 must replace this seam with durable signed receipt append, +independent negative-decision fallback evidence, rollback-resistant sequence/generation state, +and the persistent recovery latch before production effects or whole-ATK conformance. + +## Adapter conformance harness + +`run_adapter_conformance()` accepts an exact bounded tuple of adapter-specific vectors and a PEP, +then invokes the PEP-owned instrumented execution path and always appends the non-optional +`dse716-foundation-v1` corpus. The report binds the exact activated registration set and count of +instrumented cases. Each case verifies code-only stage ordering, negative-case sink absence, and +scans both supported serialized output channels: the enforcement result and PEP trace. The closed +handler boundary cannot retain a mutable sink-owned channel; handlers requiring a wider host/IO +surface are unsupported until that surface has a separately instrumented TCB adapter. That fixed +corpus covers malformed request, runtime, and effect objects, hostile nested request values, and +effect substitution; callers cannot remove or replace those cases. It invokes sinks for positive +cases, so it must never be pointed at a live production adapter. A passing foundation report proves: + +- every signed manifest operation has a successful instrumented allow vector; +- all five fixed negative vectors invoke no sink; +- each result matches the expected stable enforcement code and invocation state; +- every planted secret is absent from the serialized enforcement result; +- the report itself is deterministic and digest protected. + +Manifest/registration bijection, late-registration rejection, code/delegate drift, signed bundle +activation, evidence-before-sink ordering, decision/evidence substitution, malformed-input +behavior, and no-I/O imports are covered by the repository test suite. This is deliberately a +DSE-716 foundation corpus, not the complete ATK §11 suite: DSE-717 must still add the fixed +receipt/fallback/log/restart/rollback cases. An alternate raw sink outside the registry is +nonconformant and is not made safe by a passing foundation report. + +## Resource limits + +| Surface | V1 cap | +|---|---:| +| Canonical effect arguments | 1 MiB | +| Policy canonical payload | 512 KiB | +| Trusted-runtime canonical payload | 64 KiB | +| Adapter manifest canonical payload | 256 KiB | +| Executable-bundle manifest canonical payload | 256 KiB | +| Adapter implementation or bundle component evidence | 256 KiB / 64 KiB each | +| Policy grants / revocations | 4,096 each | +| Manifest operations / dependencies | 1,024 each | +| Detached signature | 64 KiB | +| Conformance cases | 2,048, including five mandatory fixed cases | +| Planted secrets per case | 64, each at most 4 KiB | + +Exact cap and cap-plus-one cases are tested for the high-volume policy, effect, operation, +dependency, and signature surfaces. + +## Supported construction boundary + +Security guarantees apply to the public constructors, activation functions, PDP/PEP methods, and +serializers. Public boundaries exact-type-check and revalidate Pydantic instances, recompute +self-digests, and reject hostile subclasses and incomplete `model_construct` objects. Underscored +module internals, arbitrary interpreter memory modification, and reflection that extracts private +module markers are outside the supported API and are TCB compromise, not an authorization path. + +## Current product boundary + +- The historical `guard` proxy remains governed by `GUARD_PROXY.md` / `GUARD_PROXY_V3.md`; it is + not silently upgraded and is not ATK-conformant. +- DSE-716 APIs are importable client-agnostic foundations, not a new CLI command or deployed + runtime adapter. +- No built-in production verifier, durable evidence gate, recovery store, or live protocol + adapter is selected by this ticket. +- DSE-717 remains required before any whole-kernel or production effect claim. diff --git a/docs/plans/2026-07-19-pdp-pep-design.md b/docs/plans/2026-07-19-pdp-pep-design.md new file mode 100644 index 0000000..242547d --- /dev/null +++ b/docs/plans/2026-07-19-pdp-pep-design.md @@ -0,0 +1,59 @@ +# DSE-716 PDP/PEP implementation plan + +> **Non-normative execution plan.** `docs/AGENT_TRUST_KERNEL.md` is the governing security +> contract. This plan may not weaken its invariants. + +## Objective + +Implement strict deterministic policy decision and structural enforcement APIs consuming the +DSE-715 envelope, with stable fail-closed reasons and an executable adapter conformance harness. + +## Design choices + +1. Explicit bounded digest-only request context; raw canonical effect arguments are held in a + separate bounded non-serializable input whose digest the PEP recomputes before evaluation. + No raw identities, content, secrets, ambient time, environment, or network state enter a + decision or public result. +2. Signed policy, adapter, trusted-runtime, and executable-bundle candidates activate outside + evaluation through a verifier TCB seam. Failed candidates cannot replace a fresh active + snapshot; bundle activation binds exact evidence, publisher, policy, and adapter manifest. +3. Exact lease grants bind user, agent, device, session, data, adapter manifest, executable bundle + for `execute`, operation, arguments, destination, purpose, authority generations, and trusted + validity/freshness. +4. Closed mandatory critical floor and stable reason/recovery registries; policy may only add + restrictions. +5. Finite signed operation manifest and frozen, closure-free, mechanically code/dependency-derived + handler bijection; the + caller never supplies a decision, and PEP ordering is effect-digest validation, exact + adapter/bundle binding, decision, bound evidence gate, then sink. +6. Default pre-effect gate denies and returns no boolean bypass. DSE-717 supplies the bound durable + evidence result before production effects. +7. V1 emits allow, deny, or quarantine only; `limit` is deferred until constraints can be + mechanically enforced rather than represented by an advisory digest. + + +## TDD sequence + +1. Add failing model/canonicalization/golden-vector tests, then strict versioned models and digest + domains. +2. Add failing activation/default-deny/binding/time/revocation/critical-floor tests, then the pure + deterministic PDP. +3. Add failing manifest/registry/decision-swap/evidence-order/unknown-operation tests, then the + structural PEP. +4. Add failing conformance-corpus and planted-secret tests, then the adapter harness with a fixed + non-optional versioned foundation corpus. +5. Add deterministic property/fuzz cases and exact cap/cap+1 tests. +6. Integrate the API/boundary documentation and core docs without claiming DSE-717 behavior. +7. Reopen after independent security review; reproduce and close executable-load, handler-TOCTOU, + adapter-version binding, hostile-nested-input, caller-selected-corpus, and missing execution + instrumentation/output-channel findings before publication. + +## Verification + +- focused unit suites after every RED/GREEN task; +- deterministic property/fuzz suite; +- exact no-I/O/no-ambient-clock/static import guards; +- full Ruff and repository suite with CI environment flags; +- strict MkDocs, relative links, Mermaid render, compile, diff, and secret scan; +- independent security review and Conclave adversarial regression; +- required GitHub Actions on the immutable final PR head. diff --git a/src/mcp_warden/adapter_conformance.py b/src/mcp_warden/adapter_conformance.py new file mode 100644 index 0000000..2fae696 --- /dev/null +++ b/src/mcp_warden/adapter_conformance.py @@ -0,0 +1,475 @@ +"""Deterministic adapter conformance harness for instrumented PEP tests.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +import rfc8785 +from pydantic import ( + BaseModel, + ConfigDict, + StrictBool, + StrictInt, + ValidationError, + field_validator, + model_validator, +) + +from mcp_warden.decision_models import ( + DIGEST_RE, + CapabilityV1, + DecisionDigestDomain, + DecisionRequestV1, + OperationBindingV1, + digest_decision_bytes, +) +from mcp_warden.policy_decision import ActivatedRuntimeV1 +from mcp_warden.policy_enforcement import ( + EffectInputV1, + EnforcementCodeV1, + EnforcementResultV1, + PolicyEnforcementPointV1, + create_effect_input, +) + +CASE_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$") +MAX_CONFORMANCE_CASES = 2_048 +MAX_PLANTED_SECRETS = 64 +MAX_PLANTED_SECRET_BYTES = 4_096 +MAX_REPORT_BYTES = 64 * 1024 +FOUNDATION_CORPUS_VERSION = "dse716-foundation-v1" + + +class ConformanceError(Exception): + """Stable code-only conformance failure.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + def __str__(self) -> str: + return self.code + + def __repr__(self) -> str: + return self.code + + +class ConformanceFailureV1(StrEnum): + CASE_MISMATCH = "CONF-CASE-MISMATCH" + CASE_ERROR = "CONF-CASE-ERROR" + SECRET_LEAK = "CONF-SECRET-LEAK" + OPERATION_UNCOVERED = "CONF-OPERATION-UNCOVERED" + NEGATIVE_CASE_MISSING = "CONF-NEGATIVE-CASE-MISSING" + INSTRUMENTATION = "CONF-INSTRUMENTATION" + + +@dataclass(frozen=True, slots=True) +class AdapterConformanceCaseV1: + name: str + request: object + runtime: object + effect: object + expected_code: EnforcementCodeV1 + expected_invoked: bool + planted_secrets: tuple[bytes, ...] = () + + def __post_init__(self) -> None: + if ( + type(self.name) is not str + or CASE_NAME_RE.fullmatch(self.name) is None + or type(self.expected_code) is not EnforcementCodeV1 + or type(self.expected_invoked) is not bool + or type(self.planted_secrets) is not tuple + or len(self.planted_secrets) > MAX_PLANTED_SECRETS + ): + raise ConformanceError("CONF-CASE-MALFORMED") from None + if any( + type(secret) is not bytes or not secret or len(secret) > MAX_PLANTED_SECRET_BYTES + for secret in self.planted_secrets + ): + raise ConformanceError("CONF-CASE-MALFORMED") from None + + +def _digest(value: str) -> str: + if type(value) is not str or DIGEST_RE.fullmatch(value) is None: + raise ValueError("invalid digest") + return value + + +class ConformanceReportV1(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + revalidate_instances="always", + ) + + schema_version: StrictInt + corpus_version: str + manifest_digest: str + total_cases: StrictInt + caller_cases: StrictInt + fixed_cases: StrictInt + instrumented_cases: StrictInt + registration_operations: tuple[str, ...] + passed: StrictBool + covered_operations: tuple[str, ...] + failures: tuple[str, ...] + report_digest: str + + _manifest = field_validator("manifest_digest")(_digest) + _report = field_validator("report_digest")(_digest) + + @field_validator("corpus_version") + @classmethod + def _corpus(cls, value: str) -> str: + if value != FOUNDATION_CORPUS_VERSION: + raise ValueError("unknown corpus") + return value + + def __init__(self, **data: Any) -> None: + invalid = False + try: + super().__init__(**data) + except ValidationError: + invalid = True + if invalid: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + + def __setattr__(self, name: str, value: Any) -> None: + invalid = False + try: + super().__setattr__(name, value) + except ValidationError: + invalid = True + if invalid: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + + def __delattr__(self, name: str) -> None: + invalid = False + try: + super().__delattr__(name) + except (TypeError, ValidationError): + invalid = True + if invalid: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + + @classmethod + def model_validate(cls, obj: Any, **kwargs: Any) -> Any: + invalid = False + result: Any = None + try: + result = super().model_validate(obj, **kwargs) + except ValidationError: + invalid = True + if invalid: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + return result + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if value != 1: + raise ValueError("unknown schema") + return value + + @field_validator("total_cases") + @classmethod + def _total(cls, value: int) -> int: + if value < 1 or value > MAX_CONFORMANCE_CASES: + raise ValueError("invalid case count") + return value + + @field_validator("caller_cases", "fixed_cases", "instrumented_cases") + @classmethod + def _case_partition(cls, value: int) -> int: + if value < 0 or value > MAX_CONFORMANCE_CASES: + raise ValueError("invalid case partition") + return value + + @model_validator(mode="after") + def _partition_matches_total(self) -> ConformanceReportV1: + if ( + self.caller_cases + self.fixed_cases != self.total_cases + or self.instrumented_cases > self.total_cases + ): + raise ValueError("invalid case partition") + return self + + @field_validator("registration_operations") + @classmethod + def _registrations(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if value != tuple(sorted(set(value))): + raise ValueError("invalid registration operations") + return value + + @field_validator("covered_operations") + @classmethod + def _covered(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if value != tuple(sorted(set(value))): + raise ValueError("invalid operation coverage") + return value + + @field_validator("failures") + @classmethod + def _failures(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if value != tuple(sorted(set(value))) or any( + item not in {failure.value for failure in ConformanceFailureV1} for item in value + ): + raise ValueError("invalid failures") + return value + + +def _report_body(report: ConformanceReportV1) -> dict[str, object]: + return { + "schema_version": report.schema_version, + "corpus_version": report.corpus_version, + "manifest_digest": report.manifest_digest, + "total_cases": report.total_cases, + "caller_cases": report.caller_cases, + "fixed_cases": report.fixed_cases, + "instrumented_cases": report.instrumented_cases, + "registration_operations": list(report.registration_operations), + "passed": report.passed, + "covered_operations": list(report.covered_operations), + "failures": list(report.failures), + } + + +def _make_report( + *, + manifest_digest: str, + total_cases: int, + caller_cases: int, + fixed_cases: int, + instrumented_cases: int, + registration_operations: tuple[str, ...], + covered_operations: tuple[str, ...], + failures: tuple[str, ...], +) -> ConformanceReportV1: + invalid = False + report: ConformanceReportV1 | None = None + try: + provisional = ConformanceReportV1( + schema_version=1, + corpus_version=FOUNDATION_CORPUS_VERSION, + manifest_digest=manifest_digest, + total_cases=total_cases, + caller_cases=caller_cases, + fixed_cases=fixed_cases, + instrumented_cases=instrumented_cases, + registration_operations=registration_operations, + passed=not failures, + covered_operations=covered_operations, + failures=failures, + report_digest=digest_decision_bytes( + b"provisional", domain=DecisionDigestDomain.CONFORMANCE_REPORT + ), + ) + payload = rfc8785.dumps(_report_body(provisional)) + report = provisional.model_copy( + update={ + "report_digest": digest_decision_bytes( + payload, domain=DecisionDigestDomain.CONFORMANCE_REPORT + ) + } + ) + except (ConformanceError, ValidationError, TypeError, ValueError): + invalid = True + if invalid or report is None: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + return report + + +def serialize_conformance_report(report: ConformanceReportV1) -> bytes: + if type(report) is not ConformanceReportV1: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + invalid = False + payload: bytes | None = None + try: + ConformanceReportV1.model_validate(report) + expected = digest_decision_bytes( + rfc8785.dumps(_report_body(report)), + domain=DecisionDigestDomain.CONFORMANCE_REPORT, + ) + if report.report_digest != expected: + raise ConformanceError("CONF-REPORT-INTEGRITY") from None + payload = rfc8785.dumps(_report_body(report) | {"report_digest": report.report_digest}) + except ConformanceError: + raise + except Exception: + invalid = True + if invalid or payload is None: + raise ConformanceError("CONF-REPORT-MALFORMED") from None + if len(payload) > MAX_REPORT_BYTES: + raise ConformanceError("CONF-REPORT-OVER-CAP") from None + return payload + + +def run_adapter_conformance( + pep: PolicyEnforcementPointV1, *, cases: tuple[AdapterConformanceCaseV1, ...] +) -> ConformanceReportV1: + """Run explicit vectors against an instrumented adapter PEP. + + This harness invokes sinks for positive cases. Callers must supply only + instrumented/test sinks, never a live production adapter. + """ + if ( + type(pep) is not PolicyEnforcementPointV1 + or type(cases) is not tuple + or not cases + or len(cases) > MAX_CONFORMANCE_CASES - 5 + or any(type(case) is not AdapterConformanceCaseV1 for case in cases) + or len({case.name for case in cases}) != len(cases) + ): + raise ConformanceError("CONF-CASES-MALFORMED") from None + + seed = next( + ( + case + for case in cases + if type(case.request) is DecisionRequestV1 + and type(case.runtime) is ActivatedRuntimeV1 + and type(case.effect) is EffectInputV1 + ), + None, + ) + if seed is None: + raise ConformanceError("CONF-SEED-MISSING") from None + + class _HostileDigest: + def __eq__(self, _other: object) -> bool: + raise RuntimeError("PLANTED-FIXED-CORPUS-SECRET") + + hostile_operation = OperationBindingV1.model_construct( + adapter_id=seed.request.operation.adapter_id, + adapter_manifest_digest=seed.request.operation.adapter_manifest_digest, + operation_id=seed.request.operation.operation_id, + capability=CapabilityV1.READ.value, + arguments_digest=_HostileDigest(), + destination_digest=seed.request.operation.destination_digest, + bundle_manifest_digest=None, + ) + substituted_effect = create_effect_input( + b'{"mcp_warden_fixed_corpus":"effect-substitution-v1"}' + ) + if substituted_effect.arguments_digest == seed.effect.arguments_digest: + substituted_effect = create_effect_input( + b'{"mcp_warden_fixed_corpus":"effect-substitution-v1-alt"}' + ) + fixed_cases = ( + AdapterConformanceCaseV1( + name="fixed.malformed-request", + request=object(), + runtime=seed.runtime, + effect=seed.effect, + expected_code=EnforcementCodeV1.DECISION_BLOCKED, + expected_invoked=False, + ), + AdapterConformanceCaseV1( + name="fixed.malformed-runtime", + request=seed.request, + runtime=object(), + effect=seed.effect, + expected_code=EnforcementCodeV1.DECISION_BLOCKED, + expected_invoked=False, + ), + AdapterConformanceCaseV1( + name="fixed.malformed-effect", + request=seed.request, + runtime=seed.runtime, + effect=object(), + expected_code=EnforcementCodeV1.EFFECT_MALFORMED, + expected_invoked=False, + ), + AdapterConformanceCaseV1( + name="fixed.hostile-nested-request", + request=seed.request.model_copy(update={"operation": hostile_operation}), + runtime=seed.runtime, + effect=seed.effect, + expected_code=EnforcementCodeV1.DECISION_BLOCKED, + expected_invoked=False, + planted_secrets=(b"PLANTED-FIXED-CORPUS-SECRET",), + ), + AdapterConformanceCaseV1( + name="fixed.effect-substitution", + request=seed.request, + runtime=seed.runtime, + effect=substituted_effect, + expected_code=EnforcementCodeV1.EFFECT_DIGEST_MISMATCH, + expected_invoked=False, + ), + ) + + failures: set[str] = set() + covered: set[str] = set() + instrumented_cases = 0 + negative_case_seen = False + manifest_operations = {item.operation_id for item in pep.manifest.operations} + + all_cases = cases + fixed_cases + for case in all_cases: + if not case.expected_invoked: + negative_case_seen = True + result: EnforcementResultV1 | None = None + output_channels: tuple[bytes, ...] = () + try: + result, instrumentation = pep._execute_instrumented( + case.request, runtime=case.runtime, effect=case.effect + ) + output_channels = instrumentation.output_channels + instrumented_cases += 1 + except Exception: + failures.add(ConformanceFailureV1.CASE_ERROR.value) + if result is None: + continue + if result.code != case.expected_code.value or result.invoked is not case.expected_invoked: + failures.add(ConformanceFailureV1.CASE_MISMATCH.value) + if any(secret in channel for secret in case.planted_secrets for channel in output_channels): + failures.add(ConformanceFailureV1.SECRET_LEAK.value) + events = instrumentation.events + ordered_effect = False + if result.invoked is True: + try: + ordered_effect = ( + events.index("decision") < events.index("evidence") < events.index("sink") + ) + except ValueError: + ordered_effect = False + if ( + not events + or events[-1] != "result:" + result.code + or (result.invoked is False and "sink" in events) + or (result.invoked is True and not ordered_effect) + ): + failures.add(ConformanceFailureV1.INSTRUMENTATION.value) + if ( + result.code == EnforcementCodeV1.EXECUTED.value + and result.invoked is True + and type(case.request) is DecisionRequestV1 + and type(case.effect) is EffectInputV1 + and case.request.operation.operation_id in manifest_operations + ): + covered.add(case.request.operation.operation_id) + + if covered != manifest_operations: + failures.add(ConformanceFailureV1.OPERATION_UNCOVERED.value) + if not negative_case_seen: + failures.add(ConformanceFailureV1.NEGATIVE_CASE_MISSING.value) + registrations = pep.registration_operations + if set(registrations) != manifest_operations: + failures.add(ConformanceFailureV1.INSTRUMENTATION.value) + return _make_report( + manifest_digest=pep.manifest_digest, + total_cases=len(all_cases), + caller_cases=len(cases), + fixed_cases=len(fixed_cases), + instrumented_cases=instrumented_cases, + registration_operations=registrations, + covered_operations=tuple(sorted(covered)), + failures=tuple(sorted(failures)), + ) diff --git a/src/mcp_warden/decision_models.py b/src/mcp_warden/decision_models.py new file mode 100644 index 0000000..3af3712 --- /dev/null +++ b/src/mcp_warden/decision_models.py @@ -0,0 +1,496 @@ +"""Strict V1 models for deterministic policy decisions. + +The models carry digests and bounded metadata only. They never carry raw +identity, content, argument, signature, or exception text in public outputs. +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from enum import StrEnum +from typing import Any, ClassVar, Literal + +from pydantic import ( + BaseModel, + ConfigDict, + StrictInt, + ValidationError, + field_validator, + model_validator, +) + +from mcp_warden.content_models import TAINT_REGISTRY_V1, ContentEnvelopeV1 + +DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +IDENTIFIER_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$") + +MAX_POLICY_BYTES = 512 * 1024 +MAX_RUNTIME_BYTES = 64 * 1024 +MAX_DECISION_BYTES = 16 * 1024 +MAX_SIGNATURE_BYTES = 64 * 1024 +MAX_GRANTS = 4_096 +MAX_REVOCATIONS = 4_096 + + +class DecisionError(Exception): + """Stable code-only decision failure.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + def __str__(self) -> str: + return self.code + + def __repr__(self) -> str: + return self.code + + +class DecisionDigestDomain(StrEnum): + CLAIM = "mcp-warden/decision/v1/claim" + IDENTITY_BINDING = "mcp-warden/decision/v1/identity-binding" + DATA_SCOPE = "mcp-warden/decision/v1/data-scope" + PURPOSE = "mcp-warden/decision/v1/purpose" + EFFECT_ARGUMENTS = "mcp-warden/decision/v1/effect-arguments" + POLICY_ID = "mcp-warden/decision/v1/policy-id" + RULE_SET = "mcp-warden/decision/v1/rule-set" + TRUST_ROOT = "mcp-warden/decision/v1/trust-root" + LEASE_ID = "mcp-warden/decision/v1/lease-id" + REQUEST_BINDING = "mcp-warden/decision/v1/request-binding" + LEASE = "mcp-warden/decision/v1/lease" + REQUEST = "mcp-warden/decision/v1/request" + POLICY = "mcp-warden/decision/v1/policy" + RUNTIME = "mcp-warden/decision/v1/runtime" + REVOCATION = "mcp-warden/decision/v1/revocation" + DECISION = "mcp-warden/decision/v1/decision" + INVALID_INPUT = "mcp-warden/decision/v1/invalid-input" + ADAPTER_MANIFEST = "mcp-warden/decision/v1/adapter-manifest" + ADAPTER_IMPLEMENTATION = "mcp-warden/decision/v1/adapter-implementation" + ADAPTER_DEPENDENCY = "mcp-warden/decision/v1/adapter-dependency" + HANDLER_IMPLEMENTATION = "mcp-warden/decision/v1/handler-implementation" + EFFECT_EVIDENCE = "mcp-warden/decision/v1/effect-evidence" + CONFORMANCE_REPORT = "mcp-warden/decision/v1/conformance-report" + BUNDLE_MANIFEST = "mcp-warden/decision/v1/bundle-manifest" + + +def digest_decision_bytes(payload: bytes, *, domain: DecisionDigestDomain) -> str: + """Hash exact bytes under a closed decision-domain namespace.""" + if type(payload) is not bytes or type(domain) is not DecisionDigestDomain: + raise TypeError("PDP-DIGEST-TYPE") + value = hashlib.sha256(domain.value.encode("ascii") + b"\x00" + payload).hexdigest() + return "sha256:" + value + + +class ArtifactKindV1(StrEnum): + POLICY = "policy" + RUNTIME = "runtime" + ADAPTER = "adapter" + BUNDLE = "bundle" + + +class VerificationAlgorithmV1(StrEnum): + EXTERNAL_V1 = "external-v1" + + +class AuthorityHealthV1(StrEnum): + HEALTHY = "healthy" + RECOVERY_ONLY = "recovery-only" + + +class CapabilityV1(StrEnum): + READ = "read" + DISCLOSE = "disclose" + TRANSFORM = "transform" + COMMUNICATE = "communicate" + EXECUTE = "execute" + ADMINISTER_POLICY = "administer-policy" + REPAIR_RECOVERY = "repair-recovery" + + +class DecisionVerdictV1(StrEnum): + ALLOW = "allow" + DENY = "deny" + QUARANTINE = "quarantine" + + +class DecisionReasonV1(StrEnum): + ALLOW_EXACT_GRANT = "PDP-ALLOW-EXACT-GRANT" + DENY_DEFAULT = "PDP-DENY-DEFAULT" + INPUT_MALFORMED = "PDP-INPUT-MALFORMED" + REQUEST_INTEGRITY = "PDP-REQUEST-INTEGRITY" + AUTHORITY_UNAVAILABLE = "PDP-AUTHORITY-UNAVAILABLE" + RECOVERY_ONLY = "PDP-RECOVERY-ONLY" + TRUSTED_TIME_STALE = "PDP-TRUSTED-TIME-STALE" + POLICY_STALE = "PDP-POLICY-STALE" + POLICY_ROLLBACK = "PDP-POLICY-ROLLBACK" + ENVELOPE_INVALID = "PDP-ENVELOPE-INVALID" + CRITICAL_TAINT = "PDP-CRITICAL-TAINT" + UNINSPECTABLE_DATA = "PDP-UNINSPECTABLE-DATA" + PRIVILEGED_PATH_REQUIRED = "PDP-PRIVILEGED-PATH-REQUIRED" + EXECUTABLE_CONTENT = "PDP-EXECUTABLE-CONTENT" + LEASE_NOT_YET_VALID = "PDP-LEASE-NOT-YET-VALID" + LEASE_EXPIRED = "PDP-LEASE-EXPIRED" + LEASE_REVOKED = "PDP-LEASE-REVOKED" + LEASE_BINDING = "PDP-LEASE-BINDING" + POLICY_BINDING = "PDP-POLICY-BINDING" + INTERNAL_ERROR = "PDP-INTERNAL-ERROR" + + +class DecisionRecoveryV1(StrEnum): + NONE = "none" + REFRESH_AUTHORITY = "refresh-authority" + OBTAIN_NEW_LEASE = "obtain-new-lease" + REAUTHENTICATE = "reauthenticate" + QUARANTINE_INPUT = "quarantine-input" + DISABLE_ADAPTER = "disable-adapter" + RECOVERY_ONLY = "recovery-only" + + +def _digest(value: str) -> str: + if type(value) is not str or DIGEST_RE.fullmatch(value) is None: + raise ValueError("invalid digest") + return value + + +def _identifier(value: str) -> str: + if type(value) is not str or IDENTIFIER_RE.fullmatch(value) is None: + raise ValueError("invalid identifier") + return value + + +class _DecisionModel(BaseModel): + _validation_code: ClassVar[str] = "PDP-INPUT-MALFORMED" + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + revalidate_instances="always", + ) + + def __init__(self, **data: Any) -> None: + invalid = False + try: + super().__init__(**data) + except ValidationError: + invalid = True + if invalid: + raise DecisionError(self._validation_code) from None + + def __setattr__(self, name: str, value: Any) -> None: + invalid = False + try: + super().__setattr__(name, value) + except ValidationError: + invalid = True + if invalid: + raise DecisionError(self._validation_code) from None + + def __delattr__(self, name: str) -> None: + invalid = False + try: + super().__delattr__(name) + except ValidationError: + invalid = True + if invalid: + raise DecisionError(self._validation_code) from None + + @classmethod + def model_validate(cls, obj: Any, **kwargs: Any) -> Any: + invalid = False + result: Any = None + try: + result = super().model_validate(obj, **kwargs) + except ValidationError: + invalid = True + if invalid: + raise DecisionError(cls._validation_code) from None + return result + + +class IdentityBindingV1(_DecisionModel): + user_digest: str + agent_digest: str + device_digest: str + session_digest: str + + _user = field_validator("user_digest")(_digest) + _agent = field_validator("agent_digest")(_digest) + _device = field_validator("device_digest")(_digest) + _session = field_validator("session_digest")(_digest) + + +class OperationBindingV1(_DecisionModel): + adapter_id: str + adapter_manifest_digest: str + operation_id: str + capability: str + arguments_digest: str + destination_digest: str + bundle_manifest_digest: str | None + + _adapter = field_validator("adapter_id")(_identifier) + _adapter_manifest = field_validator("adapter_manifest_digest")(_digest) + _operation = field_validator("operation_id")(_identifier) + _arguments = field_validator("arguments_digest")(_digest) + _destination = field_validator("destination_digest")(_digest) + + @field_validator("bundle_manifest_digest") + @classmethod + def _bundle_manifest(cls, value: str | None) -> str | None: + return None if value is None else _digest(value) + + @field_validator("capability") + @classmethod + def _capability(cls, value: str) -> str: + if value not in {item.value for item in CapabilityV1}: + raise ValueError("invalid capability") + return value + + @model_validator(mode="after") + def _bundle_required_for_execute(self) -> OperationBindingV1: + if (self.capability == CapabilityV1.EXECUTE.value) != ( + self.bundle_manifest_digest is not None + ): + raise ValueError("invalid bundle binding") + return self + + +class CapabilityLeaseV1(_DecisionModel): + _validation_code: ClassVar[str] = "PDP-LEASE-MALFORMED" + schema_version: Literal[1] + lease_id: str + request_binding_digest: str + policy_id: str + policy_generation: StrictInt + not_before: StrictInt + expires_at: StrictInt + revocation_generation: StrictInt + lease_digest: str + + _lease_id = field_validator("lease_id")(_digest) + _binding = field_validator("request_binding_digest")(_digest) + _policy = field_validator("policy_id")(_digest) + _self_digest = field_validator("lease_digest")(_digest) + + @model_validator(mode="after") + def _valid_lease(self) -> CapabilityLeaseV1: + if ( + self.policy_generation < 0 + or self.revocation_generation < 0 + or self.not_before < 0 + or self.expires_at <= self.not_before + ): + raise ValueError("invalid lease bounds") + return self + + +class PolicyGrantV1(_DecisionModel): + _validation_code: ClassVar[str] = "PDP-POLICY-MALFORMED" + lease_digest: str + + _lease = field_validator("lease_digest")(_digest) + + +class PolicyBundleV1(_DecisionModel): + _validation_code: ClassVar[str] = "PDP-POLICY-MALFORMED" + schema_version: Literal[1] + policy_id: str + policy_generation: StrictInt + valid_from: StrictInt + valid_until: StrictInt + rule_set_digest: str + trust_root_digest: str + critical_floor_version: Literal["atk-critical-v1"] + revocation_generation: StrictInt + grants: tuple[PolicyGrantV1, ...] + revoked_lease_digests: tuple[str, ...] + + _policy_id = field_validator("policy_id")(_digest) + _rules = field_validator("rule_set_digest")(_digest) + _trust = field_validator("trust_root_digest")(_digest) + + @field_validator("grants") + @classmethod + def _grants(cls, value: tuple[PolicyGrantV1, ...]) -> tuple[PolicyGrantV1, ...]: + if len(value) > MAX_GRANTS or any(type(item) is not PolicyGrantV1 for item in value): + raise ValueError("invalid grants") + if value != tuple(sorted(value, key=lambda item: item.lease_digest)): + raise ValueError("unsorted grants") + if len({item.lease_digest for item in value}) != len(value): + raise ValueError("duplicate grants") + return value + + @field_validator("revoked_lease_digests") + @classmethod + def _revocations(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) > MAX_REVOCATIONS or value != tuple(sorted(set(value))): + raise ValueError("invalid revocations") + for item in value: + _digest(item) + return value + + @model_validator(mode="after") + def _valid_policy(self) -> PolicyBundleV1: + if ( + self.policy_generation < 0 + or self.revocation_generation < 0 + or self.valid_from < 0 + or self.valid_until <= self.valid_from + ): + raise ValueError("invalid policy bounds") + return self + + +class RuntimeSnapshotV1(_DecisionModel): + _validation_code: ClassVar[str] = "PDP-RUNTIME-MALFORMED" + schema_version: Literal[1] + health: str + trusted_time: StrictInt + trusted_time_valid_until: StrictInt + policy_generation_floor: StrictInt + policy_digest_at_floor: str + revocation_generation_floor: StrictInt + revocation_digest_at_floor: str + + _policy = field_validator("policy_digest_at_floor")(_digest) + _revocation = field_validator("revocation_digest_at_floor")(_digest) + + @field_validator("health") + @classmethod + def _health(cls, value: str) -> str: + if value not in {item.value for item in AuthorityHealthV1}: + raise ValueError("invalid health") + return value + + @model_validator(mode="after") + def _valid_runtime(self) -> RuntimeSnapshotV1: + if ( + self.trusted_time < 0 + or self.trusted_time_valid_until < 0 + or self.policy_generation_floor < 0 + or self.revocation_generation_floor < 0 + ): + raise ValueError("invalid runtime bounds") + return self + + +class DecisionRequestV1(_DecisionModel): + schema_version: Literal[1] + identity: IdentityBindingV1 + envelope: ContentEnvelopeV1 + data_scope_digest: str + operation: OperationBindingV1 + purpose_digest: str + lease: CapabilityLeaseV1 + request_digest: str + + _scope = field_validator("data_scope_digest")(_digest) + _purpose = field_validator("purpose_digest")(_digest) + _request = field_validator("request_digest")(_digest) + + @model_validator(mode="after") + def _exact_nested_types(self) -> DecisionRequestV1: + if ( + type(self.identity) is not IdentityBindingV1 + or type(self.envelope) is not ContentEnvelopeV1 + or type(self.operation) is not OperationBindingV1 + or type(self.lease) is not CapabilityLeaseV1 + ): + raise DecisionError("PDP-INPUT-MALFORMED") from None + return self + + +class DecisionV1(_DecisionModel): + _validation_code: ClassVar[str] = "PDP-DECISION-MALFORMED" + schema_version: Literal[1] + request_digest: str + policy_digest: str + runtime_digest: str + policy_generation: StrictInt + revocation_generation: StrictInt + verdict: str + reason: str + recovery: str + decision_digest: str + + _request = field_validator("request_digest")(_digest) + _policy = field_validator("policy_digest")(_digest) + _runtime = field_validator("runtime_digest")(_digest) + _decision = field_validator("decision_digest")(_digest) + + @field_validator("verdict") + @classmethod + def _verdict(cls, value: str) -> str: + if value not in {item.value for item in DecisionVerdictV1}: + raise ValueError("invalid verdict") + return value + + @field_validator("reason") + @classmethod + def _reason(cls, value: str) -> str: + if value not in {item.value for item in DecisionReasonV1}: + raise ValueError("invalid reason") + return value + + @field_validator("recovery") + @classmethod + def _recovery(cls, value: str) -> str: + if value not in {item.value for item in DecisionRecoveryV1}: + raise ValueError("invalid recovery") + return value + + +@dataclass(frozen=True, slots=True) +class SignedPolicyCandidateV1: + policy: PolicyBundleV1 + algorithm: VerificationAlgorithmV1 + signer_identity: str + signature: bytes + + def __post_init__(self) -> None: + if ( + type(self.policy) is not PolicyBundleV1 + or type(self.algorithm) is not VerificationAlgorithmV1 + or type(self.signature) is not bytes + or not self.signature + or len(self.signature) > MAX_SIGNATURE_BYTES + ): + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + invalid_signer = False + try: + _digest(self.signer_identity) + except ValueError: + invalid_signer = True + if invalid_signer: + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + + +@dataclass(frozen=True, slots=True) +class SignedRuntimeCandidateV1: + runtime: RuntimeSnapshotV1 + algorithm: VerificationAlgorithmV1 + signer_identity: str + signature: bytes + + def __post_init__(self) -> None: + if ( + type(self.runtime) is not RuntimeSnapshotV1 + or type(self.algorithm) is not VerificationAlgorithmV1 + or type(self.signature) is not bytes + or not self.signature + or len(self.signature) > MAX_SIGNATURE_BYTES + ): + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + invalid_signer = False + try: + _digest(self.signer_identity) + except ValueError: + invalid_signer = True + if invalid_signer: + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + + +TAINTS_V1 = frozenset(TAINT_REGISTRY_V1) diff --git a/src/mcp_warden/executable_bundle.py b/src/mcp_warden/executable_bundle.py new file mode 100644 index 0000000..8b22b9a --- /dev/null +++ b/src/mcp_warden/executable_bundle.py @@ -0,0 +1,365 @@ +"""Signed executable-bundle activation gate for DSE-716.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +import rfc8785 +from pydantic import BaseModel, ConfigDict, StrictInt, ValidationError, field_validator + +from mcp_warden.content_models import ( + MAX_BUNDLE_COMPONENT_BYTES, + MAX_DEPENDENCIES, + BundleEvidenceInput, + BundleEvidenceV1, + DigestDomain, +) +from mcp_warden.decision_models import ( + DIGEST_RE, + IDENTIFIER_RE, + MAX_SIGNATURE_BYTES, + ArtifactKindV1, + DecisionDigestDomain, + VerificationAlgorithmV1, + digest_decision_bytes, +) +from mcp_warden.hashing import hash_bytes +from mcp_warden.policy_decision import ActivatedPolicyV1, ArtifactVerifierV1 + +VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._+-]{0,62}[A-Za-z0-9])?$") +MAX_BUNDLE_MANIFEST_BYTES = 256 * 1024 + + +class BundleActivationError(Exception): + """Stable code-only bundle activation failure.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + def __str__(self) -> str: + return self.code + + +def _digest(value: str) -> str: + if type(value) is not str or DIGEST_RE.fullmatch(value) is None: + raise ValueError + return value + + +class _BundleModel(BaseModel): + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + revalidate_instances="always", + ) + + def __init__(self, **data: Any) -> None: + try: + super().__init__(**data) + except ValidationError: + raise BundleActivationError("PEP-BUNDLE-MANIFEST-MALFORMED") from None + + def __setattr__(self, name: str, value: Any) -> None: + try: + super().__setattr__(name, value) + except ValidationError: + raise BundleActivationError("PEP-BUNDLE-MANIFEST-MALFORMED") from None + + def __delattr__(self, name: str) -> None: + try: + super().__delattr__(name) + except (TypeError, ValidationError): + raise BundleActivationError("PEP-BUNDLE-MANIFEST-MALFORMED") from None + + @classmethod + def model_validate(cls, obj: Any, **kwargs: Any) -> Any: + try: + return super().model_validate(obj, **kwargs) + except ValidationError: + raise BundleActivationError("PEP-BUNDLE-MANIFEST-MALFORMED") from None + + +class ExecutableBundleManifestV1(_BundleModel): + """Signed authority for one exact executable bundle and policy/adapter pair.""" + + schema_version: StrictInt + bundle_id: str + bundle_version: str + publisher_identity: str + artifact_digest: str + signature_evidence_digest: str | None + version_claims_digest: str + publisher_claims_digest: str + dependency_digests: tuple[str, ...] + policy_binding_claims_digest: str + policy_id: str + policy_generation: StrictInt + adapter_manifest_digest: str + + _publisher = field_validator("publisher_identity")(_digest) + _artifact = field_validator("artifact_digest")(_digest) + _version_claims = field_validator("version_claims_digest")(_digest) + _publisher_claims = field_validator("publisher_claims_digest")(_digest) + _policy_claims = field_validator("policy_binding_claims_digest")(_digest) + _policy = field_validator("policy_id")(_digest) + _adapter = field_validator("adapter_manifest_digest")(_digest) + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if value != 1: + raise ValueError + return value + + @field_validator("bundle_id") + @classmethod + def _bundle_id(cls, value: str) -> str: + if type(value) is not str or IDENTIFIER_RE.fullmatch(value) is None: + raise ValueError + return value + + @field_validator("bundle_version") + @classmethod + def _version(cls, value: str) -> str: + if type(value) is not str or VERSION_RE.fullmatch(value) is None: + raise ValueError + return value + + @field_validator("signature_evidence_digest") + @classmethod + def _signature(cls, value: str | None) -> str | None: + return None if value is None else _digest(value) + + @field_validator("dependency_digests") + @classmethod + def _dependencies(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) > MAX_DEPENDENCIES or value != tuple(sorted(set(value))): + raise ValueError + for item in value: + _digest(item) + return value + + @field_validator("policy_generation") + @classmethod + def _generation(cls, value: int) -> int: + if value < 0: + raise ValueError + return value + + +@dataclass(frozen=True, slots=True) +class SignedExecutableBundleCandidateV1: + manifest: ExecutableBundleManifestV1 + evidence: BundleEvidenceInput + algorithm: VerificationAlgorithmV1 + signer_identity: str + signature: bytes + + def __post_init__(self) -> None: + if ( + type(self.manifest) is not ExecutableBundleManifestV1 + or type(self.evidence) is not BundleEvidenceInput + or type(self.algorithm) is not VerificationAlgorithmV1 + or type(self.signature) is not bytes + or not self.signature + or len(self.signature) > MAX_SIGNATURE_BYTES + ): + raise BundleActivationError("PEP-BUNDLE-CANDIDATE-MALFORMED") from None + try: + _digest(self.signer_identity) + except ValueError: + raise BundleActivationError("PEP-BUNDLE-CANDIDATE-MALFORMED") from None + + +def _manifest_dict(manifest: ExecutableBundleManifestV1) -> dict[str, object]: + return { + "schema_version": manifest.schema_version, + "bundle_id": manifest.bundle_id, + "bundle_version": manifest.bundle_version, + "publisher_identity": manifest.publisher_identity, + "artifact_digest": manifest.artifact_digest, + "signature_evidence_digest": manifest.signature_evidence_digest, + "version_claims_digest": manifest.version_claims_digest, + "publisher_claims_digest": manifest.publisher_claims_digest, + "dependency_digests": list(manifest.dependency_digests), + "policy_binding_claims_digest": manifest.policy_binding_claims_digest, + "policy_id": manifest.policy_id, + "policy_generation": manifest.policy_generation, + "adapter_manifest_digest": manifest.adapter_manifest_digest, + } + + +def canonical_bundle_manifest_bytes(manifest: ExecutableBundleManifestV1) -> bytes: + try: + if type(manifest) is not ExecutableBundleManifestV1: + raise ValueError + ExecutableBundleManifestV1.model_validate(manifest) + payload = rfc8785.dumps(_manifest_dict(manifest)) + except Exception: + raise BundleActivationError("PEP-BUNDLE-MANIFEST-MALFORMED") from None + if len(payload) > MAX_BUNDLE_MANIFEST_BYTES: + raise BundleActivationError("PEP-BUNDLE-MANIFEST-OVER-CAP") from None + return payload + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + +def _reject_constant(_: str) -> None: + raise ValueError + + +def _metadata_digest(value: bytes, domain: DigestDomain) -> str: + if type(value) is not bytes or len(value) > MAX_BUNDLE_COMPONENT_BYTES: + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + try: + parsed = json.loads( + value.decode("utf-8", errors="strict"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + if rfc8785.dumps(parsed) != value: + raise ValueError + except Exception: + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + return hash_bytes(value, domain=domain) + + +def bundle_evidence_from_input(value: BundleEvidenceInput) -> BundleEvidenceV1: + if type(value) is not BundleEvidenceInput: + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + dependencies = tuple( + sorted( + {_metadata_digest(item, DigestDomain.BUNDLE_DEPENDENCY) for item in value.dependencies} + ) + ) + signature_digest = None + if value.signature_evidence is not None: + if len(value.signature_evidence) > MAX_BUNDLE_COMPONENT_BYTES: + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + signature_digest = hash_bytes( + value.signature_evidence, domain=DigestDomain.BUNDLE_SIGNATURE + ) + try: + return BundleEvidenceV1( + artifact_digest=_metadata_digest(value.artifact, DigestDomain.BUNDLE_ARTIFACT), + signature_evidence_digest=signature_digest, + version_claims_digest=_metadata_digest( + value.version_claims, DigestDomain.BUNDLE_VERSION + ), + publisher_claims_digest=_metadata_digest( + value.publisher_claims, DigestDomain.BUNDLE_PUBLISHER + ), + dependency_digests=dependencies, + policy_binding_claims_digest=_metadata_digest( + value.policy_binding_claims, DigestDomain.BUNDLE_POLICY_BINDING + ), + ) + except (ValidationError, TypeError, ValueError): + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + + +_BUNDLE_SEAL = object() + + +class ActivatedExecutableBundleV1: + __slots__ = ("manifest", "manifest_digest", "evidence", "policy_digest", "_seal") + + def __init__( + self, + *, + manifest: ExecutableBundleManifestV1, + manifest_digest: str, + evidence: BundleEvidenceV1, + policy_digest: str, + _seal: object, + ) -> None: + if _seal is not _BUNDLE_SEAL: + raise BundleActivationError("PEP-BUNDLE-UNAVAILABLE") from None + object.__setattr__(self, "manifest", manifest) + object.__setattr__(self, "manifest_digest", manifest_digest) + object.__setattr__(self, "evidence", evidence) + object.__setattr__(self, "policy_digest", policy_digest) + object.__setattr__(self, "_seal", _seal) + + def __setattr__(self, name: str, value: object) -> None: + raise BundleActivationError("PEP-BUNDLE-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise BundleActivationError("PEP-BUNDLE-IMMUTABLE") from None + + +def is_activated_bundle(value: object) -> bool: + try: + return ( + type(value) is ActivatedExecutableBundleV1 + and object.__getattribute__(value, "_seal") is _BUNDLE_SEAL + ) + except Exception: + return False + + +def activate_executable_bundle( + candidate: SignedExecutableBundleCandidateV1, + *, + verifier: ArtifactVerifierV1, + policy: ActivatedPolicyV1, + adapter_manifest_digest: str, +) -> ActivatedExecutableBundleV1: + if ( + type(candidate) is not SignedExecutableBundleCandidateV1 + or type(policy) is not ActivatedPolicyV1 + ): + raise BundleActivationError("PEP-BUNDLE-CANDIDATE-MALFORMED") from None + manifest = candidate.manifest + evidence = bundle_evidence_from_input(candidate.evidence) + evidence_fields = { + "artifact_digest": evidence.artifact_digest, + "signature_evidence_digest": evidence.signature_evidence_digest, + "version_claims_digest": evidence.version_claims_digest, + "publisher_claims_digest": evidence.publisher_claims_digest, + "dependency_digests": evidence.dependency_digests, + "policy_binding_claims_digest": evidence.policy_binding_claims_digest, + } + if any(getattr(manifest, name) != value for name, value in evidence_fields.items()): + raise BundleActivationError("PEP-BUNDLE-INTEGRITY") from None + if manifest.publisher_identity != candidate.signer_identity: + raise BundleActivationError("PEP-BUNDLE-PUBLISHER") from None + if ( + manifest.policy_id != policy.policy.policy_id + or manifest.policy_generation != policy.policy.policy_generation + or manifest.adapter_manifest_digest != adapter_manifest_digest + ): + raise BundleActivationError("PEP-BUNDLE-POLICY-BINDING") from None + payload = canonical_bundle_manifest_bytes(manifest) + try: + verified = verifier.verify( + artifact_kind=ArtifactKindV1.BUNDLE, + algorithm=candidate.algorithm, + signer_identity=candidate.signer_identity, + payload=payload, + signature=candidate.signature, + ) + except Exception: + verified = False + if verified is not True: + raise BundleActivationError("PEP-BUNDLE-VERIFICATION") from None + return ActivatedExecutableBundleV1( + manifest=manifest, + manifest_digest=digest_decision_bytes(payload, domain=DecisionDigestDomain.BUNDLE_MANIFEST), + evidence=evidence, + policy_digest=policy.policy_digest, + _seal=_BUNDLE_SEAL, + ) diff --git a/src/mcp_warden/handler_identity.py b/src/mcp_warden/handler_identity.py new file mode 100644 index 0000000..39dd755 --- /dev/null +++ b/src/mcp_warden/handler_identity.py @@ -0,0 +1,155 @@ +"""Mechanically bind and freeze supported Python adapter handlers.""" + +from __future__ import annotations + +import builtins +import dis +import marshal +from types import CodeType, FunctionType + +from mcp_warden.decision_models import DecisionDigestDomain, digest_decision_bytes + + +class HandlerIdentityError(Exception): + """Stable code-only handler identity failure.""" + + def __init__(self, code: str = "PEP-HANDLER-MALFORMED") -> None: + self.code = code + super().__init__(code) + + def __str__(self) -> str: + return self.code + + +MAX_HANDLER_IDENTITY_BYTES = 256 * 1024 + + +def _encode_component(value: bytes) -> bytes: + return len(value).to_bytes(8, "big") + value + + +def _immutable_global_bytes(value: object) -> bytes | None: + if value is None: + return b"none" + if type(value) is bool: + return b"bool:" + (b"1" if value else b"0") + if type(value) is int: + return b"int:" + str(value).encode("ascii") + if type(value) is float: + return b"float:" + value.hex().encode("ascii") + if type(value) is str: + return b"str:" + value.encode("utf-8") + if type(value) is bytes: + return b"bytes:" + value + if type(value) is tuple: + parts: list[bytes] = [] + for item in value: + encoded = _immutable_global_bytes(item) + if encoded is None: + return None + parts.append(_encode_component(encoded)) + return b"tuple:" + b"".join(parts) + if type(value) is frozenset: + parts = [] + for item in value: + encoded = _immutable_global_bytes(item) + if encoded is None: + return None + parts.append(encoded) + return b"frozenset:" + b"".join(_encode_component(item) for item in sorted(parts)) + return None + + +def _canonical_handler_bytes(handler: object, seen: frozenset[int]) -> bytes: + """Return interpreter-native code evidence for an exact Python function. + + V1 intentionally accepts only closure-free, default-free ``FunctionType`` + handlers. Direct global function dependencies are included recursively in + the signed digest and cloned into the activated snapshot. Referenced data + globals must be recursively immutable and are digest bound. + """ + if ( + type(handler) is not FunctionType + or handler.__closure__ is not None + or handler.__defaults__ is not None + or handler.__kwdefaults__ is not None + ): + raise HandlerIdentityError() from None + if id(handler) in seen: + raise HandlerIdentityError() from None + next_seen = seen | {id(handler)} + try: + if any(type(item) is CodeType for item in handler.__code__.co_consts): + raise HandlerIdentityError() from None + forbidden_opcodes = { + "DELETE_DEREF", + "DELETE_GLOBAL", + "IMPORT_FROM", + "IMPORT_NAME", + "LOAD_BUILD_CLASS", + "STORE_DEREF", + "STORE_GLOBAL", + } + if any(item.opname in forbidden_opcodes for item in dis.get_instructions(handler)): + raise HandlerIdentityError() from None + code = marshal.dumps(handler.__code__) + dependencies: list[bytes] = [] + for name in sorted(set(handler.__code__.co_names)): + if name.startswith("__") and name.endswith("__"): + raise HandlerIdentityError() from None + if name in handler.__globals__: + dependency = handler.__globals__[name] + elif callable(vars(builtins).get(name)): + raise HandlerIdentityError() from None + else: + continue + if type(dependency) is FunctionType: + dependency_payload = _canonical_handler_bytes(dependency, next_seen) + dependencies.append( + _encode_component(name.encode("utf-8")) + _encode_component(dependency_payload) + ) + continue + immutable_payload = _immutable_global_bytes(dependency) + if immutable_payload is None: + raise HandlerIdentityError() from None + dependencies.append( + _encode_component(name.encode("utf-8")) + _encode_component(immutable_payload) + ) + payload = b"mcp-warden/handler/v1\x00" + _encode_component(code) + b"".join(dependencies) + except Exception: + raise HandlerIdentityError() from None + if not payload or len(payload) > MAX_HANDLER_IDENTITY_BYTES: + raise HandlerIdentityError() from None + return payload + + +def canonical_handler_bytes(handler: object) -> bytes: + return _canonical_handler_bytes(handler, frozenset()) + + +def digest_handler(handler: object) -> str: + return digest_decision_bytes( + canonical_handler_bytes(handler), + domain=DecisionDigestDomain.HANDLER_IMPLEMENTATION, + ) + + +def freeze_handler(handler: object) -> FunctionType: + """Clone a function so later mutation of the caller's object cannot drift it.""" + canonical_handler_bytes(handler) + try: + frozen_globals = dict(handler.__globals__) + for name in handler.__code__.co_names: + dependency = frozen_globals.get(name) + if type(dependency) is FunctionType: + frozen_globals[name] = freeze_handler(dependency) + frozen = FunctionType( + handler.__code__, + frozen_globals, + handler.__name__, + None, + None, + ) + except Exception: + raise HandlerIdentityError() from None + return frozen diff --git a/src/mcp_warden/policy_decision.py b/src/mcp_warden/policy_decision.py new file mode 100644 index 0000000..e4f36ba --- /dev/null +++ b/src/mcp_warden/policy_decision.py @@ -0,0 +1,815 @@ +"""Deterministic fail-closed V1 policy decision point.""" + +from __future__ import annotations + +from typing import Protocol + +import rfc8785 + +from mcp_warden.content_envelope import to_public_bytes, to_public_dict +from mcp_warden.content_models import ContentEnvelopeV1, TaintV1 +from mcp_warden.decision_models import ( + DIGEST_RE, + MAX_DECISION_BYTES, + MAX_POLICY_BYTES, + MAX_RUNTIME_BYTES, + ArtifactKindV1, + AuthorityHealthV1, + CapabilityLeaseV1, + CapabilityV1, + DecisionDigestDomain, + DecisionError, + DecisionReasonV1, + DecisionRecoveryV1, + DecisionRequestV1, + DecisionV1, + DecisionVerdictV1, + IdentityBindingV1, + OperationBindingV1, + PolicyBundleV1, + RuntimeSnapshotV1, + SignedPolicyCandidateV1, + SignedRuntimeCandidateV1, + VerificationAlgorithmV1, + digest_decision_bytes, +) + + +class ArtifactVerifierV1(Protocol): + def verify( + self, + *, + artifact_kind: ArtifactKindV1, + algorithm: VerificationAlgorithmV1, + signer_identity: str, + payload: bytes, + signature: bytes, + ) -> bool: ... + + +_ACTIVATION_SEAL = object() +_INVALID_REQUEST_DIGEST = digest_decision_bytes( + b"invalid", domain=DecisionDigestDomain.INVALID_INPUT +) +_INVALID_RUNTIME_DIGEST = digest_decision_bytes( + b"invalid-runtime", domain=DecisionDigestDomain.INVALID_INPUT +) + + +def _identity_dict(identity: IdentityBindingV1) -> dict[str, str]: + return { + "user_digest": identity.user_digest, + "agent_digest": identity.agent_digest, + "device_digest": identity.device_digest, + "session_digest": identity.session_digest, + } + + +def _operation_dict(operation: OperationBindingV1) -> dict[str, str]: + return { + "adapter_id": operation.adapter_id, + "adapter_manifest_digest": operation.adapter_manifest_digest, + "operation_id": operation.operation_id, + "capability": operation.capability, + "arguments_digest": operation.arguments_digest, + "destination_digest": operation.destination_digest, + "bundle_manifest_digest": operation.bundle_manifest_digest, + } + + +def _lease_body(lease: CapabilityLeaseV1) -> dict[str, object]: + return { + "schema_version": lease.schema_version, + "lease_id": lease.lease_id, + "request_binding_digest": lease.request_binding_digest, + "policy_id": lease.policy_id, + "policy_generation": lease.policy_generation, + "not_before": lease.not_before, + "expires_at": lease.expires_at, + "revocation_generation": lease.revocation_generation, + } + + +def _lease_dict(lease: CapabilityLeaseV1) -> dict[str, object]: + body = _lease_body(lease) + body["lease_digest"] = lease.lease_digest + return body + + +def _policy_dict(policy: PolicyBundleV1) -> dict[str, object]: + return { + "schema_version": policy.schema_version, + "policy_id": policy.policy_id, + "policy_generation": policy.policy_generation, + "valid_from": policy.valid_from, + "valid_until": policy.valid_until, + "rule_set_digest": policy.rule_set_digest, + "trust_root_digest": policy.trust_root_digest, + "critical_floor_version": policy.critical_floor_version, + "revocation_generation": policy.revocation_generation, + "grants": [{"lease_digest": grant.lease_digest} for grant in policy.grants], + "revoked_lease_digests": list(policy.revoked_lease_digests), + } + + +def _runtime_dict(runtime: RuntimeSnapshotV1) -> dict[str, object]: + return { + "schema_version": runtime.schema_version, + "health": runtime.health, + "trusted_time": runtime.trusted_time, + "trusted_time_valid_until": runtime.trusted_time_valid_until, + "policy_generation_floor": runtime.policy_generation_floor, + "policy_digest_at_floor": runtime.policy_digest_at_floor, + "revocation_generation_floor": runtime.revocation_generation_floor, + "revocation_digest_at_floor": runtime.revocation_digest_at_floor, + } + + +def _revalidate(value: object, expected: type, code: str) -> None: + if type(value) is not expected: + raise DecisionError(code) from None + invalid = False + try: + expected.model_validate(value) + except Exception: + invalid = True + if invalid: + raise DecisionError(code) from None + + +def canonical_policy_bytes(policy: PolicyBundleV1) -> bytes: + _revalidate(policy, PolicyBundleV1, "PDP-POLICY-MALFORMED") + invalid = False + encoded: bytes | None = None + try: + encoded = rfc8785.dumps(_policy_dict(policy)) + except Exception: + invalid = True + if invalid or encoded is None: + raise DecisionError("PDP-POLICY-MALFORMED") from None + if len(encoded) > MAX_POLICY_BYTES: + raise DecisionError("PDP-POLICY-OVER-CAP") from None + return encoded + + +def canonical_runtime_bytes(runtime: RuntimeSnapshotV1) -> bytes: + _revalidate(runtime, RuntimeSnapshotV1, "PDP-RUNTIME-MALFORMED") + invalid = False + encoded: bytes | None = None + try: + encoded = rfc8785.dumps(_runtime_dict(runtime)) + except Exception: + invalid = True + if invalid or encoded is None: + raise DecisionError("PDP-RUNTIME-MALFORMED") from None + if len(encoded) > MAX_RUNTIME_BYTES: + raise DecisionError("PDP-RUNTIME-OVER-CAP") from None + return encoded + + +class ActivatedPolicyV1: + __slots__ = ("policy", "policy_digest", "revocation_digest", "_seal", "_locked") + + def __init__( + self, + policy: PolicyBundleV1, + policy_digest: str, + revocation_digest: str, + *, + _seal: object, + ) -> None: + if _seal is not _ACTIVATION_SEAL: + raise DecisionError("PDP-AUTHORITY-UNAVAILABLE") from None + object.__setattr__(self, "policy", policy) + object.__setattr__(self, "policy_digest", policy_digest) + object.__setattr__(self, "revocation_digest", revocation_digest) + object.__setattr__(self, "_seal", _seal) + object.__setattr__(self, "_locked", True) + + def __setattr__(self, name: str, value: object) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + +class ActivatedRuntimeV1: + __slots__ = ("runtime", "runtime_digest", "_seal", "_locked") + + def __init__(self, runtime: RuntimeSnapshotV1, runtime_digest: str, *, _seal: object) -> None: + if _seal is not _ACTIVATION_SEAL: + raise DecisionError("PDP-AUTHORITY-UNAVAILABLE") from None + object.__setattr__(self, "runtime", runtime) + object.__setattr__(self, "runtime_digest", runtime_digest) + object.__setattr__(self, "_seal", _seal) + object.__setattr__(self, "_locked", True) + + def __setattr__(self, name: str, value: object) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + +def _verify_candidate( + *, + verifier: ArtifactVerifierV1, + artifact_kind: ArtifactKindV1, + algorithm: VerificationAlgorithmV1, + signer_identity: str, + payload: bytes, + signature: bytes, + failure_code: str, +) -> None: + failed = False + result: object = False + try: + verify = verifier.verify + result = verify( + artifact_kind=artifact_kind, + algorithm=algorithm, + signer_identity=signer_identity, + payload=payload, + signature=signature, + ) + except Exception: + failed = True + if failed or result is not True: + raise DecisionError(failure_code) from None + + +def activate_policy( + candidate: SignedPolicyCandidateV1, *, verifier: ArtifactVerifierV1 +) -> ActivatedPolicyV1: + if type(candidate) is not SignedPolicyCandidateV1: + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + payload = canonical_policy_bytes(candidate.policy) + _verify_candidate( + verifier=verifier, + artifact_kind=ArtifactKindV1.POLICY, + algorithm=candidate.algorithm, + signer_identity=candidate.signer_identity, + payload=payload, + signature=candidate.signature, + failure_code="PDP-POLICY-VERIFICATION", + ) + policy_digest = digest_decision_bytes(payload, domain=DecisionDigestDomain.POLICY) + revocation_payload = rfc8785.dumps( + { + "generation": candidate.policy.revocation_generation, + "revoked_lease_digests": list(candidate.policy.revoked_lease_digests), + } + ) + revocation_digest = digest_decision_bytes( + revocation_payload, domain=DecisionDigestDomain.REVOCATION + ) + return ActivatedPolicyV1( + candidate.policy, + policy_digest, + revocation_digest, + _seal=_ACTIVATION_SEAL, + ) + + +def activate_runtime( + candidate: SignedRuntimeCandidateV1, *, verifier: ArtifactVerifierV1 +) -> ActivatedRuntimeV1: + if type(candidate) is not SignedRuntimeCandidateV1: + raise DecisionError("PDP-CANDIDATE-MALFORMED") from None + payload = canonical_runtime_bytes(candidate.runtime) + _verify_candidate( + verifier=verifier, + artifact_kind=ArtifactKindV1.RUNTIME, + algorithm=candidate.algorithm, + signer_identity=candidate.signer_identity, + payload=payload, + signature=candidate.signature, + failure_code="PDP-RUNTIME-VERIFICATION", + ) + return ActivatedRuntimeV1( + candidate.runtime, + digest_decision_bytes(payload, domain=DecisionDigestDomain.RUNTIME), + _seal=_ACTIVATION_SEAL, + ) + + +def compute_request_binding_digest( + *, + identity: IdentityBindingV1, + envelope: ContentEnvelopeV1, + data_scope_digest: str, + operation: OperationBindingV1, + purpose_digest: str, +) -> str: + _revalidate(identity, IdentityBindingV1, "PDP-INPUT-MALFORMED") + _revalidate(operation, OperationBindingV1, "PDP-INPUT-MALFORMED") + if type(envelope) is not ContentEnvelopeV1: + raise DecisionError("PDP-INPUT-MALFORMED") from None + invalid = False + payload: bytes | None = None + try: + envelope_dict = to_public_dict(envelope) + body = { + "identity": _identity_dict(identity), + "envelope": envelope_dict, + "data_scope_digest": data_scope_digest, + "operation": _operation_dict(operation), + "purpose_digest": purpose_digest, + } + payload = rfc8785.dumps(body) + except Exception: + invalid = True + if invalid or payload is None: + raise DecisionError("PDP-INPUT-MALFORMED") from None + return digest_decision_bytes(payload, domain=DecisionDigestDomain.REQUEST_BINDING) + + +def create_capability_lease( + *, + lease_id: str, + request_binding_digest: str, + policy_id: str, + policy_generation: int, + not_before: int, + expires_at: int, + revocation_generation: int, +) -> CapabilityLeaseV1: + provisional = CapabilityLeaseV1( + schema_version=1, + lease_id=lease_id, + request_binding_digest=request_binding_digest, + policy_id=policy_id, + policy_generation=policy_generation, + not_before=not_before, + expires_at=expires_at, + revocation_generation=revocation_generation, + lease_digest=digest_decision_bytes(b"provisional", domain=DecisionDigestDomain.LEASE), + ) + payload = rfc8785.dumps(_lease_body(provisional)) + return provisional.model_copy( + update={"lease_digest": digest_decision_bytes(payload, domain=DecisionDigestDomain.LEASE)} + ) + + +def _request_body(request: DecisionRequestV1) -> dict[str, object]: + return { + "schema_version": request.schema_version, + "identity": _identity_dict(request.identity), + "envelope": to_public_dict(request.envelope), + "data_scope_digest": request.data_scope_digest, + "operation": _operation_dict(request.operation), + "purpose_digest": request.purpose_digest, + "lease": _lease_dict(request.lease), + } + + +def create_decision_request( + *, + identity: IdentityBindingV1, + envelope: ContentEnvelopeV1, + data_scope_digest: str, + operation: OperationBindingV1, + purpose_digest: str, + lease: CapabilityLeaseV1, +) -> DecisionRequestV1: + binding = compute_request_binding_digest( + identity=identity, + envelope=envelope, + data_scope_digest=data_scope_digest, + operation=operation, + purpose_digest=purpose_digest, + ) + if type(lease) is not CapabilityLeaseV1 or binding != lease.request_binding_digest: + raise DecisionError("PDP-LEASE-BINDING") from None + provisional = DecisionRequestV1( + schema_version=1, + identity=identity, + envelope=envelope, + data_scope_digest=data_scope_digest, + operation=operation, + purpose_digest=purpose_digest, + lease=lease, + request_digest=_INVALID_REQUEST_DIGEST, + ) + payload = rfc8785.dumps(_request_body(provisional)) + return provisional.model_copy( + update={ + "request_digest": digest_decision_bytes(payload, domain=DecisionDigestDomain.REQUEST) + } + ) + + +def _decision_body(decision: DecisionV1) -> dict[str, object]: + return { + "schema_version": decision.schema_version, + "request_digest": decision.request_digest, + "policy_digest": decision.policy_digest, + "runtime_digest": decision.runtime_digest, + "policy_generation": decision.policy_generation, + "revocation_generation": decision.revocation_generation, + "verdict": decision.verdict, + "reason": decision.reason, + "recovery": decision.recovery, + } + + +def _make_decision( + *, + request_digest: str, + active_policy: ActivatedPolicyV1, + runtime_digest: str, + verdict: DecisionVerdictV1, + reason: DecisionReasonV1, + recovery: DecisionRecoveryV1, +) -> DecisionV1: + provisional = DecisionV1( + schema_version=1, + request_digest=request_digest, + policy_digest=active_policy.policy_digest, + runtime_digest=runtime_digest, + policy_generation=active_policy.policy.policy_generation, + revocation_generation=active_policy.policy.revocation_generation, + verdict=verdict.value, + reason=reason.value, + recovery=recovery.value, + decision_digest=digest_decision_bytes(b"provisional", domain=DecisionDigestDomain.DECISION), + ) + payload = rfc8785.dumps(_decision_body(provisional)) + return provisional.model_copy( + update={ + "decision_digest": digest_decision_bytes(payload, domain=DecisionDigestDomain.DECISION) + } + ) + + +def serialize_decision(decision: DecisionV1) -> bytes: + _revalidate(decision, DecisionV1, "PDP-DECISION-MALFORMED") + expected = digest_decision_bytes( + rfc8785.dumps(_decision_body(decision)), domain=DecisionDigestDomain.DECISION + ) + if decision.decision_digest != expected: + raise DecisionError("PDP-DECISION-INTEGRITY") from None + payload = rfc8785.dumps( + _decision_body(decision) | {"decision_digest": decision.decision_digest} + ) + if len(payload) > MAX_DECISION_BYTES: + raise DecisionError("PDP-DECISION-OVER-CAP") from None + return payload + + +def _has_activation_marker(value: object, expected: type) -> bool: + if type(value) is not expected: + return False + try: + return object.__getattribute__(value, "_seal") is _ACTIVATION_SEAL + except (AttributeError, TypeError): + return False + + +def _request_preflight(request: DecisionRequestV1) -> bool: + try: + storage = object.__getattribute__(request, "__dict__") + except (AttributeError, TypeError): + return False + required = frozenset(DecisionRequestV1.model_fields) + return ( + type(storage) is dict + and required.issubset(storage) + and type(storage["identity"]) is IdentityBindingV1 + and type(storage["envelope"]) is ContentEnvelopeV1 + and type(storage["operation"]) is OperationBindingV1 + and type(storage["lease"]) is CapabilityLeaseV1 + and type(storage["request_digest"]) is str + and DIGEST_RE.fullmatch(storage["request_digest"]) is not None + ) + + +def _safe_request_digest(request: object) -> str: + if type(request) is not DecisionRequestV1: + return _INVALID_REQUEST_DIGEST + try: + value = object.__getattribute__(request, "request_digest") + except (AttributeError, TypeError): + return _INVALID_REQUEST_DIGEST + if type(value) is str and DIGEST_RE.fullmatch(value) is not None: + return value + return _INVALID_REQUEST_DIGEST + + +class PolicyDecisionPointV1: + """Pure deterministic PDP over activated policy and explicit runtime state.""" + + __slots__ = ("_policy", "_locked") + + def __init__(self, policy: ActivatedPolicyV1) -> None: + if not _has_activation_marker(policy, ActivatedPolicyV1): + raise DecisionError("PDP-AUTHORITY-UNAVAILABLE") from None + object.__setattr__(self, "_policy", policy) + object.__setattr__(self, "_locked", True) + + def __setattr__(self, name: str, value: object) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise DecisionError("PDP-AUTHORITY-IMMUTABLE") from None + + @property + def policy_digest(self) -> str: + return self._policy.policy_digest + + @property + def policy_id(self) -> str: + return self._policy.policy.policy_id + + @property + def policy_generation(self) -> int: + return self._policy.policy.policy_generation + + def _result( + self, + request_digest: str, + runtime_digest: str, + verdict: DecisionVerdictV1, + reason: DecisionReasonV1, + recovery: DecisionRecoveryV1, + ) -> DecisionV1: + return _make_decision( + request_digest=request_digest, + active_policy=self._policy, + runtime_digest=runtime_digest, + verdict=verdict, + reason=reason, + recovery=recovery, + ) + + def evaluate(self, request: object, *, runtime: object) -> DecisionV1: + runtime_digest = _INVALID_RUNTIME_DIGEST + try: + if type(request) is not DecisionRequestV1: + return self._result( + _INVALID_REQUEST_DIGEST, + runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.INPUT_MALFORMED, + DecisionRecoveryV1.REAUTHENTICATE, + ) + if not _request_preflight(request): + return self._result( + _INVALID_REQUEST_DIGEST, + runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.INPUT_MALFORMED, + DecisionRecoveryV1.REAUTHENTICATE, + ) + if not _has_activation_marker(runtime, ActivatedRuntimeV1): + return self._result( + _safe_request_digest(request), + runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.AUTHORITY_UNAVAILABLE, + DecisionRecoveryV1.REFRESH_AUTHORITY, + ) + runtime_digest = runtime.runtime_digest + return self._evaluate_validated(request, runtime) + except Exception: + return self._result( + _safe_request_digest(request), + runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.INTERNAL_ERROR, + DecisionRecoveryV1.RECOVERY_ONLY, + ) + + def _evaluate_validated( + self, request: DecisionRequestV1, runtime: ActivatedRuntimeV1 + ) -> DecisionV1: + policy = self._policy.policy + runtime_model = runtime.runtime + _revalidate(policy, PolicyBundleV1, "PDP-POLICY-MALFORMED") + _revalidate(runtime_model, RuntimeSnapshotV1, "PDP-RUNTIME-MALFORMED") + if ( + digest_decision_bytes( + canonical_policy_bytes(policy), domain=DecisionDigestDomain.POLICY + ) + != self._policy.policy_digest + ): + raise DecisionError("PDP-POLICY-INTEGRITY") from None + if ( + digest_decision_bytes( + canonical_runtime_bytes(runtime_model), domain=DecisionDigestDomain.RUNTIME + ) + != runtime.runtime_digest + ): + raise DecisionError("PDP-RUNTIME-INTEGRITY") from None + + envelope_invalid = False + try: + to_public_bytes(request.envelope) + except Exception: + envelope_invalid = True + if envelope_invalid: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.QUARANTINE, + DecisionReasonV1.ENVELOPE_INVALID, + DecisionRecoveryV1.QUARANTINE_INPUT, + ) + + try: + _revalidate(request, DecisionRequestV1, "PDP-INPUT-MALFORMED") + request_payload = rfc8785.dumps(_request_body(request)) + except Exception: + return self._result( + _INVALID_REQUEST_DIGEST, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.INPUT_MALFORMED, + DecisionRecoveryV1.REAUTHENTICATE, + ) + computed_request = digest_decision_bytes( + request_payload, domain=DecisionDigestDomain.REQUEST + ) + if computed_request != request.request_digest: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.REQUEST_INTEGRITY, + DecisionRecoveryV1.REAUTHENTICATE, + ) + + if runtime_model.health == AuthorityHealthV1.RECOVERY_ONLY.value: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.RECOVERY_ONLY, + DecisionRecoveryV1.RECOVERY_ONLY, + ) + now = runtime_model.trusted_time + if now >= runtime_model.trusted_time_valid_until: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.TRUSTED_TIME_STALE, + DecisionRecoveryV1.REFRESH_AUTHORITY, + ) + if now < policy.valid_from or now >= policy.valid_until: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.POLICY_STALE, + DecisionRecoveryV1.REFRESH_AUTHORITY, + ) + if policy.policy_generation < runtime_model.policy_generation_floor or ( + policy.policy_generation == runtime_model.policy_generation_floor + and self._policy.policy_digest != runtime_model.policy_digest_at_floor + ): + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.POLICY_ROLLBACK, + DecisionRecoveryV1.RECOVERY_ONLY, + ) + if policy.revocation_generation < runtime_model.revocation_generation_floor or ( + policy.revocation_generation == runtime_model.revocation_generation_floor + and self._policy.revocation_digest != runtime_model.revocation_digest_at_floor + ): + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.POLICY_ROLLBACK, + DecisionRecoveryV1.RECOVERY_ONLY, + ) + + taints = frozenset(request.envelope.taints) + if taints & {TaintV1.CRITICAL.value, TaintV1.AUTHORITY_INJECTION.value}: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.QUARANTINE, + DecisionReasonV1.CRITICAL_TAINT, + DecisionRecoveryV1.QUARANTINE_INPUT, + ) + if taints & {TaintV1.MALFORMED.value, TaintV1.UNINSPECTABLE.value}: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.QUARANTINE, + DecisionReasonV1.UNINSPECTABLE_DATA, + DecisionRecoveryV1.QUARANTINE_INPUT, + ) + if request.operation.capability in { + CapabilityV1.ADMINISTER_POLICY.value, + CapabilityV1.REPAIR_RECOVERY.value, + }: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.PRIVILEGED_PATH_REQUIRED, + DecisionRecoveryV1.REAUTHENTICATE, + ) + if ( + request.operation.capability == CapabilityV1.EXECUTE.value + and TaintV1.EXECUTABLE.value in taints + ): + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.QUARANTINE, + DecisionReasonV1.EXECUTABLE_CONTENT, + DecisionRecoveryV1.QUARANTINE_INPUT, + ) + + lease = request.lease + lease_payload = rfc8785.dumps(_lease_body(lease)) + if ( + digest_decision_bytes(lease_payload, domain=DecisionDigestDomain.LEASE) + != lease.lease_digest + ): + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.LEASE_BINDING, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + if ( + lease.policy_id != policy.policy_id + or lease.policy_generation != policy.policy_generation + ): + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.POLICY_BINDING, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + if lease.revocation_generation > policy.revocation_generation: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.POLICY_BINDING, + DecisionRecoveryV1.REFRESH_AUTHORITY, + ) + if now < lease.not_before: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.LEASE_NOT_YET_VALID, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + if now >= lease.expires_at: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.LEASE_EXPIRED, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + if lease.lease_digest in policy.revoked_lease_digests: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.LEASE_REVOKED, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + binding = compute_request_binding_digest( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=request.operation, + purpose_digest=request.purpose_digest, + ) + if binding != lease.request_binding_digest: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.LEASE_BINDING, + DecisionRecoveryV1.REAUTHENTICATE, + ) + if lease.lease_digest not in {grant.lease_digest for grant in policy.grants}: + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.DENY, + DecisionReasonV1.DENY_DEFAULT, + DecisionRecoveryV1.OBTAIN_NEW_LEASE, + ) + return self._result( + request.request_digest, + runtime.runtime_digest, + DecisionVerdictV1.ALLOW, + DecisionReasonV1.ALLOW_EXACT_GRANT, + DecisionRecoveryV1.NONE, + ) diff --git a/src/mcp_warden/policy_enforcement.py b/src/mcp_warden/policy_enforcement.py new file mode 100644 index 0000000..25ff2a9 --- /dev/null +++ b/src/mcp_warden/policy_enforcement.py @@ -0,0 +1,1043 @@ +"""Structural V1 policy enforcement point and signed adapter registry.""" + +from __future__ import annotations + +import json +import re +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from types import MappingProxyType +from typing import Any, ClassVar, Protocol + +import rfc8785 +from pydantic import ( + BaseModel, + ConfigDict, + StrictBool, + ValidationError, + field_validator, + model_validator, +) + +from mcp_warden.content_envelope import to_public_bytes, to_public_dict +from mcp_warden.content_models import ContentEnvelopeV1 +from mcp_warden.decision_models import ( + DIGEST_RE, + IDENTIFIER_RE, + MAX_SIGNATURE_BYTES, + ArtifactKindV1, + CapabilityV1, + DecisionDigestDomain, + DecisionRequestV1, + DecisionV1, + DecisionVerdictV1, + VerificationAlgorithmV1, + digest_decision_bytes, +) +from mcp_warden.executable_bundle import ( + ActivatedExecutableBundleV1, + is_activated_bundle, +) +from mcp_warden.handler_identity import ( + HandlerIdentityError, + digest_handler, + freeze_handler, +) +from mcp_warden.policy_decision import ( + ActivatedPolicyV1, + ActivatedRuntimeV1, + ArtifactVerifierV1, + PolicyDecisionPointV1, +) + +MAX_EFFECT_BYTES = 1024 * 1024 +MAX_HANDLER_IMPLEMENTATION_BYTES = 256 * 1024 +MAX_MANIFEST_BYTES = 256 * 1024 +MAX_MANIFEST_OPERATIONS = 1_024 +MAX_ADAPTER_DEPENDENCIES = 1_024 +MAX_ENFORCEMENT_RESULT_BYTES = 128 * 1024 +VERSION_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._+-]{0,62}[A-Za-z0-9])?$") + + +class EnforcementError(Exception): + """Stable code-only enforcement failure.""" + + def __init__(self, code: str) -> None: + self.code = code + super().__init__(code) + + def __str__(self) -> str: + return self.code + + def __repr__(self) -> str: + return self.code + + +class EnforcementCodeV1(StrEnum): + EXECUTED = "PEP-EXECUTED" + DECISION_BLOCKED = "PEP-DECISION-BLOCKED" + EFFECT_MALFORMED = "PEP-EFFECT-MALFORMED" + EFFECT_DIGEST_MISMATCH = "PEP-EFFECT-DIGEST-MISMATCH" + ADAPTER_MISMATCH = "PEP-ADAPTER-MISMATCH" + BUNDLE_UNAVAILABLE = "PEP-BUNDLE-UNAVAILABLE" + BUNDLE_MISMATCH = "PEP-BUNDLE-MISMATCH" + OPERATION_UNKNOWN = "PEP-OPERATION-UNKNOWN" + EVIDENCE_UNAVAILABLE = "PEP-EVIDENCE-UNAVAILABLE" + EVIDENCE_MISMATCH = "PEP-EVIDENCE-MISMATCH" + SINK_FAILED = "PEP-SINK-FAILED" + OUTPUT_INVALID = "PEP-OUTPUT-INVALID" + INTERNAL_ERROR = "PEP-INTERNAL-ERROR" + + +class EffectOutcomeV1(StrEnum): + BLOCKED = "blocked" + COMPLETED = "completed" + INDETERMINATE = "indeterminate" + + +def _digest(value: str) -> str: + if type(value) is not str or DIGEST_RE.fullmatch(value) is None: + raise ValueError("invalid digest") + return value + + +def _identifier(value: str) -> str: + if type(value) is not str or IDENTIFIER_RE.fullmatch(value) is None: + raise ValueError("invalid identifier") + return value + + +class _EnforcementModel(BaseModel): + _validation_code: ClassVar[str] = "PEP-MALFORMED" + model_config = ConfigDict( + extra="forbid", + frozen=True, + strict=True, + hide_input_in_errors=True, + revalidate_instances="always", + ) + + def __init__(self, **data: Any) -> None: + invalid = False + try: + super().__init__(**data) + except ValidationError: + invalid = True + if invalid: + raise EnforcementError(self._validation_code) from None + + def __setattr__(self, name: str, value: Any) -> None: + invalid = False + try: + super().__setattr__(name, value) + except ValidationError: + invalid = True + if invalid: + raise EnforcementError(self._validation_code) from None + + def __delattr__(self, name: str) -> None: + invalid = False + try: + super().__delattr__(name) + except (TypeError, ValidationError): + invalid = True + if invalid: + raise EnforcementError(self._validation_code) from None + + @classmethod + def model_validate(cls, obj: Any, **kwargs: Any) -> Any: + invalid = False + result: Any = None + try: + result = super().model_validate(obj, **kwargs) + except ValidationError: + invalid = True + if invalid: + raise EnforcementError(cls._validation_code) from None + return result + + +class ManifestOperationV1(_EnforcementModel): + _validation_code: ClassVar[str] = "PEP-MANIFEST-MALFORMED" + operation_id: str + capability: str + handler_digest: str + + _operation = field_validator("operation_id")(_identifier) + _handler = field_validator("handler_digest")(_digest) + + @field_validator("capability") + @classmethod + def _capability(cls, value: str) -> str: + if value not in {item.value for item in CapabilityV1}: + raise ValueError("invalid capability") + return value + + +class AdapterManifestV1(_EnforcementModel): + _validation_code: ClassVar[str] = "PEP-MANIFEST-MALFORMED" + schema_version: int + adapter_id: str + adapter_version: str + implementation_digest: str + dependency_digests: tuple[str, ...] + policy_id: str + policy_generation: int + operations: tuple[ManifestOperationV1, ...] + + _adapter = field_validator("adapter_id")(_identifier) + _implementation = field_validator("implementation_digest")(_digest) + _policy = field_validator("policy_id")(_digest) + + @field_validator("dependency_digests") + @classmethod + def _dependencies(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if len(value) > MAX_ADAPTER_DEPENDENCIES or value != tuple(sorted(set(value))): + raise ValueError("invalid dependencies") + for item in value: + _digest(item) + return value + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if type(value) is not int or value != 1: + raise ValueError("unknown schema") + return value + + @field_validator("adapter_version") + @classmethod + def _version(cls, value: str) -> str: + if type(value) is not str or VERSION_RE.fullmatch(value) is None: + raise ValueError("invalid version") + return value + + @field_validator("policy_generation") + @classmethod + def _generation(cls, value: int) -> int: + if type(value) is not int or value < 0: + raise ValueError("invalid generation") + return value + + @field_validator("operations") + @classmethod + def _operations(cls, value: tuple[ManifestOperationV1, ...]) -> tuple[ManifestOperationV1, ...]: + if ( + not value + or len(value) > MAX_MANIFEST_OPERATIONS + or any(type(item) is not ManifestOperationV1 for item in value) + ): + raise ValueError("invalid operations") + if value != tuple(sorted(value, key=lambda item: item.operation_id)): + raise ValueError("unsorted operations") + if len({item.operation_id for item in value}) != len(value): + raise ValueError("duplicate operations") + return value + + +class EvidenceResultV1(_EnforcementModel): + _validation_code: ClassVar[str] = "PEP-EVIDENCE-MALFORMED" + schema_version: int + request_digest: str + decision_digest: str + manifest_digest: str + policy_digest: str + evidence_digest: str + + _request = field_validator("request_digest")(_digest) + _decision = field_validator("decision_digest")(_digest) + _manifest = field_validator("manifest_digest")(_digest) + _policy = field_validator("policy_digest")(_digest) + _evidence = field_validator("evidence_digest")(_digest) + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if type(value) is not int or value != 1: + raise ValueError("unknown schema") + return value + + +class EnforcementResultV1(_EnforcementModel): + _validation_code: ClassVar[str] = "PEP-RESULT-MALFORMED" + schema_version: int + invoked: StrictBool + outcome: str + code: str + manifest_digest: str + decision: DecisionV1 | None + evidence_digest: str | None + output: ContentEnvelopeV1 | None + result_digest: str + + _manifest = field_validator("manifest_digest")(_digest) + _result = field_validator("result_digest")(_digest) + + @field_validator("schema_version") + @classmethod + def _schema(cls, value: int) -> int: + if type(value) is not int or value != 1: + raise ValueError("unknown schema") + return value + + @field_validator("outcome") + @classmethod + def _outcome(cls, value: str) -> str: + if value not in {item.value for item in EffectOutcomeV1}: + raise ValueError("invalid outcome") + return value + + @field_validator("code") + @classmethod + def _code(cls, value: str) -> str: + if value not in {item.value for item in EnforcementCodeV1}: + raise ValueError("invalid code") + return value + + @field_validator("evidence_digest") + @classmethod + def _optional_evidence(cls, value: str | None) -> str | None: + return None if value is None else _digest(value) + + @model_validator(mode="after") + def _nested_exact(self) -> EnforcementResultV1: + if self.decision is not None and type(self.decision) is not DecisionV1: + raise ValueError("invalid decision") + if self.output is not None and type(self.output) is not ContentEnvelopeV1: + raise ValueError("invalid output") + return self + + +@dataclass(frozen=True, slots=True) +class EffectInputV1: + arguments: bytes + arguments_digest: str + + def __post_init__(self) -> None: + if type(self.arguments) is not bytes or len(self.arguments) > MAX_EFFECT_BYTES: + raise EnforcementError("PEP-EFFECT-MALFORMED") from None + invalid_digest = False + try: + _digest(self.arguments_digest) + except ValueError: + invalid_digest = True + if invalid_digest: + raise EnforcementError("PEP-EFFECT-MALFORMED") from None + + +@dataclass(frozen=True, slots=True) +class EnforcementTraceV1: + """Code-only trace emitted only by the conformance execution path.""" + + events: tuple[str, ...] + output_channels: tuple[bytes, ...] + + +@dataclass(frozen=True, slots=True) +class SignedAdapterCandidateV1: + manifest: AdapterManifestV1 + implementation: bytes + dependencies: tuple[bytes, ...] + algorithm: VerificationAlgorithmV1 + signer_identity: str + signature: bytes + + def __post_init__(self) -> None: + if ( + type(self.manifest) is not AdapterManifestV1 + or type(self.implementation) is not bytes + or len(self.implementation) > MAX_HANDLER_IMPLEMENTATION_BYTES + or type(self.dependencies) is not tuple + or len(self.dependencies) > MAX_ADAPTER_DEPENDENCIES + or any(type(item) is not bytes for item in self.dependencies) + or type(self.algorithm) is not VerificationAlgorithmV1 + or type(self.signature) is not bytes + or not self.signature + or len(self.signature) > MAX_SIGNATURE_BYTES + ): + raise EnforcementError("PEP-CANDIDATE-MALFORMED") from None + invalid_signer = False + try: + _digest(self.signer_identity) + except ValueError: + invalid_signer = True + if invalid_signer: + raise EnforcementError("PEP-CANDIDATE-MALFORMED") from None + + +def _manifest_dict(manifest: AdapterManifestV1) -> dict[str, object]: + return { + "schema_version": manifest.schema_version, + "adapter_id": manifest.adapter_id, + "adapter_version": manifest.adapter_version, + "implementation_digest": manifest.implementation_digest, + "dependency_digests": list(manifest.dependency_digests), + "policy_id": manifest.policy_id, + "policy_generation": manifest.policy_generation, + "operations": [ + { + "operation_id": operation.operation_id, + "capability": operation.capability, + "handler_digest": operation.handler_digest, + } + for operation in manifest.operations + ], + } + + +def _revalidate(value: object, expected: type, code: str) -> None: + if type(value) is not expected: + raise EnforcementError(code) from None + invalid = False + try: + expected.model_validate(value) + except Exception: + invalid = True + if invalid: + raise EnforcementError(code) from None + + +def canonical_manifest_bytes(manifest: AdapterManifestV1) -> bytes: + _revalidate(manifest, AdapterManifestV1, "PEP-MANIFEST-MALFORMED") + invalid = False + payload: bytes | None = None + try: + payload = rfc8785.dumps(_manifest_dict(manifest)) + except Exception: + invalid = True + if invalid or payload is None: + raise EnforcementError("PEP-MANIFEST-MALFORMED") from None + if len(payload) > MAX_MANIFEST_BYTES: + raise EnforcementError("PEP-MANIFEST-OVER-CAP") from None + return payload + + +def digest_handler_implementation(handler: object) -> str: + """Digest executable evidence derived from the actual supported callable.""" + try: + return digest_handler(handler) + except HandlerIdentityError: + raise EnforcementError("PEP-HANDLER-MALFORMED") from None + + +def digest_adapter_implementation(implementation: bytes) -> str: + if type(implementation) is not bytes or len(implementation) > MAX_HANDLER_IMPLEMENTATION_BYTES: + raise EnforcementError("PEP-ADAPTER-INTEGRITY") from None + return digest_decision_bytes(implementation, domain=DecisionDigestDomain.ADAPTER_IMPLEMENTATION) + + +def digest_adapter_dependency(dependency: bytes) -> str: + if type(dependency) is not bytes or len(dependency) > MAX_HANDLER_IMPLEMENTATION_BYTES: + raise EnforcementError("PEP-ADAPTER-INTEGRITY") from None + return digest_decision_bytes(dependency, domain=DecisionDigestDomain.ADAPTER_DEPENDENCY) + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError + result[key] = value + return result + + +def _reject_constant(_: str) -> None: + raise ValueError + + +def create_effect_input(arguments: bytes) -> EffectInputV1: + if type(arguments) is not bytes or len(arguments) > MAX_EFFECT_BYTES: + raise EnforcementError("PEP-EFFECT-MALFORMED") from None + invalid = False + canonical: bytes | None = None + try: + parsed = json.loads( + arguments.decode("utf-8", errors="strict"), + object_pairs_hook=_unique_object, + parse_constant=_reject_constant, + ) + canonical = rfc8785.dumps(parsed) + except Exception: + invalid = True + if invalid or canonical is None: + raise EnforcementError("PEP-EFFECT-MALFORMED") from None + if canonical != arguments: + raise EnforcementError("PEP-EFFECT-NONCANONICAL") from None + return EffectInputV1( + arguments=arguments, + arguments_digest=digest_decision_bytes( + arguments, domain=DecisionDigestDomain.EFFECT_ARGUMENTS + ), + ) + + +class AdapterRegistryV1: + """One-shot finite registration builder consumed by adapter activation.""" + + __slots__ = ("_registrations", "_frozen") + + def __init__(self) -> None: + self._registrations: dict[str, tuple[Callable[[bytes], object], str]] = {} + self._frozen = False + + def register(self, *, operation_id: str, handler: Callable[[bytes], object]) -> None: + if self._frozen: + raise EnforcementError("PEP-REGISTRY-FROZEN") from None + invalid_operation = False + try: + _identifier(operation_id) + except ValueError: + invalid_operation = True + if invalid_operation: + raise EnforcementError("PEP-REGISTRATION-MALFORMED") from None + if operation_id in self._registrations or not callable(handler): + raise EnforcementError("PEP-REGISTRATION-MALFORMED") from None + try: + frozen_handler = freeze_handler(handler) + handler_digest = digest_handler(frozen_handler) + except HandlerIdentityError: + raise EnforcementError("PEP-HANDLER-MALFORMED") from None + self._registrations[operation_id] = (frozen_handler, handler_digest) + + def _snapshot(self) -> dict[str, tuple[Callable[[bytes], object], str]]: + if self._frozen: + raise EnforcementError("PEP-REGISTRY-FROZEN") from None + return dict(self._registrations) + + def _freeze(self) -> None: + self._frozen = True + + +_ADAPTER_SEAL = object() + + +class ActivatedAdapterV1: + __slots__ = ( + "manifest", + "manifest_digest", + "policy_digest", + "_handlers", + "_seal", + "_locked", + ) + + def __init__( + self, + *, + manifest: AdapterManifestV1, + manifest_digest: str, + policy_digest: str, + handlers: dict[str, tuple[Callable[[bytes], object], str]], + _seal: object, + ) -> None: + if _seal is not _ADAPTER_SEAL: + raise EnforcementError("PEP-ADAPTER-UNAVAILABLE") from None + object.__setattr__(self, "manifest", manifest) + object.__setattr__(self, "manifest_digest", manifest_digest) + object.__setattr__(self, "policy_digest", policy_digest) + object.__setattr__(self, "_handlers", MappingProxyType(dict(handlers))) + object.__setattr__(self, "_seal", _seal) + object.__setattr__(self, "_locked", True) + + def __setattr__(self, name: str, value: object) -> None: + raise EnforcementError("PEP-ADAPTER-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise EnforcementError("PEP-ADAPTER-IMMUTABLE") from None + + def _handler(self, operation_id: str) -> Callable[[bytes], object]: + missing = False + registration: tuple[Callable[[bytes], object], str] | None = None + try: + registration = self._handlers[operation_id] + except KeyError: + missing = True + if missing or registration is None: + raise EnforcementError("PEP-OPERATION-UNKNOWN") from None + handler, expected_digest = registration + try: + current_digest = digest_handler(handler) + except HandlerIdentityError: + raise EnforcementError("PEP-ADAPTER-MISMATCH") from None + if current_digest != expected_digest: + raise EnforcementError("PEP-ADAPTER-MISMATCH") from None + return handler + + @property + def registration_operations(self) -> tuple[str, ...]: + return tuple(sorted(self._handlers)) + + +def activate_adapter( + candidate: SignedAdapterCandidateV1, + *, + registry: AdapterRegistryV1, + verifier: ArtifactVerifierV1, + policy: ActivatedPolicyV1, +) -> ActivatedAdapterV1: + if ( + type(candidate) is not SignedAdapterCandidateV1 + or type(registry) is not AdapterRegistryV1 + or type(policy) is not ActivatedPolicyV1 + ): + raise EnforcementError("PEP-CANDIDATE-MALFORMED") from None + manifest = candidate.manifest + payload = canonical_manifest_bytes(manifest) + dependency_digests = tuple( + sorted(digest_adapter_dependency(item) for item in candidate.dependencies) + ) + if ( + len(set(dependency_digests)) != len(candidate.dependencies) + or manifest.implementation_digest != digest_adapter_implementation(candidate.implementation) + or manifest.dependency_digests != dependency_digests + ): + raise EnforcementError("PEP-ADAPTER-INTEGRITY") from None + if ( + manifest.policy_id != policy.policy.policy_id + or manifest.policy_generation != policy.policy.policy_generation + ): + raise EnforcementError("PEP-POLICY-BINDING") from None + registrations = registry._snapshot() + expected = {item.operation_id: item for item in manifest.operations} + if set(registrations) != set(expected) or any( + registrations[name][1] != expected[name].handler_digest for name in expected + ): + raise EnforcementError("PEP-MANIFEST-REGISTRY-MISMATCH") from None + verification_failed = False + verified: object = False + try: + verified = verifier.verify( + artifact_kind=ArtifactKindV1.ADAPTER, + algorithm=candidate.algorithm, + signer_identity=candidate.signer_identity, + payload=payload, + signature=candidate.signature, + ) + except Exception: + verification_failed = True + if verification_failed or verified is not True: + raise EnforcementError("PEP-ADAPTER-VERIFICATION") from None + registry._freeze() + return ActivatedAdapterV1( + manifest=manifest, + manifest_digest=digest_decision_bytes( + payload, domain=DecisionDigestDomain.ADAPTER_MANIFEST + ), + policy_digest=policy.policy_digest, + handlers=registrations, + _seal=_ADAPTER_SEAL, + ) + + +def _evidence_body(result: EvidenceResultV1) -> dict[str, object]: + return { + "schema_version": result.schema_version, + "request_digest": result.request_digest, + "decision_digest": result.decision_digest, + "manifest_digest": result.manifest_digest, + "policy_digest": result.policy_digest, + } + + +def create_evidence_result(*, decision: DecisionV1, manifest_digest: str) -> EvidenceResultV1: + if type(decision) is not DecisionV1: + raise EnforcementError("PEP-EVIDENCE-MALFORMED") from None + provisional = EvidenceResultV1( + schema_version=1, + request_digest=decision.request_digest, + decision_digest=decision.decision_digest, + manifest_digest=manifest_digest, + policy_digest=decision.policy_digest, + evidence_digest=digest_decision_bytes( + b"provisional", domain=DecisionDigestDomain.EFFECT_EVIDENCE + ), + ) + payload = rfc8785.dumps(_evidence_body(provisional)) + return provisional.model_copy( + update={ + "evidence_digest": digest_decision_bytes( + payload, domain=DecisionDigestDomain.EFFECT_EVIDENCE + ) + } + ) + + +class EvidenceGateV1(Protocol): + def record(self, *, decision: DecisionV1, manifest_digest: str) -> EvidenceResultV1: ... + + +class FailClosedEvidenceGateV1: + __slots__ = () + + def record(self, *, decision: DecisionV1, manifest_digest: str) -> EvidenceResultV1: + raise EnforcementError("PEP-EVIDENCE-UNAVAILABLE") from None + + +def _result_body(result: EnforcementResultV1) -> dict[str, object]: + decision = None if result.decision is None else result.decision.model_dump(mode="python") + output = None if result.output is None else to_public_dict(result.output) + return { + "schema_version": result.schema_version, + "invoked": result.invoked, + "outcome": result.outcome, + "code": result.code, + "manifest_digest": result.manifest_digest, + "decision": decision, + "evidence_digest": result.evidence_digest, + "output": output, + } + + +def _make_result( + *, + invoked: bool, + outcome: EffectOutcomeV1, + code: EnforcementCodeV1, + manifest_digest: str, + decision: DecisionV1 | None = None, + evidence_digest: str | None = None, + output: ContentEnvelopeV1 | None = None, +) -> EnforcementResultV1: + provisional = EnforcementResultV1( + schema_version=1, + invoked=invoked, + outcome=outcome.value, + code=code.value, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence_digest, + output=output, + result_digest=digest_decision_bytes( + b"provisional", domain=DecisionDigestDomain.EFFECT_EVIDENCE + ), + ) + payload = rfc8785.dumps(_result_body(provisional)) + return provisional.model_copy( + update={ + "result_digest": digest_decision_bytes( + payload, domain=DecisionDigestDomain.EFFECT_EVIDENCE + ) + } + ) + + +def serialize_enforcement_result(result: EnforcementResultV1) -> bytes: + _revalidate(result, EnforcementResultV1, "PEP-RESULT-MALFORMED") + expected = digest_decision_bytes( + rfc8785.dumps(_result_body(result)), domain=DecisionDigestDomain.EFFECT_EVIDENCE + ) + if result.result_digest != expected: + raise EnforcementError("PEP-RESULT-INTEGRITY") from None + payload = rfc8785.dumps(_result_body(result) | {"result_digest": result.result_digest}) + if len(payload) > MAX_ENFORCEMENT_RESULT_BYTES: + raise EnforcementError("PEP-RESULT-OVER-CAP") from None + return payload + + +class PolicyEnforcementPointV1: + """Only supported operation-to-sink route for an activated adapter.""" + + __slots__ = ("_pdp", "_adapter", "_bundles", "_evidence_gate", "_locked") + + def __init__( + self, + pdp: PolicyDecisionPointV1, + adapter: ActivatedAdapterV1, + *, + evidence_gate: EvidenceGateV1 | None = None, + executable_bundles: tuple[ActivatedExecutableBundleV1, ...] = (), + ) -> None: + marker_valid = False + if type(adapter) is ActivatedAdapterV1: + try: + marker_valid = object.__getattribute__(adapter, "_seal") is _ADAPTER_SEAL + except (AttributeError, TypeError): + marker_valid = False + if type(pdp) is not PolicyDecisionPointV1 or not marker_valid: + raise EnforcementError("PEP-UNAVAILABLE") from None + if pdp.policy_digest != adapter.policy_digest: + raise EnforcementError("PEP-POLICY-BINDING") from None + if ( + type(executable_bundles) is not tuple + or any(not is_activated_bundle(item) for item in executable_bundles) + or len({item.manifest_digest for item in executable_bundles}) != len(executable_bundles) + or any( + item.policy_digest != pdp.policy_digest + or item.manifest.adapter_manifest_digest != adapter.manifest_digest + for item in executable_bundles + ) + ): + raise EnforcementError("PEP-BUNDLE-UNAVAILABLE") from None + object.__setattr__(self, "_pdp", pdp) + object.__setattr__(self, "_adapter", adapter) + object.__setattr__( + self, + "_bundles", + MappingProxyType({item.manifest_digest: item for item in executable_bundles}), + ) + object.__setattr__( + self, + "_evidence_gate", + FailClosedEvidenceGateV1() if evidence_gate is None else evidence_gate, + ) + object.__setattr__(self, "_locked", True) + + def __setattr__(self, name: str, value: object) -> None: + raise EnforcementError("PEP-IMMUTABLE") from None + + def __delattr__(self, name: str) -> None: + raise EnforcementError("PEP-IMMUTABLE") from None + + @property + def manifest(self) -> AdapterManifestV1: + return self._adapter.manifest + + @property + def manifest_digest(self) -> str: + return self._adapter.manifest_digest + + @property + def registration_operations(self) -> tuple[str, ...]: + return self._adapter.registration_operations + + def execute( + self, + request: object, + *, + runtime: ActivatedRuntimeV1, + effect: object, + ) -> EnforcementResultV1: + return self._execute(request, runtime=runtime, effect=effect, trace=None) + + def _execute_instrumented( + self, + request: object, + *, + runtime: ActivatedRuntimeV1, + effect: object, + ) -> tuple[EnforcementResultV1, EnforcementTraceV1]: + events: list[str] = [] + result = self._execute(request, runtime=runtime, effect=effect, trace=events) + events.append("result:" + result.code) + event_channel = rfc8785.dumps(events) + return result, EnforcementTraceV1( + events=tuple(events), + output_channels=(serialize_enforcement_result(result), event_channel), + ) + + def _execute( + self, + request: object, + *, + runtime: ActivatedRuntimeV1, + effect: object, + trace: list[str] | None, + ) -> EnforcementResultV1: + manifest_digest = self._adapter.manifest_digest + if type(effect) is not EffectInputV1: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EFFECT_MALFORMED, + manifest_digest=manifest_digest, + ) + try: + canonical_effect = create_effect_input(effect.arguments) + except Exception: + canonical_effect = None + if canonical_effect is None or canonical_effect.arguments_digest != effect.arguments_digest: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EFFECT_MALFORMED, + manifest_digest=manifest_digest, + ) + if trace is not None: + trace.append("effect") + request_valid = True + try: + if type(request) is not DecisionRequestV1: + raise ValueError + DecisionRequestV1.model_validate(request) + except Exception: + request_valid = False + if not request_valid: + decision = self._pdp.evaluate(request, runtime=runtime) + if trace is not None: + trace.append("decision") + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.DECISION_BLOCKED, + manifest_digest=manifest_digest, + decision=decision, + ) + if trace is not None: + trace.append("request") + request_operation = request.operation + request_arguments_digest = request_operation.arguments_digest + if effect.arguments_digest != request_arguments_digest: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EFFECT_DIGEST_MISMATCH, + manifest_digest=manifest_digest, + ) + manifest = self._adapter.manifest + if ( + request_operation.adapter_id != manifest.adapter_id + or request_operation.adapter_manifest_digest != manifest_digest + ): + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.ADAPTER_MISMATCH, + manifest_digest=manifest_digest, + ) + if trace is not None: + trace.append("adapter") + if request_operation.capability == CapabilityV1.EXECUTE.value: + bundle_digest = request_operation.bundle_manifest_digest + bundle = self._bundles.get(bundle_digest) + if bundle is None: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.BUNDLE_UNAVAILABLE, + manifest_digest=manifest_digest, + ) + if request.envelope.bundle != bundle.evidence: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.BUNDLE_MISMATCH, + manifest_digest=manifest_digest, + ) + if trace is not None: + trace.append("bundle") + operations = {item.operation_id: item for item in manifest.operations} + manifest_operation = operations.get(request_operation.operation_id) + if ( + manifest_operation is None + or manifest_operation.capability != request_operation.capability + ): + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.OPERATION_UNKNOWN, + manifest_digest=manifest_digest, + ) + decision = self._pdp.evaluate(request, runtime=runtime) + if trace is not None: + trace.append("decision") + if decision.verdict != DecisionVerdictV1.ALLOW.value: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.DECISION_BLOCKED, + manifest_digest=manifest_digest, + decision=decision, + ) + try: + evidence = self._evidence_gate.record( + decision=decision, manifest_digest=manifest_digest + ) + except Exception: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EVIDENCE_UNAVAILABLE, + manifest_digest=manifest_digest, + decision=decision, + ) + if type(evidence) is not EvidenceResultV1: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EVIDENCE_MISMATCH, + manifest_digest=manifest_digest, + decision=decision, + ) + evidence_valid = True + try: + _revalidate(evidence, EvidenceResultV1, "PEP-EVIDENCE-MALFORMED") + evidence_payload = rfc8785.dumps(_evidence_body(evidence)) + evidence_digest = digest_decision_bytes( + evidence_payload, domain=DecisionDigestDomain.EFFECT_EVIDENCE + ) + except Exception: + evidence_valid = False + evidence_digest = "" + if not evidence_valid or ( + evidence.request_digest != request.request_digest + or evidence.decision_digest != decision.decision_digest + or evidence.manifest_digest != manifest_digest + or evidence.policy_digest != decision.policy_digest + or evidence.evidence_digest != evidence_digest + ): + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.EVIDENCE_MISMATCH, + manifest_digest=manifest_digest, + decision=decision, + ) + if trace is not None: + trace.append("evidence") + try: + handler = self._adapter._handler(request_operation.operation_id) + except Exception: + return _make_result( + invoked=False, + outcome=EffectOutcomeV1.BLOCKED, + code=EnforcementCodeV1.ADAPTER_MISMATCH, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence.evidence_digest, + ) + try: + if trace is not None: + trace.append("sink") + output = handler(effect.arguments) + except Exception: + return _make_result( + invoked=True, + outcome=EffectOutcomeV1.INDETERMINATE, + code=EnforcementCodeV1.SINK_FAILED, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence.evidence_digest, + ) + if trace is not None: + trace.append("output") + if output is not None and type(output) is not ContentEnvelopeV1: + return _make_result( + invoked=True, + outcome=EffectOutcomeV1.INDETERMINATE, + code=EnforcementCodeV1.OUTPUT_INVALID, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence.evidence_digest, + ) + if output is not None: + try: + to_public_bytes(output) + except Exception: + return _make_result( + invoked=True, + outcome=EffectOutcomeV1.INDETERMINATE, + code=EnforcementCodeV1.OUTPUT_INVALID, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence.evidence_digest, + ) + return _make_result( + invoked=True, + outcome=EffectOutcomeV1.COMPLETED, + code=EnforcementCodeV1.EXECUTED, + manifest_digest=manifest_digest, + decision=decision, + evidence_digest=evidence.evidence_digest, + output=output, + ) diff --git a/tests/fuzz/test_fuzz_policy_decision.py b/tests/fuzz/test_fuzz_policy_decision.py new file mode 100644 index 0000000..6d2d287 --- /dev/null +++ b/tests/fuzz/test_fuzz_policy_decision.py @@ -0,0 +1,100 @@ +"""Bounded properties for the deterministic DSE-716 PDP/PEP boundary.""" + +from __future__ import annotations + +import rfc8785 +from hypothesis import given, settings +from hypothesis import strategies as st + +from mcp_warden.decision_models import ( + AuthorityHealthV1, + DecisionDigestDomain, + DecisionReasonV1, + DecisionVerdictV1, + RuntimeSnapshotV1, + SignedRuntimeCandidateV1, + VerificationAlgorithmV1, + digest_decision_bytes, +) +from mcp_warden.policy_decision import ( + PolicyDecisionPointV1, + activate_runtime, + serialize_decision, +) +from mcp_warden.policy_enforcement import EnforcementError, create_effect_input +from tests.test_policy_enforcement import Verifier, _active_components + + +@settings(max_examples=75) +@given( + st.sampled_from(("user_digest", "agent_digest", "device_digest", "session_digest")), + st.binary(min_size=1, max_size=64), +) +def test_any_identity_substitution_fails_closed(field: str, marker: bytes) -> None: + _, request, active_policy, runtime = _active_components() + forged_identity = request.identity.model_copy( + update={field: digest_decision_bytes(marker, domain=DecisionDigestDomain.CLAIM)} + ) + forged = request.model_copy(update={"identity": forged_identity}) + first = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=runtime) + second = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=runtime) + assert first == second + assert first.verdict == DecisionVerdictV1.DENY.value + assert first.reason == DecisionReasonV1.REQUEST_INTEGRITY.value + + +@settings(max_examples=100, deadline=None) +@given(st.binary(max_size=2_048)) +def test_arbitrary_effect_bytes_are_canonical_or_fail_code_only(data: bytes) -> None: + try: + effect = create_effect_input(data) + except EnforcementError as error: + assert error.code.startswith("PEP-") + assert error.__cause__ is None + assert error.__context__ is None + else: + assert rfc8785.dumps(__import__("json").loads(data)) == effect.arguments + + +@settings(max_examples=50) +@given(st.integers(min_value=0, max_value=600)) +def test_explicit_trusted_time_has_deterministic_validity_boundary(now: int) -> None: + _, request, active_policy, _ = _active_components() + runtime_model = RuntimeSnapshotV1( + schema_version=1, + health=AuthorityHealthV1.HEALTHY.value, + trusted_time=now, + trusted_time_valid_until=250, + policy_generation_floor=7, + policy_digest_at_floor=active_policy.policy_digest, + revocation_generation_floor=3, + revocation_digest_at_floor=active_policy.revocation_digest, + ) + runtime = activate_runtime( + SignedRuntimeCandidateV1( + runtime=runtime_model, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=digest_decision_bytes( + b"runtime-signer", domain=DecisionDigestDomain.CLAIM + ), + signature=b"valid-signature", + ), + verifier=Verifier(), + ) + first = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=runtime) + second = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=runtime) + assert serialize_decision(first) == serialize_decision(second) + assert (first.verdict == DecisionVerdictV1.ALLOW.value) is (100 <= now < 250) + + +@settings(max_examples=75) +@given(st.text(min_size=1, max_size=64)) +def test_raw_identity_marker_never_appears_in_decision(marker: str) -> None: + _, request, active_policy, runtime = _active_components() + planted = ("PLANTED-DSE716-" + marker).encode("utf-8") + forged_identity = request.identity.model_copy( + update={"user_digest": digest_decision_bytes(planted, domain=DecisionDigestDomain.CLAIM)} + ) + forged = request.model_copy(update={"identity": forged_identity}) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=runtime) + assert planted not in serialize_decision(decision) diff --git a/tests/test_adapter_conformance.py b/tests/test_adapter_conformance.py new file mode 100644 index 0000000..63285f7 --- /dev/null +++ b/tests/test_adapter_conformance.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from mcp_warden.adapter_conformance import ( + AdapterConformanceCaseV1, + ConformanceError, + ConformanceFailureV1, + ConformanceReportV1, + run_adapter_conformance, + serialize_conformance_report, +) +from mcp_warden.policy_decision import PolicyDecisionPointV1 +from mcp_warden.policy_enforcement import ( + EnforcementCodeV1, + PolicyEnforcementPointV1, + create_effect_input, +) +from tests.test_policy_enforcement import ( + PermitGate, + _activated_adapter, + _active_components, + _noop_handler, +) + + +def _pep_and_cases(): + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + pep = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate(trace) + ) + allow = AdapterConformanceCaseV1( + name="allow-document-read", + request=request, + runtime=runtime, + effect=effect, + expected_code=EnforcementCodeV1.EXECUTED, + expected_invoked=True, + planted_secrets=(b"planted-secret-allow",), + ) + blocked = AdapterConformanceCaseV1( + name="block-effect-substitution", + request=request, + runtime=runtime, + effect=create_effect_input(b'{"document_id":"forged"}'), + expected_code=EnforcementCodeV1.EFFECT_DIGEST_MISMATCH, + expected_invoked=False, + planted_secrets=(b"planted-secret-blocked",), + ) + return pep, (allow, blocked), trace + + +def test_conformance_report_passes_with_full_operation_and_negative_coverage() -> None: + pep, cases, trace = _pep_and_cases() + report = run_adapter_conformance(pep, cases=cases) + assert report.passed is True + assert report.total_cases > len(cases) + assert report.registration_operations == ("document.read",) + assert report.instrumented_cases == report.total_cases + assert report.covered_operations == ("document.read",) + assert report.failures == () + assert trace == ["evidence"] + assert b"planted-secret" not in serialize_conformance_report(report) + + +def test_missing_allow_vector_fails_operation_coverage() -> None: + pep, cases, _ = _pep_and_cases() + report = run_adapter_conformance(pep, cases=(cases[1],)) + assert report.passed is False + assert ConformanceFailureV1.OPERATION_UNCOVERED.value in report.failures + + +def test_expected_code_or_invocation_mismatch_is_reported_without_input_echo() -> None: + pep, cases, _ = _pep_and_cases() + wrong = AdapterConformanceCaseV1( + name="wrong-expectation", + request=cases[0].request, + runtime=cases[0].runtime, + effect=cases[0].effect, + expected_code=EnforcementCodeV1.DECISION_BLOCKED, + expected_invoked=False, + planted_secrets=(b"secret-expectation",), + ) + report = run_adapter_conformance(pep, cases=(wrong, cases[1])) + assert report.passed is False + assert ConformanceFailureV1.CASE_MISMATCH.value in report.failures + assert b"secret-expectation" not in serialize_conformance_report(report) + + +def test_planted_secret_scan_covers_serialized_enforcement_result() -> None: + pep, cases, _ = _pep_and_cases() + collision = AdapterConformanceCaseV1( + name="secret-scan-proof", + request=cases[0].request, + runtime=cases[0].runtime, + effect=cases[0].effect, + expected_code=EnforcementCodeV1.EXECUTED, + expected_invoked=True, + planted_secrets=(b"PEP-EXECUTED",), + ) + report = run_adapter_conformance(pep, cases=(collision, cases[1])) + assert report.passed is False + assert ConformanceFailureV1.SECRET_LEAK.value in report.failures + + +def test_planted_secret_scan_covers_pep_owned_instrumentation_channel() -> None: + pep, cases, _ = _pep_and_cases() + collision = AdapterConformanceCaseV1( + name="instrumentation-secret-scan-proof", + request=cases[0].request, + runtime=cases[0].runtime, + effect=cases[0].effect, + expected_code=EnforcementCodeV1.EXECUTED, + expected_invoked=True, + planted_secrets=(b'"sink"',), + ) + report = run_adapter_conformance(pep, cases=(collision, cases[1])) + assert report.passed is False + assert ConformanceFailureV1.SECRET_LEAK.value in report.failures + + +def test_reports_are_byte_identical_for_identical_case_outcomes() -> None: + first_pep, first_cases, _ = _pep_and_cases() + second_pep, second_cases, _ = _pep_and_cases() + first = run_adapter_conformance(first_pep, cases=first_cases) + second = run_adapter_conformance(second_pep, cases=second_cases) + assert first == second + assert serialize_conformance_report(first) == serialize_conformance_report(second) + + +@pytest.mark.parametrize("cases", [[], (), "cases", object()]) +def test_harness_rejects_nonexact_or_empty_case_collections_code_only(cases) -> None: + pep, _, _ = _pep_and_cases() + with pytest.raises(ConformanceError, match="CONF-CASES-MALFORMED") as caught: + run_adapter_conformance(pep, cases=cases) + assert "secret" not in repr(caught.value) + + +def test_duplicate_case_names_are_rejected() -> None: + pep, cases, _ = _pep_and_cases() + with pytest.raises(ConformanceError, match="CONF-CASES-MALFORMED"): + run_adapter_conformance(pep, cases=(cases[0], cases[0])) + + +def test_case_rejects_nonbytes_or_empty_planted_secret() -> None: + _, cases, _ = _pep_and_cases() + with pytest.raises(ConformanceError, match="CONF-CASE-MALFORMED"): + AdapterConformanceCaseV1( + name="bad-secret", + request=cases[0].request, + runtime=cases[0].runtime, + effect=cases[0].effect, + expected_code=EnforcementCodeV1.EXECUTED, + expected_invoked=True, + planted_secrets=(b"",), + ) + + +def test_harness_module_has_no_network_clock_or_environment_imports() -> None: + path = Path(__file__).parents[1] / "src/mcp_warden/adapter_conformance.py" + tree = ast.parse(path.read_text()) + forbidden = {"requests", "httpx", "socket", "urllib", "time", "datetime", "os"} + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module.split(".")[0]) + assert not imports & forbidden + + +def test_conformance_serializer_rejects_self_digest_forgery() -> None: + pep, cases, _ = _pep_and_cases() + report = run_adapter_conformance(pep, cases=cases) + forged = report.model_copy(update={"passed": False}) + with pytest.raises(ConformanceError, match="CONF-REPORT-INTEGRITY"): + serialize_conformance_report(forged) + + +def test_conformance_report_validation_is_code_only_and_frozen() -> None: + with pytest.raises(ConformanceError, match="CONF-REPORT-MALFORMED") as caught: + ConformanceReportV1( + schema_version=1, + corpus_version="dse716-foundation-v1", + manifest_digest="secret-invalid-digest", + total_cases=1, + caller_cases=1, + fixed_cases=0, + instrumented_cases=1, + registration_operations=(), + passed=True, + covered_operations=(), + failures=(), + report_digest="secret-invalid-digest", + ) + assert "secret" not in str(caught.value) + assert caught.value.__context__ is None + + pep, cases, _ = _pep_and_cases() + report = run_adapter_conformance(pep, cases=cases) + with pytest.raises(ConformanceError, match="CONF-REPORT-MALFORMED"): + del report.report_digest # type: ignore[misc] diff --git a/tests/test_policy_decision.py b/tests/test_policy_decision.py new file mode 100644 index 0000000..1a1f043 --- /dev/null +++ b/tests/test_policy_decision.py @@ -0,0 +1,696 @@ +from __future__ import annotations + +import ast +from dataclasses import replace +from pathlib import Path + +import pytest + +from mcp_warden.content_envelope import create_ingress +from mcp_warden.content_models import ( + IngressKindV1, + MediaTypeV1, + TaintV1, + TransformKindV1, +) +from mcp_warden.decision_models import ( + MAX_GRANTS, + MAX_SIGNATURE_BYTES, + ArtifactKindV1, + AuthorityHealthV1, + CapabilityV1, + DecisionDigestDomain, + DecisionError, + DecisionReasonV1, + DecisionRecoveryV1, + DecisionVerdictV1, + IdentityBindingV1, + OperationBindingV1, + PolicyBundleV1, + PolicyGrantV1, + RuntimeSnapshotV1, + SignedPolicyCandidateV1, + SignedRuntimeCandidateV1, + VerificationAlgorithmV1, + digest_decision_bytes, +) +from mcp_warden.policy_decision import ( + ActivatedPolicyV1, + PolicyDecisionPointV1, + activate_policy, + activate_runtime, + canonical_policy_bytes, + canonical_runtime_bytes, + compute_request_binding_digest, + create_capability_lease, + create_decision_request, + serialize_decision, +) + + +def _digest(label: str, domain: DecisionDigestDomain = DecisionDigestDomain.CLAIM) -> str: + return digest_decision_bytes(label.encode("ascii"), domain=domain) + + +class RecordingVerifier: + def __init__(self, *, accept: bool = True, raises: bool = False) -> None: + self.accept = accept + self.raises = raises + self.calls: list[tuple[object, ...]] = [] + + def verify( + self, + *, + artifact_kind: ArtifactKindV1, + algorithm: VerificationAlgorithmV1, + signer_identity: str, + payload: bytes, + signature: bytes, + ) -> bool: + self.calls.append((artifact_kind, algorithm, signer_identity, payload, signature)) + if self.raises: + raise RuntimeError("secret verifier detail") + return self.accept and signature == b"valid-signature" + + +def _envelope(*, taints: tuple[TaintV1, ...] = ()): + return create_ingress( + content=b'{"safe":true}', + media_type=MediaTypeV1.APPLICATION_JSON, + source_kind=IngressKindV1.TOOL_RESULT, + source_identity=b'{"server":"fixture"}', + source_claims=b'{"tool":"read"}', + capture_kind=TransformKindV1.INGRESS_CAPTURE, + capture_implementation=b'{"name":"fixture","version":"1"}', + capture_parameters=b'{"mode":"exact"}', + added_taints=taints, + ) + + +def _identity() -> IdentityBindingV1: + return IdentityBindingV1( + user_digest=_digest("user"), + agent_digest=_digest("agent"), + device_digest=_digest("device"), + session_digest=_digest("session"), + ) + + +def _operation() -> OperationBindingV1: + return OperationBindingV1( + adapter_id="fixture.adapter", + adapter_manifest_digest=_digest("adapter-manifest"), + operation_id="document.read", + capability=CapabilityV1.READ.value, + arguments_digest=_digest("arguments", DecisionDigestDomain.EFFECT_ARGUMENTS), + destination_digest=_digest("destination"), + bundle_manifest_digest=None, + ) + + +def _policy_bundle(*, lease_digest: str | None, revoked: tuple[str, ...] = ()) -> PolicyBundleV1: + grants = () if lease_digest is None else (PolicyGrantV1(lease_digest=lease_digest),) + return PolicyBundleV1( + schema_version=1, + policy_id=_digest("policy-id", DecisionDigestDomain.POLICY_ID), + policy_generation=7, + valid_from=100, + valid_until=500, + rule_set_digest=_digest("rules", DecisionDigestDomain.RULE_SET), + trust_root_digest=_digest("trust-root", DecisionDigestDomain.TRUST_ROOT), + critical_floor_version="atk-critical-v1", + revocation_generation=3, + grants=grants, + revoked_lease_digests=revoked, + ) + + +def _activate_policy(bundle: PolicyBundleV1): + verifier = RecordingVerifier() + candidate = SignedPolicyCandidateV1( + policy=bundle, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("policy-signer"), + signature=b"valid-signature", + ) + active = activate_policy(candidate, verifier=verifier) + return active, verifier + + +def _activate_runtime(active_policy, *, now: int = 200, health=AuthorityHealthV1.HEALTHY): + runtime = RuntimeSnapshotV1( + schema_version=1, + health=health.value, + trusted_time=now, + trusted_time_valid_until=300, + policy_generation_floor=7, + policy_digest_at_floor=active_policy.policy_digest, + revocation_generation_floor=3, + revocation_digest_at_floor=active_policy.revocation_digest, + ) + verifier = RecordingVerifier() + candidate = SignedRuntimeCandidateV1( + runtime=runtime, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("runtime-signer"), + signature=b"valid-signature", + ) + return activate_runtime(candidate, verifier=verifier), verifier + + +def _request_and_policy(*, taints: tuple[TaintV1, ...] = (), grant: bool = True): + envelope = _envelope(taints=taints) + identity = _identity() + operation = _operation() + purpose = _digest("purpose", DecisionDigestDomain.PURPOSE) + scope = _digest("scope", DecisionDigestDomain.DATA_SCOPE) + policy_id = _digest("policy-id", DecisionDigestDomain.POLICY_ID) + binding = compute_request_binding_digest( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + ) + lease = create_capability_lease( + lease_id=_digest("lease-id", DecisionDigestDomain.LEASE_ID), + request_binding_digest=binding, + policy_id=policy_id, + policy_generation=7, + not_before=150, + expires_at=250, + revocation_generation=3, + ) + bundle = _policy_bundle(lease_digest=lease.lease_digest if grant else None) + active_policy, policy_verifier = _activate_policy(bundle) + active_runtime, runtime_verifier = _activate_runtime(active_policy) + request = create_decision_request( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + lease=lease, + ) + return request, active_policy, active_runtime, policy_verifier, runtime_verifier + + +def test_activation_verifies_exact_canonical_payload_once() -> None: + request, active_policy, active_runtime, policy_verifier, runtime_verifier = ( + _request_and_policy() + ) + assert request.schema_version == 1 + assert len(policy_verifier.calls) == 1 + assert policy_verifier.calls[0][0] is ArtifactKindV1.POLICY + assert policy_verifier.calls[0][3] == canonical_policy_bytes(active_policy.policy) + assert len(runtime_verifier.calls) == 1 + assert runtime_verifier.calls[0][0] is ArtifactKindV1.RUNTIME + assert runtime_verifier.calls[0][3] == canonical_runtime_bytes(active_runtime.runtime) + + +@pytest.mark.parametrize("accept,raises", [(False, False), (True, True)]) +def test_failed_candidate_activation_is_code_only_and_returns_no_replacement( + accept: bool, raises: bool +) -> None: + bundle = _policy_bundle(lease_digest=None) + candidate = SignedPolicyCandidateV1( + policy=bundle, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + verifier = RecordingVerifier(accept=accept, raises=raises) + with pytest.raises(DecisionError, match="PDP-POLICY-VERIFICATION") as caught: + activate_policy(candidate, verifier=verifier) + assert str(caught.value) == "PDP-POLICY-VERIFICATION" + assert "secret" not in repr(caught.value) + + +def test_identical_explicit_inputs_produce_byte_identical_allow_decision() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + pdp = PolicyDecisionPointV1(active_policy) + first = pdp.evaluate(request, runtime=active_runtime) + second = pdp.evaluate(request, runtime=active_runtime) + assert first == second + assert serialize_decision(first) == serialize_decision(second) + assert first.verdict == DecisionVerdictV1.ALLOW.value + assert first.reason == DecisionReasonV1.ALLOW_EXACT_GRANT.value + assert first.recovery == DecisionRecoveryV1.NONE.value + + +def test_no_exact_policy_grant_denies_by_default() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy(grant=False) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.DENY_DEFAULT.value + assert decision.recovery == DecisionRecoveryV1.OBTAIN_NEW_LEASE.value + + +@pytest.mark.parametrize( + "field", + ["user_digest", "agent_digest", "device_digest", "session_digest"], +) +def test_every_identity_dimension_is_bound(field: str) -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + forged_identity = request.identity.model_copy(update={field: _digest(f"other-{field}")}) + forged = request.model_copy(update={"identity": forged_identity}) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.REQUEST_INTEGRITY.value + + +@pytest.mark.parametrize( + "field,value", + [ + ("adapter_id", "other.adapter"), + ("operation_id", "other.operation"), + ("capability", CapabilityV1.EXECUTE.value), + ("arguments_digest", _digest("other-args")), + ("destination_digest", _digest("other-destination")), + ], +) +def test_every_operation_dimension_is_bound(field: str, value: str) -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + update = {field: value} + if field == "capability" and value == CapabilityV1.EXECUTE.value: + update["bundle_manifest_digest"] = _digest("bundle-manifest") + forged_operation = request.operation.model_copy(update=update) + forged = request.model_copy(update={"operation": forged_operation}) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.REQUEST_INTEGRITY.value + + +def test_scope_purpose_and_envelope_are_bound() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + changes = ( + {"data_scope_digest": _digest("other-scope")}, + {"purpose_digest": _digest("other-purpose")}, + {"envelope": _envelope(taints=(TaintV1.SENSITIVE,))}, + ) + for change in changes: + forged = request.model_copy(update=change) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.REQUEST_INTEGRITY.value + + +@pytest.mark.parametrize( + "now,reason,recovery", + [ + (149, DecisionReasonV1.LEASE_NOT_YET_VALID, DecisionRecoveryV1.OBTAIN_NEW_LEASE), + (250, DecisionReasonV1.LEASE_EXPIRED, DecisionRecoveryV1.OBTAIN_NEW_LEASE), + (251, DecisionReasonV1.LEASE_EXPIRED, DecisionRecoveryV1.OBTAIN_NEW_LEASE), + (300, DecisionReasonV1.TRUSTED_TIME_STALE, DecisionRecoveryV1.REFRESH_AUTHORITY), + (301, DecisionReasonV1.TRUSTED_TIME_STALE, DecisionRecoveryV1.REFRESH_AUTHORITY), + ], +) +def test_time_failures_deny_with_stable_recovery(now, reason, recovery) -> None: + request, active_policy, _, _, _ = _request_and_policy() + active_runtime, _ = _activate_runtime(active_policy, now=now) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == reason.value + assert decision.recovery == recovery.value + + +def test_revoked_lease_denies() -> None: + request, _, _, _, _ = _request_and_policy() + active_policy, _ = _activate_policy( + _policy_bundle( + lease_digest=request.lease.lease_digest, revoked=(request.lease.lease_digest,) + ) + ) + active_runtime, _ = _activate_runtime(active_policy) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=active_runtime) + assert decision.reason == DecisionReasonV1.LEASE_REVOKED.value + assert decision.recovery == DecisionRecoveryV1.OBTAIN_NEW_LEASE.value + + +@pytest.mark.parametrize( + "taint,reason", + [ + (TaintV1.CRITICAL, DecisionReasonV1.CRITICAL_TAINT), + (TaintV1.AUTHORITY_INJECTION, DecisionReasonV1.CRITICAL_TAINT), + (TaintV1.MALFORMED, DecisionReasonV1.UNINSPECTABLE_DATA), + (TaintV1.UNINSPECTABLE, DecisionReasonV1.UNINSPECTABLE_DATA), + ], +) +def test_mandatory_critical_floor_cannot_be_granted(taint, reason) -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy(taints=(taint,)) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=active_runtime) + assert decision.verdict == DecisionVerdictV1.QUARANTINE.value + assert decision.reason == reason.value + assert decision.recovery == DecisionRecoveryV1.QUARANTINE_INPUT.value + + +@pytest.mark.parametrize( + "capability", + [CapabilityV1.ADMINISTER_POLICY, CapabilityV1.REPAIR_RECOVERY], +) +def test_normal_pdp_never_grants_privileged_activation_or_recovery_paths(capability) -> None: + request, _, _, _, _ = _request_and_policy() + operation = request.operation.model_copy(update={"capability": capability.value}) + binding = compute_request_binding_digest( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=operation, + purpose_digest=request.purpose_digest, + ) + lease = create_capability_lease( + lease_id=request.lease.lease_id, + request_binding_digest=binding, + policy_id=request.lease.policy_id, + policy_generation=request.lease.policy_generation, + not_before=request.lease.not_before, + expires_at=request.lease.expires_at, + revocation_generation=request.lease.revocation_generation, + ) + active_policy, _ = _activate_policy(_policy_bundle(lease_digest=lease.lease_digest)) + runtime, _ = _activate_runtime(active_policy) + privileged = create_decision_request( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=operation, + purpose_digest=request.purpose_digest, + lease=lease, + ) + decision = PolicyDecisionPointV1(active_policy).evaluate(privileged, runtime=runtime) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.PRIVILEGED_PATH_REQUIRED.value + + +def test_untrusted_executable_content_cannot_be_granted_execute_authority() -> None: + request, _, _, _, _ = _request_and_policy(taints=(TaintV1.EXECUTABLE,)) + operation = request.operation.model_copy( + update={ + "capability": CapabilityV1.EXECUTE.value, + "bundle_manifest_digest": _digest("bundle-manifest"), + } + ) + binding = compute_request_binding_digest( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=operation, + purpose_digest=request.purpose_digest, + ) + lease = create_capability_lease( + lease_id=request.lease.lease_id, + request_binding_digest=binding, + policy_id=request.lease.policy_id, + policy_generation=request.lease.policy_generation, + not_before=request.lease.not_before, + expires_at=request.lease.expires_at, + revocation_generation=request.lease.revocation_generation, + ) + active_policy, _ = _activate_policy(_policy_bundle(lease_digest=lease.lease_digest)) + runtime, _ = _activate_runtime(active_policy) + executable = create_decision_request( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=operation, + purpose_digest=request.purpose_digest, + lease=lease, + ) + decision = PolicyDecisionPointV1(active_policy).evaluate(executable, runtime=runtime) + assert decision.verdict == DecisionVerdictV1.QUARANTINE.value + assert decision.reason == DecisionReasonV1.EXECUTABLE_CONTENT.value + + +def test_recovery_only_runtime_denies_all_normal_requests() -> None: + request, active_policy, _, _, _ = _request_and_policy() + runtime, _ = _activate_runtime(active_policy, health=AuthorityHealthV1.RECOVERY_ONLY) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=runtime) + assert decision.reason == DecisionReasonV1.RECOVERY_ONLY.value + assert decision.recovery == DecisionRecoveryV1.RECOVERY_ONLY.value + + +def test_runtime_policy_floor_mismatch_denies_as_rollback() -> None: + request, active_policy, runtime, _, _ = _request_and_policy() + forged_runtime = runtime.runtime.model_copy( + update={"policy_digest_at_floor": _digest("other-policy")} + ) + candidate = SignedRuntimeCandidateV1( + runtime=forged_runtime, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("runtime-signer"), + signature=b"valid-signature", + ) + activated = activate_runtime(candidate, verifier=RecordingVerifier()) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=activated) + assert decision.reason == DecisionReasonV1.POLICY_ROLLBACK.value + assert decision.recovery == DecisionRecoveryV1.RECOVERY_ONLY.value + + +def test_hostile_or_constructed_inputs_fail_closed_without_echo() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + + class HostileRequest(type(request)): + pass + + hostile = HostileRequest.model_construct(**request.model_dump()) + incomplete = type(request).model_construct(schema_version=1) + pdp = PolicyDecisionPointV1(active_policy) + for candidate in (hostile, incomplete, object()): + decision = pdp.evaluate(candidate, runtime=active_runtime) + encoded = serialize_decision(decision) + assert decision.verdict == DecisionVerdictV1.DENY.value + assert decision.reason == DecisionReasonV1.INPUT_MALFORMED.value + assert b"secret" not in encoded + + malformed_digest = request.model_copy( + update={ + "envelope": request.envelope.model_copy(update={"trust_state": "trusted"}), + "request_digest": "secret-invalid-digest", + } + ) + decision = pdp.evaluate(malformed_digest, runtime=active_runtime) + encoded = serialize_decision(decision) + assert decision.reason == DecisionReasonV1.INPUT_MALFORMED.value + assert b"secret" not in encoded + + +def test_models_are_strict_frozen_and_unknown_fields_fail_code_only() -> None: + with pytest.raises(DecisionError) as caught: + IdentityBindingV1( + user_digest=_digest("user"), + agent_digest=_digest("agent"), + device_digest=_digest("device"), + session_digest=_digest("session"), + surprise="secret", # type: ignore[call-arg] + ) + assert "secret" not in str(caught.value) + assert caught.value.__context__ is None + identity = _identity() + with pytest.raises((DecisionError, TypeError)): + identity.user_digest = _digest("other") # type: ignore[misc] + + +def test_candidate_dataclasses_reject_wrong_exact_types() -> None: + with pytest.raises(DecisionError, match="PDP-CANDIDATE-MALFORMED"): + SignedPolicyCandidateV1( + policy=_policy_bundle(lease_digest=None), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=bytearray(b"valid-signature"), # type: ignore[arg-type] + ) + + +def test_decision_modules_have_no_network_clock_environment_or_legacy_canon_imports() -> None: + root = Path(__file__).parents[1] / "src/mcp_warden" + forbidden_imports = {"requests", "httpx", "socket", "urllib", "time", "datetime", "os"} + for name in ("decision_models.py", "policy_decision.py"): + tree = ast.parse((root / name).read_text()) + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module.split(".")[0]) + assert not (imports & forbidden_imports) + source = (root / name).read_text() + assert "hash_value(" not in source + assert "canon(" not in source + + +def test_request_copy_without_recomputed_digest_is_rejected_before_lease_checks() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + forged = ( + replace(request, purpose_digest=_digest("forged")) + if hasattr(request, "__dataclass_fields__") + else request.model_copy(update={"purpose_digest": _digest("forged")}) + ) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=active_runtime) + assert decision.reason == DecisionReasonV1.REQUEST_INTEGRITY.value + + +def test_forged_activated_policy_without_private_activation_marker_is_rejected() -> None: + _, active_policy, _, _, _ = _request_and_policy() + forged = object.__new__(ActivatedPolicyV1) + object.__setattr__(forged, "policy", active_policy.policy) + object.__setattr__(forged, "policy_digest", active_policy.policy_digest) + object.__setattr__(forged, "revocation_digest", active_policy.revocation_digest) + object.__setattr__(forged, "_locked", True) + with pytest.raises(DecisionError, match="PDP-AUTHORITY-UNAVAILABLE"): + PolicyDecisionPointV1(forged) + + +def test_forged_activated_runtime_without_private_activation_marker_denies() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + forged = object.__new__(type(active_runtime)) + object.__setattr__(forged, "runtime", active_runtime.runtime) + object.__setattr__(forged, "runtime_digest", active_runtime.runtime_digest) + object.__setattr__(forged, "_locked", True) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=forged) + assert decision.reason == DecisionReasonV1.AUTHORITY_UNAVAILABLE.value + + +def test_invalid_envelope_quarantines_before_stale_request_digest() -> None: + request, active_policy, runtime, _, _ = _request_and_policy() + invalid_envelope = request.envelope.model_copy(update={"trust_state": "trusted"}) + forged = request.model_copy(update={"envelope": invalid_envelope}) + decision = PolicyDecisionPointV1(active_policy).evaluate(forged, runtime=runtime) + assert decision.verdict == DecisionVerdictV1.QUARANTINE.value + assert decision.reason == DecisionReasonV1.ENVELOPE_INVALID.value + + +def test_policy_validity_and_revocation_floor_fail_closed() -> None: + request, active_policy, _, _, _ = _request_and_policy() + for now, runtime_change, expected in ( + (99, {}, DecisionReasonV1.POLICY_STALE), + (500, {}, DecisionReasonV1.POLICY_STALE), + ( + 200, + { + "revocation_digest_at_floor": _digest("wrong-revocation"), + }, + DecisionReasonV1.POLICY_ROLLBACK, + ), + ): + runtime_model = RuntimeSnapshotV1( + schema_version=1, + health=AuthorityHealthV1.HEALTHY.value, + trusted_time=now, + trusted_time_valid_until=600, + policy_generation_floor=7, + policy_digest_at_floor=active_policy.policy_digest, + revocation_generation_floor=3, + revocation_digest_at_floor=active_policy.revocation_digest, + ).model_copy(update=runtime_change) + activated = activate_runtime( + SignedRuntimeCandidateV1( + runtime=runtime_model, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("runtime-signer"), + signature=b"valid-signature", + ), + verifier=RecordingVerifier(), + ) + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=activated) + assert decision.reason == expected.value + + +def test_lease_policy_and_future_revocation_generation_mismatch_deny() -> None: + request, _, _, _, _ = _request_and_policy() + for policy_id, revocation_generation, expected_recovery in ( + (_digest("other-policy"), 3, DecisionRecoveryV1.OBTAIN_NEW_LEASE), + (request.lease.policy_id, 4, DecisionRecoveryV1.REFRESH_AUTHORITY), + ): + binding = compute_request_binding_digest( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=request.operation, + purpose_digest=request.purpose_digest, + ) + lease = create_capability_lease( + lease_id=request.lease.lease_id, + request_binding_digest=binding, + policy_id=policy_id, + policy_generation=7, + not_before=150, + expires_at=250, + revocation_generation=revocation_generation, + ) + active_policy, _ = _activate_policy(_policy_bundle(lease_digest=lease.lease_digest)) + runtime, _ = _activate_runtime(active_policy) + candidate = create_decision_request( + identity=request.identity, + envelope=request.envelope, + data_scope_digest=request.data_scope_digest, + operation=request.operation, + purpose_digest=request.purpose_digest, + lease=lease, + ) + decision = PolicyDecisionPointV1(active_policy).evaluate(candidate, runtime=runtime) + assert decision.reason == DecisionReasonV1.POLICY_BINDING.value + assert decision.recovery == expected_recovery.value + + +def test_pdp_authority_cannot_be_replaced_after_construction() -> None: + _, active_policy, _, _, _ = _request_and_policy() + pdp = PolicyDecisionPointV1(active_policy) + with pytest.raises(DecisionError, match="PDP-AUTHORITY-IMMUTABLE"): + pdp._policy = active_policy # type: ignore[misc] + with pytest.raises(DecisionError, match="PDP-AUTHORITY-IMMUTABLE"): + del pdp._policy # type: ignore[misc] + with pytest.raises(DecisionError, match="PDP-AUTHORITY-IMMUTABLE"): + del active_policy.policy # type: ignore[misc] + + +def test_decision_serializer_rejects_self_digest_forgery() -> None: + request, active_policy, active_runtime, _, _ = _request_and_policy() + decision = PolicyDecisionPointV1(active_policy).evaluate(request, runtime=active_runtime) + forged = decision.model_copy(update={"reason": DecisionReasonV1.DENY_DEFAULT.value}) + with pytest.raises(DecisionError, match="PDP-DECISION-INTEGRITY"): + serialize_decision(forged) + + +def test_policy_grant_and_signature_caps_accept_cap_and_reject_cap_plus_one() -> None: + grants = tuple( + sorted( + ( + PolicyGrantV1(lease_digest=_digest(f"lease-cap-{index}")) + for index in range(MAX_GRANTS) + ), + key=lambda item: item.lease_digest, + ) + ) + bundle = PolicyBundleV1( + schema_version=1, + policy_id=_digest("policy-id", DecisionDigestDomain.POLICY_ID), + policy_generation=7, + valid_from=100, + valid_until=500, + rule_set_digest=_digest("rules", DecisionDigestDomain.RULE_SET), + trust_root_digest=_digest("root", DecisionDigestDomain.TRUST_ROOT), + critical_floor_version="atk-critical-v1", + revocation_generation=3, + grants=grants, + revoked_lease_digests=(), + ) + assert len(canonical_policy_bytes(bundle)) > 0 + SignedPolicyCandidateV1( + policy=bundle, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"x" * MAX_SIGNATURE_BYTES, + ) + with pytest.raises(DecisionError, match="PDP-POLICY-MALFORMED"): + PolicyBundleV1( + **( + bundle.model_dump() + | {"grants": grants + (PolicyGrantV1(lease_digest=_digest("plus-one")),)} + ) + ) + with pytest.raises(DecisionError, match="PDP-CANDIDATE-MALFORMED"): + SignedPolicyCandidateV1( + policy=bundle, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"x" * (MAX_SIGNATURE_BYTES + 1), + ) diff --git a/tests/test_policy_enforcement.py b/tests/test_policy_enforcement.py new file mode 100644 index 0000000..fce07e2 --- /dev/null +++ b/tests/test_policy_enforcement.py @@ -0,0 +1,1041 @@ +from __future__ import annotations + +import ast +import inspect +from pathlib import Path + +import pytest + +from mcp_warden.content_envelope import create_ingress +from mcp_warden.content_models import ( + BundleEvidenceInput, + IngressKindV1, + MediaTypeV1, + TransformKindV1, +) +from mcp_warden.decision_models import ( + ArtifactKindV1, + AuthorityHealthV1, + CapabilityV1, + DecisionDigestDomain, + IdentityBindingV1, + OperationBindingV1, + PolicyBundleV1, + PolicyGrantV1, + RuntimeSnapshotV1, + SignedPolicyCandidateV1, + SignedRuntimeCandidateV1, + VerificationAlgorithmV1, + digest_decision_bytes, +) +from mcp_warden.executable_bundle import ( + ExecutableBundleManifestV1, + SignedExecutableBundleCandidateV1, + activate_executable_bundle, + bundle_evidence_from_input, + canonical_bundle_manifest_bytes, +) +from mcp_warden.policy_decision import ( + PolicyDecisionPointV1, + activate_policy, + activate_runtime, + compute_request_binding_digest, + create_capability_lease, + create_decision_request, +) +from mcp_warden.policy_enforcement import ( + MAX_ADAPTER_DEPENDENCIES, + MAX_EFFECT_BYTES, + MAX_MANIFEST_OPERATIONS, + AdapterManifestV1, + AdapterRegistryV1, + EffectOutcomeV1, + EnforcementCodeV1, + EnforcementError, + ManifestOperationV1, + PolicyEnforcementPointV1, + SignedAdapterCandidateV1, + activate_adapter, + canonical_manifest_bytes, + create_effect_input, + create_evidence_result, + digest_adapter_dependency, + digest_adapter_implementation, + digest_handler_implementation, + serialize_enforcement_result, +) + + +def _digest(label: str, domain: DecisionDigestDomain = DecisionDigestDomain.CLAIM) -> str: + return digest_decision_bytes(label.encode("ascii"), domain=domain) + + +class Verifier: + def __init__(self, *, accept: bool = True) -> None: + self.accept = accept + self.calls: list[tuple[object, ...]] = [] + + def verify(self, **kwargs) -> bool: + self.calls.append(tuple(kwargs.values())) + return self.accept and kwargs["signature"] == b"valid-signature" + + +def _envelope(): + return create_ingress( + content=b'{"safe":true}', + media_type=MediaTypeV1.APPLICATION_JSON, + source_kind=IngressKindV1.TOOL_RESULT, + source_identity=b'{"server":"fixture"}', + source_claims=b'{"tool":"read"}', + capture_kind=TransformKindV1.INGRESS_CAPTURE, + capture_implementation=b'{"name":"fixture","version":"1"}', + capture_parameters=b'{"mode":"exact"}', + ) + + +def _noop_handler(_: bytes) -> None: + return None + + +def _different_handler(_: bytes) -> None: + return None + + +def _original_code_handler(_: bytes) -> None: + return None + + +def _drifted_code_handler(_: bytes) -> bytes: + return b"drifted" + + +def _raw_output_handler(_: bytes) -> bytes: + return b"secret raw output" + + +def _raising_handler(_: bytes): + return 1 // 0 + + +_MUTABLE_DELEGATES = [_noop_handler] + + +def _mutable_global_delegate_handler(value: bytes): + return _MUTABLE_DELEGATES[0](value) + + +_NESTED_DELEGATES = [_noop_handler] + + +def _nested_mutable_delegate_handler(value: bytes): + return (lambda: _NESTED_DELEGATES[0](value))() + + +def _active_components( + *, + grant: bool = True, + operation_id: str = "document.read", + handler=_noop_handler, +): + effect = create_effect_input(b'{"document_id":"42"}') + identity = IdentityBindingV1( + user_digest=_digest("user"), + agent_digest=_digest("agent"), + device_digest=_digest("device"), + session_digest=_digest("session"), + ) + policy_id = _digest("policy-id", DecisionDigestDomain.POLICY_ID) + manifest = _manifest(handler, policy_id=policy_id) + operation = OperationBindingV1( + adapter_id="fixture.adapter", + adapter_manifest_digest=digest_decision_bytes( + canonical_manifest_bytes(manifest), domain=DecisionDigestDomain.ADAPTER_MANIFEST + ), + operation_id=operation_id, + capability=CapabilityV1.READ.value, + arguments_digest=effect.arguments_digest, + destination_digest=_digest("destination"), + bundle_manifest_digest=None, + ) + envelope = _envelope() + purpose = _digest("purpose", DecisionDigestDomain.PURPOSE) + scope = _digest("scope", DecisionDigestDomain.DATA_SCOPE) + binding = compute_request_binding_digest( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + ) + lease = create_capability_lease( + lease_id=_digest("lease", DecisionDigestDomain.LEASE_ID), + request_binding_digest=binding, + policy_id=policy_id, + policy_generation=7, + not_before=100, + expires_at=300, + revocation_generation=3, + ) + grants = (PolicyGrantV1(lease_digest=lease.lease_digest),) if grant else () + policy = PolicyBundleV1( + schema_version=1, + policy_id=policy_id, + policy_generation=7, + valid_from=50, + valid_until=400, + rule_set_digest=_digest("rules", DecisionDigestDomain.RULE_SET), + trust_root_digest=_digest("root", DecisionDigestDomain.TRUST_ROOT), + critical_floor_version="atk-critical-v1", + revocation_generation=3, + grants=grants, + revoked_lease_digests=(), + ) + policy_candidate = SignedPolicyCandidateV1( + policy=policy, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("policy-signer"), + signature=b"valid-signature", + ) + active_policy = activate_policy(policy_candidate, verifier=Verifier()) + runtime = RuntimeSnapshotV1( + schema_version=1, + health=AuthorityHealthV1.HEALTHY.value, + trusted_time=200, + trusted_time_valid_until=250, + policy_generation_floor=7, + policy_digest_at_floor=active_policy.policy_digest, + revocation_generation_floor=3, + revocation_digest_at_floor=active_policy.revocation_digest, + ) + active_runtime = activate_runtime( + SignedRuntimeCandidateV1( + runtime=runtime, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("runtime-signer"), + signature=b"valid-signature", + ), + verifier=Verifier(), + ) + request = create_decision_request( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + lease=lease, + ) + return effect, request, active_policy, active_runtime + + +def _manifest(handler, *, policy_id: str) -> AdapterManifestV1: + adapter_implementation = b"fixture-adapter-binary" + dependencies = (b"dependency-a", b"dependency-b") + return AdapterManifestV1( + schema_version=1, + adapter_id="fixture.adapter", + adapter_version="1.0.0", + implementation_digest=digest_adapter_implementation(adapter_implementation), + dependency_digests=tuple(sorted(digest_adapter_dependency(item) for item in dependencies)), + policy_id=policy_id, + policy_generation=7, + operations=( + ManifestOperationV1( + operation_id="document.read", + capability=CapabilityV1.READ.value, + handler_digest=digest_handler_implementation(handler), + ), + ), + ) + + +def _activated_adapter(active_policy, handler): + manifest = _manifest(handler, policy_id=active_policy.policy.policy_id) + registry = AdapterRegistryV1() + registry.register( + operation_id="document.read", + handler=handler, + ) + verifier = Verifier() + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("adapter-signer"), + signature=b"valid-signature", + ) + activated = activate_adapter( + candidate, + registry=registry, + verifier=verifier, + policy=active_policy, + ) + return activated, registry, verifier + + +class PermitGate: + def __init__(self, trace: list[str], *, mismatch: str | None = None, raises=False) -> None: + self.trace = trace + self.mismatch = mismatch + self.raises = raises + self.calls = 0 + + def record(self, *, decision, manifest_digest: str): + self.calls += 1 + self.trace.append("evidence") + if self.raises: + raise RuntimeError("secret evidence detail") + result = create_evidence_result(decision=decision, manifest_digest=manifest_digest) + if self.mismatch: + return result.model_copy(update={self.mismatch: _digest("forged")}) + return result + + +def test_adapter_activation_verifies_exact_manifest_and_freezes_registry() -> None: + _, _, active_policy, _ = _active_components() + activated, registry, verifier = _activated_adapter(active_policy, _noop_handler) + assert len(verifier.calls) == 1 + assert verifier.calls[0][0] is ArtifactKindV1.ADAPTER + assert verifier.calls[0][3] == canonical_manifest_bytes(activated.manifest) + with pytest.raises(EnforcementError, match="PEP-REGISTRY-FROZEN"): + registry.register( + operation_id="other", + handler=_noop_handler, + ) + + +@pytest.mark.parametrize("mode", ["missing", "extra", "digest"]) +def test_manifest_registry_mismatch_fails_activation(mode: str) -> None: + _, _, active_policy, _ = _active_components() + manifest = _manifest(_noop_handler, policy_id=active_policy.policy.policy_id) + registry = AdapterRegistryV1() + if mode != "missing": + registry.register( + operation_id="document.read" if mode == "digest" else "other.operation", + handler=_different_handler if mode == "digest" else _noop_handler, + ) + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + with pytest.raises(EnforcementError, match="PEP-MANIFEST-REGISTRY-MISMATCH"): + activate_adapter(candidate, registry=registry, verifier=Verifier(), policy=active_policy) + + +def test_adapter_policy_binding_mismatch_fails_activation() -> None: + _, _, active_policy, _ = _active_components() + manifest = _manifest(_noop_handler, policy_id=_digest("other-policy")) + registry = AdapterRegistryV1() + registry.register(operation_id="document.read", handler=_noop_handler) + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + with pytest.raises(EnforcementError, match="PEP-POLICY-BINDING"): + activate_adapter(candidate, registry=registry, verifier=Verifier(), policy=active_policy) + + +def test_bad_adapter_signature_returns_code_only_failure() -> None: + _, _, active_policy, _ = _active_components() + manifest = _manifest(_noop_handler, policy_id=active_policy.policy.policy_id) + registry = AdapterRegistryV1() + registry.register(operation_id="document.read", handler=_noop_handler) + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + with pytest.raises(EnforcementError, match="PEP-ADAPTER-VERIFICATION") as caught: + activate_adapter( + candidate, registry=registry, verifier=Verifier(accept=False), policy=active_policy + ) + assert "secret" not in repr(caught.value) + + +def test_adapter_verifier_exception_has_no_retained_secret_context() -> None: + _, _, active_policy, _ = _active_components() + manifest = _manifest(_noop_handler, policy_id=active_policy.policy.policy_id) + registry = AdapterRegistryV1() + registry.register(operation_id="document.read", handler=_noop_handler) + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + + class RaisingVerifier: + def verify(self, **_): + raise RuntimeError("secret verifier detail") + + with pytest.raises(EnforcementError, match="PEP-ADAPTER-VERIFICATION") as caught: + activate_adapter( + candidate, registry=registry, verifier=RaisingVerifier(), policy=active_policy + ) + assert caught.value.__context__ is None + + +def test_default_evidence_gate_blocks_even_an_allow_decision() -> None: + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + pep = PolicyEnforcementPointV1(PolicyDecisionPointV1(active_policy), adapter) + result = pep.execute(request, runtime=runtime, effect=effect) + assert result.invoked is False + assert result.code == EnforcementCodeV1.EVIDENCE_UNAVAILABLE.value + + +def test_allow_order_is_evidence_then_sink_and_output_is_enveloped() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + gate = PermitGate(trace) + pep = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=gate + ) + result, instrumentation = pep._execute_instrumented(request, runtime=runtime, effect=effect) + assert trace == ["evidence"] + assert instrumentation.events.index("decision") < instrumentation.events.index("evidence") + assert instrumentation.events.index("evidence") < instrumentation.events.index("sink") + assert result.invoked is True + assert result.outcome == EffectOutcomeV1.COMPLETED.value + assert result.code == EnforcementCodeV1.EXECUTED.value + assert result.output is None + + +def test_deny_never_calls_evidence_or_sink() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components(grant=False) + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + gate = PermitGate(trace) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=gate + ).execute(request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.DECISION_BLOCKED.value + assert result.invoked is False + assert trace == [] + assert gate.calls == 0 + + +def test_effect_argument_substitution_is_rejected_before_evidence_or_sink() -> None: + trace: list[str] = [] + _, request, active_policy, runtime = _active_components() + forged_effect = create_effect_input(b'{"document_id":"43"}') + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + gate = PermitGate(trace) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=gate + ).execute(request, runtime=runtime, effect=forged_effect) + assert result.code == EnforcementCodeV1.EFFECT_DIGEST_MISMATCH.value + assert trace == [] + + +def test_unknown_operation_is_rejected_before_evidence_or_sink() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components(operation_id="unknown.operation") + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + gate = PermitGate(trace) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=gate + ).execute(request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.OPERATION_UNKNOWN.value + assert trace == [] + + +@pytest.mark.parametrize( + "mismatch", + ["request_digest", "decision_digest", "manifest_digest", "policy_digest"], +) +def test_evidence_result_substitution_never_reaches_sink(mismatch: str) -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + gate = PermitGate(trace, mismatch=mismatch) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=gate + ).execute(request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.EVIDENCE_MISMATCH.value + assert result.invoked is False + assert trace == ["evidence"] + + +def test_evidence_exception_is_code_only_and_never_reaches_sink() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), + adapter, + evidence_gate=PermitGate(trace, raises=True), + ).execute(request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.EVIDENCE_UNAVAILABLE.value + assert b"secret" not in serialize_enforcement_result(result) + assert trace == ["evidence"] + + +def test_sink_exception_is_indeterminate_invoked_and_not_retried() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components(handler=_raising_handler) + adapter, _, _ = _activated_adapter(active_policy, _raising_handler) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate(trace) + ).execute(request, runtime=runtime, effect=effect) + assert trace == ["evidence"] + assert result.invoked is True + assert result.outcome == EffectOutcomeV1.INDETERMINATE.value + assert result.code == EnforcementCodeV1.SINK_FAILED.value + assert b"secret" not in serialize_enforcement_result(result) + + +def test_raw_sink_output_is_rejected_without_echo() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components(handler=_raw_output_handler) + adapter, _, _ = _activated_adapter(active_policy, _raw_output_handler) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate(trace) + ).execute(request, runtime=runtime, effect=effect) + assert result.invoked is True + assert result.code == EnforcementCodeV1.OUTPUT_INVALID.value + assert b"secret" not in serialize_enforcement_result(result) + + +def test_execute_api_never_accepts_a_caller_decision() -> None: + parameters = inspect.signature(PolicyEnforcementPointV1.execute).parameters + assert "decision" not in parameters + + +def test_effect_input_rejects_noncanonical_and_over_cap_content() -> None: + with pytest.raises(EnforcementError, match="PEP-EFFECT-NONCANONICAL"): + create_effect_input(b'{"b": 2, "a": 1}') + with pytest.raises(EnforcementError, match="PEP-EFFECT-MALFORMED") as caught: + create_effect_input(b'{"secret":NaN}') + assert "secret" not in str(caught.value) + assert caught.value.__context__ is None + + +def test_direct_noncanonical_effect_dataclass_is_rejected_by_pep() -> None: + from mcp_warden.policy_enforcement import EffectInputV1 + + _, request, active_policy, runtime = _active_components() + arguments = b'{"b":2,"a":1}' + forged = EffectInputV1( + arguments=arguments, + arguments_digest=digest_decision_bytes( + arguments, domain=DecisionDigestDomain.EFFECT_ARGUMENTS + ), + ) + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate([]) + ).execute(request, runtime=runtime, effect=forged) + assert result.code == EnforcementCodeV1.EFFECT_MALFORMED.value + + +def test_hostile_constructed_request_and_effect_fail_closed_in_pep() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + pep = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate(trace) + ) + + incomplete_request = type(request).model_construct(schema_version=1) + request_result = pep.execute(incomplete_request, runtime=runtime, effect=effect) + assert request_result.code == EnforcementCodeV1.DECISION_BLOCKED.value + assert request_result.invoked is False + + hostile_operation = request.model_copy(update={"operation": object()}) + operation_result = pep.execute(hostile_operation, runtime=runtime, effect=effect) + assert operation_result.code == EnforcementCodeV1.DECISION_BLOCKED.value + assert operation_result.invoked is False + + incomplete_effect = object.__new__(type(effect)) + effect_result = pep.execute(request, runtime=runtime, effect=incomplete_effect) + assert effect_result.code == EnforcementCodeV1.EFFECT_MALFORMED.value + assert effect_result.invoked is False + assert trace == [] + + +def test_hostile_nested_request_value_cannot_escape_pep_as_raw_exception() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + pep = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate(trace) + ) + + class HostileDigest: + def __eq__(self, _other): + raise RuntimeError("PLANTED-PEP-NESTED-SECRET-716") + + hostile_operation = OperationBindingV1.model_construct( + adapter_id="fixture.adapter", + adapter_manifest_digest=request.operation.adapter_manifest_digest, + operation_id="document.read", + capability=CapabilityV1.READ.value, + arguments_digest=HostileDigest(), + destination_digest=_digest("destination"), + bundle_manifest_digest=None, + ) + hostile_request = request.model_copy(update={"operation": hostile_operation}) + + result = pep.execute(hostile_request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.DECISION_BLOCKED.value + assert result.invoked is False + assert b"PLANTED-PEP-NESTED-SECRET-716" not in serialize_enforcement_result(result) + assert trace == [] + + +def test_hostile_constructed_evidence_fails_closed_before_sink() -> None: + trace: list[str] = [] + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + + class HostileEvidenceGate: + def record(self, **_): + from mcp_warden.policy_enforcement import EvidenceResultV1 + + return EvidenceResultV1.model_construct(schema_version=1) + + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=HostileEvidenceGate() + ).execute(request, runtime=runtime, effect=effect) + assert result.code == EnforcementCodeV1.EVIDENCE_MISMATCH.value + assert result.invoked is False + assert trace == [] + + +def test_adapter_candidate_binds_actual_implementation_and_dependencies() -> None: + _, _, active_policy, _ = _active_components() + manifest = _manifest(_noop_handler, policy_id=active_policy.policy.policy_id) + registry = AdapterRegistryV1() + registry.register(operation_id="document.read", handler=_noop_handler) + candidate = SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"substituted-adapter", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("signer"), + signature=b"valid-signature", + ) + with pytest.raises(EnforcementError, match="PEP-ADAPTER-INTEGRITY"): + activate_adapter(candidate, registry=registry, verifier=Verifier(), policy=active_policy) + + +def test_post_activation_handler_code_drift_never_executes_drifted_code() -> None: + original_code = _original_code_handler.__code__ + evidence_trace: list[str] = [] + effect, request, active_policy, runtime = _active_components(handler=_original_code_handler) + adapter, _, _ = _activated_adapter(active_policy, _original_code_handler) + try: + _original_code_handler.__code__ = _drifted_code_handler.__code__ + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), + adapter, + evidence_gate=PermitGate(evidence_trace), + ).execute(request, runtime=runtime, effect=effect) + finally: + _original_code_handler.__code__ = original_code + + assert result.code == EnforcementCodeV1.EXECUTED.value + + +def test_closure_controlled_handler_is_rejected_before_registration() -> None: + delegated = _noop_handler + + def closure_handler(value: bytes): + return delegated(value) + + registry = AdapterRegistryV1() + with pytest.raises(EnforcementError, match="PEP-HANDLER-MALFORMED"): + registry.register(operation_id="document.read", handler=closure_handler) + + +def test_mutable_global_delegate_handler_is_rejected_before_registration() -> None: + registry = AdapterRegistryV1() + with pytest.raises(EnforcementError, match="PEP-HANDLER-MALFORMED"): + registry.register(operation_id="document.read", handler=_mutable_global_delegate_handler) + + +def test_nested_code_cannot_hide_mutable_global_delegate() -> None: + registry = AdapterRegistryV1() + with pytest.raises(EnforcementError, match="PEP-HANDLER-MALFORMED"): + registry.register(operation_id="document.read", handler=_nested_mutable_delegate_handler) + + +def test_one_lease_cannot_execute_against_two_adapter_manifest_versions() -> None: + effect, request, active_policy, runtime = _active_components() + v1, _, _ = _activated_adapter(active_policy, _noop_handler) + + v2_manifest = _manifest(_noop_handler, policy_id=active_policy.policy.policy_id).model_copy( + update={"adapter_version": "2.0.0"} + ) + v2_registry = AdapterRegistryV1() + v2_registry.register(operation_id="document.read", handler=_noop_handler) + v2 = activate_adapter( + SignedAdapterCandidateV1( + manifest=v2_manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("adapter-signer"), + signature=b"valid-signature", + ), + registry=v2_registry, + verifier=Verifier(), + policy=active_policy, + ) + + v1_result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), v1, evidence_gate=PermitGate([]) + ).execute(request, runtime=runtime, effect=effect) + v2_result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), v2, evidence_gate=PermitGate([]) + ).execute(request, runtime=runtime, effect=effect) + + assert v1_result.code == EnforcementCodeV1.EXECUTED.value + assert v2_result.code == EnforcementCodeV1.ADAPTER_MISMATCH.value + + +def test_attacker_bundle_metadata_and_exact_execute_lease_do_not_authorize_load() -> None: + effect = create_effect_input(b'{"entrypoint":"main"}') + bundle_input = BundleEvidenceInput( + artifact=b'{"artifact":"attacker"}', + signature_evidence=b'{"signature":"attacker"}', + version_claims=b'{"version":"9.9.9"}', + publisher_claims=b'{"publisher":"attacker"}', + dependencies=(b'{"dependency":"attacker"}',), + policy_binding_claims=b'{"policy":"claimed"}', + ) + envelope = create_ingress( + content=b'{"bundle":"attacker-authored"}', + media_type=MediaTypeV1.APPLICATION_JSON, + source_kind=IngressKindV1.BUNDLE_METADATA, + source_identity=b'{"source":"attacker"}', + source_claims=b'{"claim":"self-signed"}', + capture_kind=TransformKindV1.INGRESS_CAPTURE, + capture_implementation=b'{"name":"fixture","version":"1"}', + capture_parameters=b'{"mode":"exact"}', + bundle=bundle_input, + ) + identity = IdentityBindingV1( + user_digest=_digest("user"), + agent_digest=_digest("agent"), + device_digest=_digest("device"), + session_digest=_digest("session"), + ) + policy_id = _digest("policy-id", DecisionDigestDomain.POLICY_ID) + bundle_handler = _noop_handler + base_manifest = _manifest(bundle_handler, policy_id=policy_id) + manifest = AdapterManifestV1( + **( + base_manifest.model_dump() + | { + "operations": ( + ManifestOperationV1( + operation_id="bundle.execute", + capability=CapabilityV1.EXECUTE.value, + handler_digest=digest_handler_implementation(bundle_handler), + ), + ) + } + ) + ) + manifest_digest = digest_decision_bytes( + canonical_manifest_bytes(manifest), domain=DecisionDigestDomain.ADAPTER_MANIFEST + ) + assert envelope.bundle is not None + bundle_publisher = _digest("attacker-bundle-publisher") + bundle_manifest = ExecutableBundleManifestV1( + schema_version=1, + bundle_id="attacker.bundle", + bundle_version="9.9.9", + publisher_identity=bundle_publisher, + artifact_digest=envelope.bundle.artifact_digest, + signature_evidence_digest=envelope.bundle.signature_evidence_digest, + version_claims_digest=envelope.bundle.version_claims_digest, + publisher_claims_digest=envelope.bundle.publisher_claims_digest, + dependency_digests=envelope.bundle.dependency_digests, + policy_binding_claims_digest=envelope.bundle.policy_binding_claims_digest, + policy_id=policy_id, + policy_generation=7, + adapter_manifest_digest=manifest_digest, + ) + bundle_manifest_digest = digest_decision_bytes( + canonical_bundle_manifest_bytes(bundle_manifest), + domain=DecisionDigestDomain.BUNDLE_MANIFEST, + ) + operation = OperationBindingV1( + adapter_id="fixture.adapter", + adapter_manifest_digest=manifest_digest, + operation_id="bundle.execute", + capability=CapabilityV1.EXECUTE.value, + arguments_digest=effect.arguments_digest, + destination_digest=_digest("destination"), + bundle_manifest_digest=bundle_manifest_digest, + ) + scope = _digest("scope", DecisionDigestDomain.DATA_SCOPE) + purpose = _digest("purpose", DecisionDigestDomain.PURPOSE) + binding = compute_request_binding_digest( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + ) + lease = create_capability_lease( + lease_id=_digest("bundle-lease", DecisionDigestDomain.LEASE_ID), + request_binding_digest=binding, + policy_id=policy_id, + policy_generation=7, + not_before=100, + expires_at=300, + revocation_generation=3, + ) + policy = PolicyBundleV1( + schema_version=1, + policy_id=policy_id, + policy_generation=7, + valid_from=50, + valid_until=400, + rule_set_digest=_digest("rules", DecisionDigestDomain.RULE_SET), + trust_root_digest=_digest("root", DecisionDigestDomain.TRUST_ROOT), + critical_floor_version="atk-critical-v1", + revocation_generation=3, + grants=(PolicyGrantV1(lease_digest=lease.lease_digest),), + revoked_lease_digests=(), + ) + active_policy = activate_policy( + SignedPolicyCandidateV1( + policy=policy, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("policy-signer"), + signature=b"valid-signature", + ), + verifier=Verifier(), + ) + runtime = activate_runtime( + SignedRuntimeCandidateV1( + runtime=RuntimeSnapshotV1( + schema_version=1, + health=AuthorityHealthV1.HEALTHY.value, + trusted_time=200, + trusted_time_valid_until=250, + policy_generation_floor=7, + policy_digest_at_floor=active_policy.policy_digest, + revocation_generation_floor=3, + revocation_digest_at_floor=active_policy.revocation_digest, + ), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("runtime-signer"), + signature=b"valid-signature", + ), + verifier=Verifier(), + ) + request = create_decision_request( + identity=identity, + envelope=envelope, + data_scope_digest=scope, + operation=operation, + purpose_digest=purpose, + lease=lease, + ) + registry = AdapterRegistryV1() + registry.register(operation_id="bundle.execute", handler=bundle_handler) + adapter = activate_adapter( + SignedAdapterCandidateV1( + manifest=manifest, + implementation=b"fixture-adapter-binary", + dependencies=(b"dependency-a", b"dependency-b"), + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=_digest("adapter-signer"), + signature=b"valid-signature", + ), + registry=registry, + verifier=Verifier(), + policy=active_policy, + ) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate([]) + ).execute(request, runtime=runtime, effect=effect) + + assert result.code != EnforcementCodeV1.EXECUTED.value + assert result.invoked is False + + activated_bundle = activate_executable_bundle( + SignedExecutableBundleCandidateV1( + manifest=bundle_manifest, + evidence=bundle_input, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=bundle_publisher, + signature=b"valid-signature", + ), + verifier=Verifier(), + policy=active_policy, + adapter_manifest_digest=adapter.manifest_digest, + ) + permitted = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), + adapter, + evidence_gate=PermitGate([]), + executable_bundles=(activated_bundle,), + ).execute(request, runtime=runtime, effect=effect) + assert permitted.code == EnforcementCodeV1.EXECUTED.value + assert permitted.invoked is True + + +def test_executable_bundle_activation_verifies_exact_evidence_publisher_and_binding() -> None: + _, _, active_policy, _ = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + evidence_input = BundleEvidenceInput( + artifact=b'{"artifact":"trusted"}', + signature_evidence=b'{"signature":"supply-chain"}', + version_claims=b'{"version":"1.0.0"}', + publisher_claims=b'{"publisher":"fixture"}', + dependencies=(b'{"dependency":"fixture"}',), + policy_binding_claims=b'{"policy_generation":7}', + ) + evidence = bundle_evidence_from_input(evidence_input) + publisher = _digest("bundle-publisher") + manifest = ExecutableBundleManifestV1( + schema_version=1, + bundle_id="fixture.bundle", + bundle_version="1.0.0", + publisher_identity=publisher, + artifact_digest=evidence.artifact_digest, + signature_evidence_digest=evidence.signature_evidence_digest, + version_claims_digest=evidence.version_claims_digest, + publisher_claims_digest=evidence.publisher_claims_digest, + dependency_digests=evidence.dependency_digests, + policy_binding_claims_digest=evidence.policy_binding_claims_digest, + policy_id=active_policy.policy.policy_id, + policy_generation=active_policy.policy.policy_generation, + adapter_manifest_digest=adapter.manifest_digest, + ) + verifier = Verifier() + activated = activate_executable_bundle( + SignedExecutableBundleCandidateV1( + manifest=manifest, + evidence=evidence_input, + algorithm=VerificationAlgorithmV1.EXTERNAL_V1, + signer_identity=publisher, + signature=b"valid-signature", + ), + verifier=verifier, + policy=active_policy, + adapter_manifest_digest=adapter.manifest_digest, + ) + + assert activated.evidence == evidence + assert verifier.calls[0][0] is ArtifactKindV1.BUNDLE + assert verifier.calls[0][3] == canonical_bundle_manifest_bytes(manifest) + + +def test_activated_handler_map_and_pep_bindings_are_immutable() -> None: + _, _, active_policy, _ = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + with pytest.raises(TypeError): + adapter._handlers["document.read"] = lambda _: None # type: ignore[index] + pep = PolicyEnforcementPointV1(PolicyDecisionPointV1(active_policy), adapter) + with pytest.raises(EnforcementError, match="PEP-IMMUTABLE"): + pep._adapter = adapter # type: ignore[misc] + with pytest.raises(EnforcementError, match="PEP-IMMUTABLE"): + del pep._adapter # type: ignore[misc] + with pytest.raises(EnforcementError, match="PEP-ADAPTER-IMMUTABLE"): + del adapter.manifest # type: ignore[misc] + + +def test_enforcement_serializer_rejects_self_digest_forgery() -> None: + effect, request, active_policy, runtime = _active_components() + adapter, _, _ = _activated_adapter(active_policy, _noop_handler) + result = PolicyEnforcementPointV1( + PolicyDecisionPointV1(active_policy), adapter, evidence_gate=PermitGate([]) + ).execute(request, runtime=runtime, effect=effect) + forged = result.model_copy(update={"code": EnforcementCodeV1.INTERNAL_ERROR.value}) + with pytest.raises(EnforcementError, match="PEP-RESULT-INTEGRITY"): + serialize_enforcement_result(forged) + + +def test_effect_byte_cap_accepts_cap_and_rejects_cap_plus_one() -> None: + at_cap = b'"' + b"a" * (MAX_EFFECT_BYTES - 2) + b'"' + assert len(create_effect_input(at_cap).arguments) == MAX_EFFECT_BYTES + with pytest.raises(EnforcementError, match="PEP-EFFECT-MALFORMED"): + create_effect_input(b'"' + b"a" * (MAX_EFFECT_BYTES - 1) + b'"') + + +def test_manifest_operation_and_dependency_caps_are_exact() -> None: + handler_digest = digest_handler_implementation(_noop_handler) + operations = tuple( + ManifestOperationV1( + operation_id=f"operation.{index:04d}", + capability=CapabilityV1.READ.value, + handler_digest=handler_digest, + ) + for index in range(MAX_MANIFEST_OPERATIONS) + ) + dependencies = tuple( + sorted( + digest_adapter_dependency(f"dep-{index}".encode()) + for index in range(MAX_ADAPTER_DEPENDENCIES) + ) + ) + manifest = AdapterManifestV1( + schema_version=1, + adapter_id="fixture.adapter", + adapter_version="1.0.0", + implementation_digest=digest_adapter_implementation(b"adapter"), + dependency_digests=dependencies, + policy_id=_digest("policy-id"), + policy_generation=7, + operations=operations, + ) + assert len(manifest.operations) == MAX_MANIFEST_OPERATIONS + assert len(manifest.dependency_digests) == MAX_ADAPTER_DEPENDENCIES + with pytest.raises(EnforcementError, match="PEP-MANIFEST-MALFORMED"): + AdapterManifestV1( + **( + manifest.model_dump() + | { + "operations": operations + + ( + ManifestOperationV1( + operation_id="operation.plus-one", + capability=CapabilityV1.READ.value, + handler_digest=handler_digest, + ), + ) + } + ) + ) + with pytest.raises(EnforcementError, match="PEP-MANIFEST-MALFORMED"): + AdapterManifestV1( + **( + manifest.model_dump() + | {"dependency_digests": dependencies + (_digest("plus-one-dependency"),)} + ) + ) + + +def test_enforcement_module_has_no_network_clock_or_environment_imports() -> None: + path = Path(__file__).parents[1] / "src/mcp_warden/policy_enforcement.py" + tree = ast.parse(path.read_text()) + forbidden = {"requests", "httpx", "socket", "urllib", "time", "datetime", "os"} + imports: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name.split(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.add(node.module.split(".")[0]) + assert not imports & forbidden