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
4 changes: 2 additions & 2 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0.
| `attestation.enforcement_mode` | `enforcing` |
| `attestation.staleness_policy` | `fail_closed` |
| `attestation.validity_seconds` | `86400` |
| `policy_reload_interval_seconds` | `0` (disabled; policy change requires an enclave restart. Do not raise it: see the hot-reload row below) |
| `policy_reload_interval_seconds` | `0` (disabled. Raising it requires a pinned `CMCP_POLICY_SIGNING_KEY`; see the hot-reload row below) |
| `attestation.allow_unmeasured_spawn` | `false` (a stdio server the catalog does not pin is not spawned) |
| `attestation.required_provenance_kind` | `null` (server provenance is recorded, not enforced) |

Expand All @@ -34,7 +34,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0.
| Transparency-log anchoring for TRACE Claims | v0.2 | Write and lookup. |
| Server-side (provider) attestation | Not yet (Phase 2) | Phase 1 attests the gateway boundary only. |
| Server provenance checking | Shipped | Consumes [server-provenance-v1](https://github.com/agentrust-io/trace-spec/blob/main/spec/server-provenance-v1.md) records: verifies the signature against a configured publisher key, then compares the record's tool-catalog hash against the tools the server advertises **to this gateway**. Five outcomes reach the audit chain and none of them is silent: `verified`, `catalog-mismatch` (the document is fine and the server is not), `invalid`, `unchecked` (verified but the tool list was unavailable, so the comparison that matters never ran), `absent`. Absence is recorded and non-fatal by default, because almost no MCP server has a record and a gateway that refuses to route without one gets disabled on first contact. Set `attestation.required_provenance_kind` for a floor. |
| Real-time policy update without enclave restart | Not yet; the configuration that pretended to offer it is now refused | `policy_reload_interval_seconds` defaults to `0` and a policy change requires a restart. Setting it above `0` alongside a pinned `CMCP_POLICY_HASH` used to look enabled and do nothing: every reload was re-validated against the startup hash, so a changed bundle was always rejected while the old policy kept being enforced, and the failed reload re-read and re-hashed the whole bundle on every subsequent tool call. That pairing now aborts startup (`POLICY_RELOAD_PINNED_HASH`), and a failing reload costs one attempt per interval rather than one per request. Automatic reload works only under `CMCP_DEV_MODE=1`, where no hash is pinned. The agreed fix for production is a pinned signing key rather than a pinned artifact hash, which is designed but not built: see [policy-hot-reload.md](docs/spec/policy-hot-reload.md). |
| Real-time policy update without enclave restart | Shipped, with signed bundles; still off by default | Set `CMCP_POLICY_SIGNING_KEY` to the Ed25519 public key allowed to sign policy, and `policy_reload_interval_seconds` above `0`. A bundle whose `manifest.signature` verifies over the bundle hash **and** whose `version` increased is installed without a restart. The version rule is not optional: without it a genuinely signed *older* bundle can be replayed, which is a downgrade attack, so equal-or-lower versions are refused. A pinned `CMCP_POLICY_HASH` still cannot authorise a changed bundle — a hash pins one artifact — so an interval alongside only a hash aborts startup (`POLICY_RELOAD_PINNED_HASH`). Signing is opt-in: unsigned bundles keep working where no key is pinned, and every bundle hash issued before signing existed is unchanged. **Key revocation is not implemented**: a compromised signing key is replaced by config plus a restart, so this buys faster policy change, not faster key change. See [policy-hot-reload.md](docs/spec/policy-hot-reload.md). |
| AARM R4 five decision types | Shipped, with caveats | ALLOW, DENY, MODIFY, STEP_UP, DEFER are recorded in the audit chain. MODIFY is recorded as `redact`, DEFER is classified but not asynchronously enforced, and the TRACE Claim still carries the pre-AARM vocabulary. See [LIMITATIONS.md](LIMITATIONS.md). |
| AARM R8 telemetry export | Shipped | OpenTelemetry spans mirroring audit entries. Opt in with `CMCP_OTEL_ENABLED=1` and `pip install cmcp-runtime[otel]`; a no-op otherwise. Exports digests, never payloads. The audit chain stays authoritative. |
| AARM R2/R3 declared intent | Not implemented | cMCP takes no declared-intent input, so the intent-alignment half of R2 and R3 is unmet. Adding one changes the MCP-facing surface. |
Expand Down
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,12 @@ All fields are optional as a group. If `path` is set, `trust_anchor_path` must a
| `catalog_path` | string | `catalog.json` | Path to the JSON tool catalog. Path traversal (`..` components) is rejected. |
| `listen_addr` | string | `0.0.0.0:8443` | Address and port the gateway binds to. Default is `127.0.0.1:8443` in tokenless `CMCP_DEV_MODE=1`, otherwise `0.0.0.0:8443`. Tokenless dev mode requires loopback (e.g., `127.0.0.1:8443`, `localhost:8443`, `[::1]:8443`). Wildcard, LAN, public, and non-loopback hostname binds require `CMCP_BEARER_TOKEN`. |
| `max_response_size_bytes` | integer | `2097152` | Maximum tool response size in bytes (2MB). Must be a positive integer. Responses exceeding this limit are rejected before inspection. |
| `policy_reload_interval_seconds` | integer | `0` | Interval in seconds between automatic Cedar bundle reloads. `0` disables automatic reload. **Setting it above `0` together with a pinned `CMCP_POLICY_HASH` aborts startup** (`POLICY_RELOAD_PINNED_HASH`): the reload re-validates against that pinned hash, so a bundle that actually changed would always be rejected and the old policy would stay in force. The two are alternatives, not layers — pin a hash for a policy that must not change, or use `CMCP_DEV_MODE=1` where reload works because nothing is pinned. Production hot-reload needs a pinned signing key instead, which is designed but not built: see [Policy Hot-Reload](spec/policy-hot-reload.md). |
| `policy_reload_interval_seconds` | integer | `0` | Interval in seconds between automatic Cedar bundle reloads. `0` disables automatic reload. Above `0` requires a pinned `CMCP_POLICY_SIGNING_KEY`: **an interval alongside only a pinned `CMCP_POLICY_HASH` aborts startup** (`POLICY_RELOAD_PINNED_HASH`): the reload re-validates against that pinned hash, so a bundle that actually changed would always be rejected and the old policy would stay in force. A hash pins one artifact and so cannot authorise a bundle that changed; a signing key approves any bundle the authority signs, which is what reload needs. Both pins together is the supported production shape. See [Policy Hot-Reload](spec/policy-hot-reload.md). |

## Environment variables

`CMCP_POLICY_SIGNING_KEY` is the raw Ed25519 **public** key (base64url or hex, 32 bytes) permitted to sign policy bundles. Pinning it is what allows `policy_reload_interval_seconds > 0`: a bundle is installed at runtime only when its `manifest.signature` verifies under this key and its `manifest.version` increased. It is not a secret, but it is security-critical config, which is why it sits here with `CMCP_POLICY_HASH` rather than in the config file. There is no revocation mechanism: replacing a compromised key means changing this value and restarting.

Environment variables control secrets and mode flags that must not appear in config files. They are read once at process startup; they cannot be changed at runtime.

| Variable | Description | Overrides |
Expand Down
1 change: 1 addition & 0 deletions docs/spec/error-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This is the normative registry for all error codes used across the cMCP Runtime.
| `ATTESTATION_PROVIDER_NOT_IMPLEMENTED` | 501 | FATAL | A recognized provider was explicitly selected but is not yet implemented (e.g. `opaque`) | [attestation.md §1.1](attestation.md) |
| `POLICY_HASH_MISMATCH` | 500 | FATAL | Measured policy bundle hash does not match deployment manifest | [failure-modes.md FM-4](failure-modes.md) |
| `POLICY_RELOAD_PINNED_HASH` | 500 | FATAL | `policy_reload_interval_seconds > 0` configured alongside a pinned `CMCP_POLICY_HASH`. Every reload is validated against that hash, so a changed bundle could never be installed; refused at startup rather than appearing to work | [policy-hot-reload.md](policy-hot-reload.md) |
| `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) |
| `CATALOG_HASH_MISMATCH` | 500 | FATAL | Measured catalog hash does not match deployment manifest | [attestation.md §5](attestation.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) |
Expand Down
97 changes: 66 additions & 31 deletions docs/spec/policy-hot-reload.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,29 @@
# Policy Hot-Reload

**Document status:** Direction decided (option A, signing key); the two
direction-independent fixes have landed, the signing-key model has not been built
**Document status:** Implemented (option A, signing key). Revocation is
deliberately out of scope; see the limit stated below
**Applies to:** cMCP Runtime gateway (`PolicyStore`, `startup`)
**Related config:** `policy_reload_interval_seconds`

---

## Summary

Hot-reload is not missing. It is implemented in `PolicyStore.reload_if_stale`, wired
into `PolicyEvaluator`, and documented as a supported knob — and **it cannot swap a
policy in any production configuration.** This document records why, measures what
the current code does instead, and lays out the options for fixing it.
Hot-reload was never missing. It was implemented in `PolicyStore.reload_if_stale`,
wired into `PolicyEvaluator`, and documented as a supported knob — and **it could
not swap a policy in any production configuration.** The sections below record why,
with the measurements, because the shape of that mistake is worth keeping.

The direction is now decided (option A, a pinned signing key) and the two fixes
that did not depend on the direction have landed: the guaranteed-inert
configuration is refused at startup, and a failing reload no longer re-reads the
bundle on every request. The signing-key model itself is not built.
**Now:** policy can change at runtime when the gateway pins a **signing key**
rather than an artifact hash (option A, built). The guaranteed-inert configuration
is refused at startup, a failing reload no longer re-reads the bundle on every
request, and a signed bundle whose version increases is installed without a
restart. Revocation is not implemented and that limit is stated rather than
implied.

`STATUS.md` says real-time policy update is "Not yet" because
`policy_reload_interval_seconds` is `0`. That reads as "unimplemented, default off".
The truth is worse and more specific: it is implemented, it is off by default, and
turning it on in production buys a warning log line every request instead of a
policy update.
The rest of this document is the analysis that got there, kept because the
diagnosis matters more than the fix: a status file said "not yet" while the code
said "implemented and inert", and nothing failed.

## What is actually there

Expand Down Expand Up @@ -232,20 +232,55 @@ What it means concretely:
signer identity and the bundle version, so a verifier can answer both "what
policy ran" and "who authorised it" for a process whose policy changed mid-life.

Open sub-questions the implementation issue has to answer, none of which reopen
the direction:

- Where the pinned key comes from, and whether it can be rotated without a
restart. A key that can only change on restart is fine and is probably right,
since key rotation is rarer than policy change.
- Revocation. A signing key that is compromised needs a way to stop being trusted
that is faster than a fleet restart, or the model's advantage over hash pinning
shrinks.
- Whether the signature covers the bundle hash or the full canonical bundle. The
first is smaller and reuses `_canonical_bundle_hash`; the second is
self-contained.
- What happens to a session already admitted under the previous bundle, which is
listed under Not in scope below and now needs an answer.
### As built

```bash
export CMCP_POLICY_SIGNING_KEY=<raw Ed25519 public key, base64url or hex>
# and in cmcp-config.yaml
policy_reload_interval_seconds: 60
```

| Decision | Answer |
|---|---|
| Signature covers | The **bundle hash**, domain-separated: `sha256(cmcp-policy-bundle-v1\|<bundle_hash>)`. Reuses the hash the gateway already computes and measures. |
| Where the signature lives | `signature` in `manifest.json`, base64url. It is **excluded from the hashed manifest**, because it cannot be inside the pre-image it signs. Same idiom the delegation credential uses. Every bundle hash issued before signing existed is unchanged, since stripping an absent key is a no-op. |
| Monotonic version | Enforced on reload when a key is pinned. Versions are compared as tuples of integers, so `1.10.0` beats `1.9.0`; an unorderable version is refused **at load**, not at the first reload. |
| Key rotation | Restart only. Rotation is rarer than policy change, and this is a deliberate choice rather than an omission. |
| Revocation | **Not implemented.** See the limit below. |
| In-flight sessions | The new bundle applies from the next evaluation, including for sessions already open. |
| Unsigned bundles | Still valid when no key is pinned. Signing is opt-in; a deployment pinning a hash needs none of it. |
| Unsigned bundle *with* a key pinned | Refused. Having asked for signed policy, being handed unsigned policy is a refusal, not a downgrade to the unsigned path. |

### Why the version check is not optional

Without it the signing-key model **is** a downgrade attack. Anyone who can write
the bundle directory replays yesterday's more permissive bundle: the authority
really signed it, the signature really verifies, and the gateway installs a policy
the operator already retired. Monotonicity is what makes "signed by the authority"
mean "the authority's *current* intent". `test_a_replayed_older_signed_bundle_is_refused`
constructs exactly that attack and fails if the check is removed, which was
verified by removing it.

### The limit: no revocation

A compromised signing key stops being trusted by changing
`CMCP_POLICY_SIGNING_KEY` and restarting. There is no revocation list, no key set,
and no expiry.

Stated plainly because it bounds what this buys: **faster policy change, not
faster key change.** A deployment whose threat model includes a compromised policy
signing key needing revocation inside a fleet-restart window is not served by
this, and should keep pinning a hash. Adding revocation later is compatible with
what is built — it constrains which keys are acceptable, and does not change the
signature or the version rule.

### In-flight sessions

A session admitted under the previous bundle is evaluated against the new one from
its next call. This is what an operator tightening a policy during an incident
expects, and it is the reason reload was wanted at all. The cost is that a
long-running session can see its effective permissions narrow with no signal; a
session-facing notification was considered and is not built.

## Two things that were not optional, and are now done

Expand Down Expand Up @@ -277,5 +312,5 @@ the reads.
- Catalog hot-reload. `CMCP_CATALOG_HASH` has exactly the same pin, and
`load_catalog` the same shape, so whatever is decided here should be applied
there deliberately rather than by copy. It is not analysed in this document.
- What a reloaded policy means for an in-flight session that has already been
admitted under the previous bundle.
- Revocation of a policy signing key, which is a real gap rather than a
non-goal: see "The limit: no revocation" above.
14 changes: 14 additions & 0 deletions src/cmcp_runtime/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,20 @@ class PolicyHashMismatch(CMCPError):
http_status = 500


class PolicySignatureInvalid(CMCPError):
"""A policy bundle's manifest signature is absent, malformed, or does not
verify under the pinned signing key; or its version did not increase.

POLICY-004. Distinct from PolicyHashMismatch, which says the bundle is not the
one exact artifact that was pinned. This says nobody authorised to change
policy authorised *this* bundle, which is the question a deployment asks when
it allows policy to change at runtime at all.
"""

code = "POLICY_SIGNATURE_INVALID"
http_status = 500


class CatalogHashMismatch(CMCPError):
code = "CATALOG_HASH_MISMATCH"
http_status = 500
Expand Down
Loading