diff --git a/pyproject.toml b/pyproject.toml index aa39a82..a2ce039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,9 +27,9 @@ classifiers = [ requires-python = ">=3.11" dependencies = [ "agentrust-trace>=0.5", - # 0.11.1 is the floor: the chained TPM verifier forwards ParsedSignature so - # the envelope's declared signature scheme and digest remain authoritative. - "agent-manifest>=0.11.1", + # 0.11.2 is the floor: verification binds the manifest's declared policy + # enforcement mode to the runtime's attested enforcement mode. + "agent-manifest>=0.11.2", "cryptography>=50.0,<51.0", "pyyaml>=6.0", "httpx>=0.27", diff --git a/src/cmcp_runtime/agent_manifest.py b/src/cmcp_runtime/agent_manifest.py index d54c60f..16a7ee9 100644 --- a/src/cmcp_runtime/agent_manifest.py +++ b/src/cmcp_runtime/agent_manifest.py @@ -13,6 +13,7 @@ import agent_manifest as agent_manifest_sdk +from cmcp_runtime.config import EnforcementMode from cmcp_runtime.errors import ConfigError SIGNED_FIELDS: tuple[str, ...] = tuple(agent_manifest_sdk.SIGNED_FIELDS) @@ -23,6 +24,20 @@ SubjectSource = Literal["config", "svid", "manifest-dev"] +# Spec 6.2: cMCP's own enforcement modes ("enforcing"/"advisory"/"silent", +# cmcp_runtime.config.EnforcementMode) and the Agent Manifest spec's +# ("enforce"/"advisory"/"audit-only") name the same three states differently. +# This is the single place that mapping is defined +_ENFORCEMENT_MODE_TO_MANIFEST: dict[EnforcementMode, str] = { + EnforcementMode.ENFORCING: "enforce", + EnforcementMode.ADVISORY: "advisory", + EnforcementMode.SILENT: "audit-only", +} + + +def enforcement_mode_for_manifest(mode: EnforcementMode) -> str: + """Map cMCP's enforcement mode to the Agent Manifest spec's vocabulary.""" + return _ENFORCEMENT_MODE_TO_MANIFEST[mode] @dataclass(frozen=True) class AgentManifestBinding: @@ -46,6 +61,14 @@ class AgentManifestBinding: #: and an intent statement is business context. A verifier that wants the #: text fetches the manifest it already has to fetch to check the signature. intent_hash: str | None = None + #: Spec 6.2. The runtime's own enforcement mode (cmcp_runtime.config. + #: EnforcementMode), present only when verify_agent_manifest_binding was + #: given one -- which, by the time this object exists, means it has + #: already been cross-checked against the manifest's declared + #: artifacts.policy_bundle.enforcement_mode. A mismatch raises before this + #: object is constructed, so a populated value here is an attested match, + #: not merely "what the caller asked for". + enforcement_mode: EnforcementMode | None = None def _b64url_decode(value: str) -> bytes: @@ -203,6 +226,14 @@ def _raise_for_sdk_result(result: Any, *, require_runtime_artifacts: bool) -> No return mismatch_fields = {str(detail.field) for detail in result.mismatch_details} + # Checked before the plain "policy_bundle" case: the hash can match while + # only the enforcement mode disagrees (spec 6.2), and that deserves its + # own message rather than being reported as a hash mismatch. + if "policy_bundle.enforcement_mode" in mismatch_fields: + raise ConfigError( + "Agent Manifest policy bundle enforcement_mode does not match the " + "runtime's attested enforcement mode" + ) if "policy_bundle" in mismatch_fields: raise ConfigError("Agent Manifest policy bundle hash does not match runtime policy") if "tool_manifest" in mismatch_fields: @@ -236,6 +267,7 @@ def _verify_with_sdk( *, policy_bundle_hash: str | None = None, tool_catalog_hash: str | None = None, + enforcement_mode: EnforcementMode | None = None, require_runtime_artifacts: bool = False, envelope: bytes | None = None, ) -> None: @@ -247,6 +279,11 @@ def _verify_with_sdk( agent_manifest_sdk.VerificationContext( policy_bundle_hash=policy_bundle_hash, tool_catalog_hash=tool_catalog_hash, + enforcement_mode=( + enforcement_mode_for_manifest(enforcement_mode) + if enforcement_mode is not None + else None + ), trusted_keys=_trusted_keys_for_sdk(trusted_keys), ), agent_manifest_sdk.RevocationStore(), @@ -314,6 +351,7 @@ def verify_agent_manifest_binding( authenticated_subject: str | None, policy_bundle_hash: str, tool_catalog_hash: str, + enforcement_mode: EnforcementMode | None = None, authenticated_subject_source: str | None = None, allow_dev_subject_from_manifest: bool = False, now: datetime | None = None, @@ -341,6 +379,7 @@ def verify_agent_manifest_binding( trusted_keys, policy_bundle_hash=policy_bundle_hash, tool_catalog_hash=tool_catalog_hash, + enforcement_mode=enforcement_mode, require_runtime_artifacts=True, envelope=envelope, ) @@ -377,4 +416,5 @@ def verify_agent_manifest_binding( # intent taken from an unverified manifest is an intent anyone could # have written, which is the failure the field exists to prevent. intent_hash=agent_manifest_sdk.intent_hash(manifest), + enforcement_mode=enforcement_mode, ) diff --git a/src/cmcp_runtime/startup.py b/src/cmcp_runtime/startup.py index 0e30ee9..cffab40 100644 --- a/src/cmcp_runtime/startup.py +++ b/src/cmcp_runtime/startup.py @@ -632,6 +632,7 @@ def run_startup(config_path: str) -> RuntimeContext: authenticated_subject=config.agent_manifest.authenticated_subject, policy_bundle_hash=policy_bundle.bundle_hash, tool_catalog_hash=catalog.catalog_hash, + enforcement_mode=config.attestation.enforcement_mode, allow_dev_subject_from_manifest=config.dev_mode, ) except ConfigError as exc: diff --git a/tests/unit/test_agent_manifest.py b/tests/unit/test_agent_manifest.py index 8b98344..e977f89 100644 --- a/tests/unit/test_agent_manifest.py +++ b/tests/unit/test_agent_manifest.py @@ -19,6 +19,7 @@ signing_pre_image, verify_agent_manifest_binding, ) +from cmcp_runtime.config import EnforcementMode from cmcp_runtime.errors import ConfigError POLICY_HASH = "sha256:" + "a" * 64 @@ -91,11 +92,11 @@ def test_valid_manifest_binds_subject_policy_and_catalog() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, ) assert binding.manifest_id == manifest["manifest_id"] assert binding.agent_id == AGENT_ID - assert binding.subject_source == "config" - assert binding.issuer_key_id == key_id + assert binding.enforcement_mode == EnforcementMode.ENFORCING def test_binding_verification_delegates_to_sdk_with_encoded_keys(monkeypatch) -> None: @@ -131,6 +132,7 @@ def fake_verify_manifest(manifest_arg, context, revocation_store): authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, ) assert binding.manifest_id == manifest["manifest_id"] @@ -151,6 +153,7 @@ def test_dev_subject_fallback_is_marked_as_manifest_dev() -> None: authenticated_subject=None, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, allow_dev_subject_from_manifest=True, ) assert binding.authenticated_subject == AGENT_ID @@ -167,6 +170,41 @@ def test_subject_mismatch_fails_closed() -> None: authenticated_subject="spiffe://factory.example/agent/other/dev", policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, + ) + + + +def test_enforcement_mode_mismatch_fails_closed() -> None: + # The manifest's policy_bundle declares "enforce" (see _signed_manifest); + # a runtime that is only attested as running in advisory mode must not + # bind, even though the policy_bundle hash itself matches. + priv, pub, key_id = _keypair() + manifest = _signed_manifest(priv, key_id) + with pytest.raises(ConfigError, match="enforcement_mode"): + verify_agent_manifest_binding( + manifest, + {key_id: pub}, + authenticated_subject=AGENT_ID, + policy_bundle_hash=POLICY_HASH, + tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ADVISORY, + ) + + +def test_enforcement_mode_not_provided_fails_closed() -> None: + # A runtime that can't or doesn't attest its enforcement mode must not be + # treated as matching a manifest that declares one -- unattested is not + # evidence of compliance. + priv, pub, key_id = _keypair() + manifest = _signed_manifest(priv, key_id) + with pytest.raises(ConfigError, match="enforcement_mode"): + verify_agent_manifest_binding( + manifest, + {key_id: pub}, + authenticated_subject=AGENT_ID, + policy_bundle_hash=POLICY_HASH, + tool_catalog_hash=CATALOG_HASH, ) @@ -181,6 +219,7 @@ def test_tampered_manifest_signature_fails_closed() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, ) @@ -194,6 +233,7 @@ def test_policy_hash_drift_fails_closed() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash="sha256:" + "0" * 64, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, ) @@ -207,6 +247,7 @@ def test_catalog_hash_drift_fails_closed() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash="sha256:" + "0" * 64, + enforcement_mode=EnforcementMode.ENFORCING, ) @@ -220,6 +261,7 @@ def test_expired_manifest_fails_closed() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, now=datetime(2026, 6, 17, tzinfo=UTC), ) @@ -284,4 +326,5 @@ def test_a_mislabelled_post_quantum_manifest_fails_closed_cleanly() -> None: authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, ) diff --git a/tests/unit/test_agent_manifest_cose.py b/tests/unit/test_agent_manifest_cose.py index a916a21..1612abe 100644 --- a/tests/unit/test_agent_manifest_cose.py +++ b/tests/unit/test_agent_manifest_cose.py @@ -26,6 +26,7 @@ load_agent_manifest_document, verify_agent_manifest_binding, ) +from cmcp_runtime.config import EnforcementMode from cmcp_runtime.errors import ConfigError POLICY_HASH = "sha256:" + "a" * 64 @@ -90,6 +91,7 @@ def _bind(loaded, trusted_keys): authenticated_subject=AGENT_ID, policy_bundle_hash=POLICY_HASH, tool_catalog_hash=CATALOG_HASH, + enforcement_mode=EnforcementMode.ENFORCING, envelope=loaded.envelope, )