diff --git a/docs/configuration.md b/docs/configuration.md index 927f6e5a..83855dcd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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:`. | 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:`. | 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 | diff --git a/docs/spec/catalog-lifecycle.md b/docs/spec/catalog-lifecycle.md new file mode 100644 index 00000000..a1366f37 --- /dev/null +++ b/docs/spec/catalog-lifecycle.md @@ -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. diff --git a/docs/spec/error-codes.md b/docs/spec/error-codes.md index 4549f588..56ef90e1 100644 --- a/docs/spec/error-codes.md +++ b/docs/spec/error-codes.md @@ -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) | diff --git a/src/cmcp_runtime/config.py b/src/cmcp_runtime/config.py index 5817089e..044aacfe 100644 --- a/src/cmcp_runtime/config.py +++ b/src/cmcp_runtime/config.py @@ -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. @@ -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( diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 9098881b..fea2618a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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"):