Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

ASCII-only records serialize to the same bytes as before, pinned by test, so nothing that verifies today stops verifying. #517 asks for JCS reuse precisely so a record can cross implementations, and nothing consumes these records at runtime yet, so the encoding is still free to correct.

- **`catalog.drift_policy` was unreachable from configuration (#523).** The parser defined
the setting, but strict top-level validation rejected every `catalog:` block before it
could be read. The block and its only supported key are now explicitly allowlisted;
`fail_closed` remains the default, and misspelled nested keys fail closed.

- **cMCP could not verify a v0.2 Agent Manifest at all (agent-manifest#315, phase 4 of agent-manifest#243).** The pin moved to `agent-manifest>=0.11`, which carries the COSE verifier, and nothing here ever presented a v0.2 manifest to it. It could not have worked: `load_agent_manifest` read JSON and `_verify_with_sdk` passed a dict, and from v0.2 the COSE_Sign1 structure **is** the signature (ADR-0011), so a v0.2 document handed over as a dict has nothing to appraise. The SDK correctly reported a missing signature, and an operator would have read that as a malformed manifest rather than a manifest supplied in the wrong form.

`load_agent_manifest_document()` now returns the decoded document alongside the envelope bytes it arrived in, and the envelope is what reaches `verify_manifest` when there is one. The file is sniffed rather than switched on its extension: a COSE envelope is CBOR and never parses as JSON, so trying JSON first is unambiguous and an operator does not have to name the file correctly for the gateway to read it. A v0.2 payload supplied as bare JSON is now named as such. `load_agent_manifest()` keeps its dict-returning signature for callers that only read identity fields.
Expand Down
17 changes: 17 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ agent_manifest:
trust_anchor_path: ./manifest-public-key.json
authenticated_subject: spiffe://factory.example/agent/material-movement/dev

catalog:
# fail_closed (default): deny calls when an upstream server advertises a
# changed or withdrawn approved tool definition.
# warn_only: route the call but record the drift in the audit chain and
# TRACE Claim so an operator can measure a rollout before enforcing.
drift_policy: fail_closed

# Path to the directory containing .cedar policy files and manifest.json.
# Must not contain '..' components. Relative paths are resolved from the
# working directory at startup.
Expand Down Expand Up @@ -97,6 +104,16 @@ All fields are optional as a group. If `path` is set, `trust_anchor_path` must a
| `trust_anchor_path` | string | none | Path to a JSON trust anchor containing the issuer Ed25519 public key, either as `{ "key_id": "...", "public_key_base64url": "..." }` or `{ "keys": [...] }`. |
| `authenticated_subject` | string | none | SPIFFE URI for the authenticated agent subject. This must equal `manifest.agent_id`. In production this should come from the agent SVID/mTLS identity; the config field is the current runtime input for that subject. |

### catalog

The gateway compares an upstream server's advertised tool definitions with the approved
catalog once per server per session, on first contact. Drift is always written to the audit
chain and TRACE Claim; this setting controls whether the session also fails closed.

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `drift_policy` | string | `fail_closed` | Action when an approved tool definition changed or was withdrawn. Valid values: `fail_closed` (deny calls in the session) and `warn_only` (route calls while recording the drift). |

### top-level fields

| Field | Type | Default | Description |
Expand Down
7 changes: 7 additions & 0 deletions src/cmcp_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ class Config:
_KNOWN_TOP_KEYS = {
"attestation",
"agent_manifest",
"catalog",
"kill_switch",
"sensitivity",
"policy_bundle_path",
Expand All @@ -160,6 +161,7 @@ class Config:
#: profile name must be a config error rather than silently enforcing nothing,
#: which is how a deployment ends up believing it is conformant when it is not.
_KNOWN_CONFORMANCE_PROFILES = {"aarm"}
_KNOWN_CATALOG_KEYS = {"drift_policy"}
_KNOWN_KILL_SWITCH_KEYS = {
"enabled",
"window_seconds",
Expand Down Expand Up @@ -391,6 +393,11 @@ def load_config(path: str) -> Config:
catalog_raw = raw.get("catalog", {})
if not isinstance(catalog_raw, dict):
raise ConfigError("catalog must be a mapping")
for key in catalog_raw:
if key not in _KNOWN_CATALOG_KEYS:
raise ConfigError(
f"Unknown catalog key '{key}'. Valid keys: {sorted(_KNOWN_CATALOG_KEYS)}"
)
try:
drift_policy = DriftPolicy(catalog_raw.get("drift_policy", "fail_closed"))
except ValueError as err:
Expand Down
34 changes: 33 additions & 1 deletion tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import pytest

from cmcp_runtime.config import Config, EnforcementMode, TEEProvider, load_config
from cmcp_runtime.config import Config, DriftPolicy, EnforcementMode, TEEProvider, load_config
from cmcp_runtime.errors import ConfigError


Expand Down Expand Up @@ -104,6 +104,38 @@ def test_unknown_agent_manifest_key_raises(config_file):
load_config(path)


# ── #523: configurable upstream catalog drift policy ──────────────────────


def test_catalog_drift_policy_loads(config_file):
path = config_file("catalog:\n drift_policy: warn_only\n")
cfg = load_config(path)
assert cfg.catalog.drift_policy is DriftPolicy.WARN_ONLY


def test_catalog_drift_policy_defaults_to_fail_closed(config_file):
cfg = load_config(config_file(""))
assert cfg.catalog.drift_policy is DriftPolicy.FAIL_CLOSED


def test_invalid_catalog_drift_policy_raises(config_file):
path = config_file("catalog:\n drift_policy: ignore\n")
with pytest.raises(ConfigError, match="catalog.drift_policy"):
load_config(path)


def test_catalog_config_must_be_mapping(config_file):
path = config_file("catalog: warn_only\n")
with pytest.raises(ConfigError, match="catalog must be a mapping"):
load_config(path)


def test_unknown_catalog_key_raises(config_file):
path = config_file("catalog:\n drift_polciy: warn_only\n")
with pytest.raises(ConfigError, match="drift_polciy"):
load_config(path)


# ── #479: configurable sensitivity vocabulary ──────────────────────────────────


Expand Down
Loading