Skip to content
Merged
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
40 changes: 40 additions & 0 deletions src/cmcp_runtime/agent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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,
)
1 change: 1 addition & 0 deletions src/cmcp_runtime/startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
47 changes: 45 additions & 2 deletions tests/unit/test_agent_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"]
Expand All @@ -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
Expand All @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -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,
)


Expand All @@ -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),
)

Expand Down Expand Up @@ -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,
)
2 changes: 2 additions & 0 deletions tests/unit/test_agent_manifest_cose.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
Loading