|
1 | 1 | # Policy Hot-Reload |
2 | 2 |
|
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 |
5 | 5 | **Applies to:** cMCP Runtime gateway (`PolicyStore`, `startup`) |
6 | 6 | **Related config:** `policy_reload_interval_seconds` |
7 | 7 |
|
8 | 8 | --- |
9 | 9 |
|
10 | 10 | ## Summary |
11 | 11 |
|
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. |
16 | 16 |
|
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. |
21 | 23 |
|
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. |
27 | 27 |
|
28 | 28 | ## What is actually there |
29 | 29 |
|
@@ -232,20 +232,55 @@ What it means concretely: |
232 | 232 | signer identity and the bundle version, so a verifier can answer both "what |
233 | 233 | policy ran" and "who authorised it" for a process whose policy changed mid-life. |
234 | 234 |
|
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. |
249 | 284 |
|
250 | 285 | ## Two things that were not optional, and are now done |
251 | 286 |
|
@@ -277,5 +312,5 @@ the reads. |
277 | 312 | - Catalog hot-reload. `CMCP_CATALOG_HASH` has exactly the same pin, and |
278 | 313 | `load_catalog` the same shape, so whatever is decided here should be applied |
279 | 314 | 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. |
0 commit comments