Skip to content

Commit 077dc91

Browse files
feat(policy): sign the bundle manifest so policy can change at runtime (#497)
Implements POLICY-004, the direction chosen in the hot-reload design (#493). A pinned hash says "this exact artifact, decided before the process started", which is what makes a bundle attestable and also why it can never authorise a bundle that changed. A pinned signing key says "anything this authority approves", which can. Set CMCP_POLICY_SIGNING_KEY to the Ed25519 public key permitted to sign policy. A bundle whose manifest.signature verifies over the bundle hash, and whose manifest.version increased, is installed with no restart. The signature is domain-separated (cmcp-policy-bundle-v1|<bundle_hash>) so it cannot be interchanged with any other signature in the system. The signature lives in manifest.json and is excluded from the hashed manifest, because it cannot be inside the pre-image it signs. Same idiom the delegation credential uses. Stripping a key that was never present is a no-op, so every bundle hash issued to date is unchanged and signing is purely additive. The version rule is not a nicety. Without it this 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. Versions compare as integer tuples so 1.10.0 beats 1.9.0, and an unorderable version is refused at load rather than at the first reload. test_a_replayed_older_signed_bundle_is_refused builds that exact attack; verified by deleting the check and watching it, and only it, fail. Not implemented, and stated rather than implied: key revocation. A compromised signing key is replaced by config plus a restart, so this buys faster policy change, not faster key change. A deployment that needs revocation inside a fleet-restart window should keep pinning a hash. Signing stays opt-in. Unsigned bundles load where no key is pinned; an unsigned bundle where a key IS pinned is refused, because having asked for signed policy, being handed unsigned policy is a refusal and not a downgrade to the unsigned path. 20 new tests in their own module, covering the pinned configuration production actually runs rather than only the dev shape that hid the original defect. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7df0b2b commit 077dc91

9 files changed

Lines changed: 655 additions & 41 deletions

File tree

STATUS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0.
1212
| `attestation.enforcement_mode` | `enforcing` |
1313
| `attestation.staleness_policy` | `fail_closed` |
1414
| `attestation.validity_seconds` | `86400` |
15-
| `policy_reload_interval_seconds` | `0` (disabled; policy change requires an enclave restart. Do not raise it: see the hot-reload row below) |
15+
| `policy_reload_interval_seconds` | `0` (disabled. Raising it requires a pinned `CMCP_POLICY_SIGNING_KEY`; see the hot-reload row below) |
1616
| `attestation.allow_unmeasured_spawn` | `false` (a stdio server the catalog does not pin is not spawned) |
1717
| `attestation.required_provenance_kind` | `null` (server provenance is recorded, not enforced) |
1818

@@ -34,7 +34,7 @@ picture is stated once. Developer Preview: interfaces may change before v1.0.
3434
| Transparency-log anchoring for TRACE Claims | v0.2 | Write and lookup. |
3535
| Server-side (provider) attestation | Not yet (Phase 2) | Phase 1 attests the gateway boundary only. |
3636
| 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. |
37-
| 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). |
37+
| 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). |
3838
| 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). |
3939
| 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. |
4040
| 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. |

docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,12 @@ All fields are optional as a group. If `path` is set, `trust_anchor_path` must a
105105
| `catalog_path` | string | `catalog.json` | Path to the JSON tool catalog. Path traversal (`..` components) is rejected. |
106106
| `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`. |
107107
| `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. |
108-
| `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). |
108+
| `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). |
109109

110110
## Environment variables
111111

112+
`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.
113+
112114
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.
113115

114116
| Variable | Description | Overrides |

docs/spec/error-codes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This is the normative registry for all error codes used across the cMCP Runtime.
1111
| `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) |
1212
| `POLICY_HASH_MISMATCH` | 500 | FATAL | Measured policy bundle hash does not match deployment manifest | [failure-modes.md FM-4](failure-modes.md) |
1313
| `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) |
14+
| `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) |
1415
| `CATALOG_HASH_MISMATCH` | 500 | FATAL | Measured catalog hash does not match deployment manifest | [attestation.md §5](attestation.md) |
1516
| `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) |
1617
| `TOOL_NOT_IN_CATALOG` | 403 | WARN | Agent requested a tool not present in the attested catalog | [cedar-policy.md](cedar-policy.md) |

docs/spec/policy-hot-reload.md

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
# Policy Hot-Reload
22

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

88
---
99

1010
## Summary
1111

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

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

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

2828
## What is actually there
2929

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

235-
Open sub-questions the implementation issue has to answer, none of which reopen
236-
the direction:
237-
238-
- Where the pinned key comes from, and whether it can be rotated without a
239-
restart. A key that can only change on restart is fine and is probably right,
240-
since key rotation is rarer than policy change.
241-
- Revocation. A signing key that is compromised needs a way to stop being trusted
242-
that is faster than a fleet restart, or the model's advantage over hash pinning
243-
shrinks.
244-
- Whether the signature covers the bundle hash or the full canonical bundle. The
245-
first is smaller and reuses `_canonical_bundle_hash`; the second is
246-
self-contained.
247-
- What happens to a session already admitted under the previous bundle, which is
248-
listed under Not in scope below and now needs an answer.
235+
### As built
236+
237+
```bash
238+
export CMCP_POLICY_SIGNING_KEY=<raw Ed25519 public key, base64url or hex>
239+
# and in cmcp-config.yaml
240+
policy_reload_interval_seconds: 60
241+
```
242+
243+
| Decision | Answer |
244+
|---|---|
245+
| Signature covers | The **bundle hash**, domain-separated: `sha256(cmcp-policy-bundle-v1\|<bundle_hash>)`. Reuses the hash the gateway already computes and measures. |
246+
| 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. |
247+
| 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. |
248+
| Key rotation | Restart only. Rotation is rarer than policy change, and this is a deliberate choice rather than an omission. |
249+
| Revocation | **Not implemented.** See the limit below. |
250+
| In-flight sessions | The new bundle applies from the next evaluation, including for sessions already open. |
251+
| Unsigned bundles | Still valid when no key is pinned. Signing is opt-in; a deployment pinning a hash needs none of it. |
252+
| 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. |
253+
254+
### Why the version check is not optional
255+
256+
Without it the signing-key model **is** a downgrade attack. Anyone who can write
257+
the bundle directory replays yesterday's more permissive bundle: the authority
258+
really signed it, the signature really verifies, and the gateway installs a policy
259+
the operator already retired. Monotonicity is what makes "signed by the authority"
260+
mean "the authority's *current* intent". `test_a_replayed_older_signed_bundle_is_refused`
261+
constructs exactly that attack and fails if the check is removed, which was
262+
verified by removing it.
263+
264+
### The limit: no revocation
265+
266+
A compromised signing key stops being trusted by changing
267+
`CMCP_POLICY_SIGNING_KEY` and restarting. There is no revocation list, no key set,
268+
and no expiry.
269+
270+
Stated plainly because it bounds what this buys: **faster policy change, not
271+
faster key change.** A deployment whose threat model includes a compromised policy
272+
signing key needing revocation inside a fleet-restart window is not served by
273+
this, and should keep pinning a hash. Adding revocation later is compatible with
274+
what is built — it constrains which keys are acceptable, and does not change the
275+
signature or the version rule.
276+
277+
### In-flight sessions
278+
279+
A session admitted under the previous bundle is evaluated against the new one from
280+
its next call. This is what an operator tightening a policy during an incident
281+
expects, and it is the reason reload was wanted at all. The cost is that a
282+
long-running session can see its effective permissions narrow with no signal; a
283+
session-facing notification was considered and is not built.
249284

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

@@ -277,5 +312,5 @@ the reads.
277312
- Catalog hot-reload. `CMCP_CATALOG_HASH` has exactly the same pin, and
278313
`load_catalog` the same shape, so whatever is decided here should be applied
279314
there deliberately rather than by copy. It is not analysed in this document.
280-
- What a reloaded policy means for an in-flight session that has already been
281-
admitted under the previous bundle.
315+
- Revocation of a policy signing key, which is a real gap rather than a
316+
non-goal: see "The limit: no revocation" above.

src/cmcp_runtime/errors.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ class PolicyHashMismatch(CMCPError):
3737
http_status = 500
3838

3939

40+
class PolicySignatureInvalid(CMCPError):
41+
"""A policy bundle's manifest signature is absent, malformed, or does not
42+
verify under the pinned signing key; or its version did not increase.
43+
44+
POLICY-004. Distinct from PolicyHashMismatch, which says the bundle is not the
45+
one exact artifact that was pinned. This says nobody authorised to change
46+
policy authorised *this* bundle, which is the question a deployment asks when
47+
it allows policy to change at runtime at all.
48+
"""
49+
50+
code = "POLICY_SIGNATURE_INVALID"
51+
http_status = 500
52+
53+
4054
class CatalogHashMismatch(CMCPError):
4155
code = "CATALOG_HASH_MISMATCH"
4256
http_status = 500

0 commit comments

Comments
 (0)