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 docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ Environment variables control secrets and mode flags that must not appear in con
| `CMCP_POLICY_HASH` | SHA-256 hash of the approved policy bundle. Required in non-dev mode and checked by startup before Agent Manifest binding. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (startup policy integrity check) |
| `CMCP_CATALOG_HASH` | SHA-256 hash of the approved `catalog.json`. Required in non-dev mode. The gateway fails closed at startup if this is unset and `CMCP_DEV_MODE` is not `1`. Format: `sha256:<hex>`. | none (additional startup check) |

The catalog is immutable for the process lifetime. Updating routing, tool
definitions, TLS pins, or measured child identities requires a new pinned hash
and a gateway restart. Hot-reload configuration is rejected with
`CATALOG_RESTART_REQUIRED`; see [Catalog lifecycle](spec/catalog-lifecycle.md).

## Enforcement modes

| Mode | Behavior | Use case |
Expand Down
28 changes: 28 additions & 0 deletions docs/spec/catalog-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Catalog lifecycle: immutable until restart

**Status:** Decided and enforced
**Decision:** A running cMCP process never reloads or mutates its approved catalog.

The catalog binds tool names to upstream identities, TLS pins, measured stdio
executables, and approved schemas. Changing it at runtime can redirect a
permitted call to different code or a different network authority. That blast
radius is larger than changing a policy decision over an otherwise stable route.

`CMCP_CATALOG_HASH` therefore means exactly one approved catalog artifact for
the process lifetime. To change it, an operator writes the new artifact,
computes and pins its hash, restarts the gateway, and obtains fresh attestation
evidence naming that hash. There is no polling interval, reload endpoint,
filesystem watcher, or signing-key exception for the catalog.

This does not hide upstream drift. `CATALOG_DRIFT_DETECTED` compares advertised
tool definitions with the sealed catalog and fails closed; it reports that
reality moved rather than silently rewriting the authority.

## Executable guard

Configuration keys implying runtime mutation, including
`catalog_reload_interval_seconds` and `catalog_reload_path`, are reserved and
rejected with `CATALOG_RESTART_REQUIRED`. A future implementation cannot make
one valid by only adding a parser field. Changing this lifecycle requires
deliberately removing the guard, revising this decision, and replacing its tests
with a complete authorization and attestation model.
1 change: 1 addition & 0 deletions docs/spec/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This is the normative registry for all error codes used across the cMCP Runtime.
| `POLICY_SIGNATURE_INVALID` | 500 | FATAL | A policy bundle's manifest signature is absent, malformed, or does not verify under the pinned `CMCP_POLICY_SIGNING_KEY`; or its `version` did not increase, which would allow a genuinely signed older bundle to be replayed | [policy-hot-reload.md](policy-hot-reload.md) |
| `CONFORMANCE_PROFILE_UNSATISFIED` | 500 | FATAL | A named `conformance_profile` requires something the deployment has not configured; `aarm` requires an Agent Manifest binding (AARM R6) | [../../STATUS.md](../../STATUS.md) |
| `CATALOG_HASH_MISMATCH` | 500 | FATAL | Measured catalog hash does not match deployment manifest | [attestation.md §5](attestation.md) |
| `CATALOG_RESTART_REQUIRED` | startup | FATAL | Runtime catalog mutation was configured, but routing authority remains immutable until a newly pinned gateway restart | [catalog-lifecycle.md](catalog-lifecycle.md) |
| `AGENT_MANIFEST_BINDING_FAILED` | 500 | FATAL | Signed Agent Manifest signature, authenticated subject, policy hash, or catalog hash did not match the runtime session inputs | [session-policy.md](session-policy.md) |
| `TOOL_NOT_IN_CATALOG` | 403 | WARN | Agent requested a tool not present in the attested catalog | [cedar-policy.md](cedar-policy.md) |
| `POLICY_DENY` | 403 | INFO | Cedar policy evaluation returned deny for this call | [cedar-policy.md](cedar-policy.md) |
Expand Down
15 changes: 15 additions & 0 deletions src/cmcp_runtime/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ class Config:
"conformance_profile",
}

# #495: catalog identity and routing are immutable for the process lifetime.
# Keep likely reload knobs in a separate denylist so merely adding one to the
# normal parser allowlist cannot silently enable mutation later.
_FORBIDDEN_CATALOG_MUTATION_KEYS = {
"catalog_reload_interval_seconds",
"catalog_reload_path",
}

#: Named conformance profiles. Deliberately a closed set: an unrecognised
#: 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.
Expand Down Expand Up @@ -230,6 +238,13 @@ def load_config(path: str) -> Config:
if not isinstance(raw, dict):
raise ConfigError("Config must be a YAML mapping at the top level")

forbidden_catalog_keys = set(raw) & _FORBIDDEN_CATALOG_MUTATION_KEYS
if forbidden_catalog_keys:
raise ConfigError(
"CATALOG_RESTART_REQUIRED: runtime catalog mutation is unsupported; "
f"remove {sorted(forbidden_catalog_keys)} and restart with a newly pinned catalog"
)

for key in raw:
if key not in _KNOWN_TOP_KEYS:
raise ConfigError(
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ def test_unknown_key_raises(config_file):
load_config(path)


def test_catalog_reload_knob_is_reserved_and_requires_restart(config_file):
"""#495: adding a parser field alone must not enable catalog mutation."""
path = config_file("catalog_reload_interval_seconds: 60\n")
with pytest.raises(ConfigError, match="CATALOG_RESTART_REQUIRED"):
load_config(path)


def test_unknown_agent_manifest_key_raises(config_file):
path = config_file("agent_manifest:\n surprise: value\n")
with pytest.raises(ConfigError, match="surprise"):
Expand Down