feat(policy): sign the bundle manifest so policy can change at runtime - #497
Merged
Conversation
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>
This was referenced Aug 10, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements POLICY-004 / #493, the direction chosen in the hot-reload design (#490), on top of the two direction-independent fixes in #492.
Why a key and not a hash
A pinned
CMCP_POLICY_HASHsays this exact artifact, decided before the process started. That is what makes a bundle attestable — the hash goes into the TRACE claim — and it is also why it can never authorise a bundle that changed. A pinned signing key says anything this authority approves, which can.A bundle whose
manifest.signatureverifies over the bundle hash and whosemanifest.versionincreased is installed with no restart.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. 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_refusedconstructs exactly that attack — it even asserts the replayed bundle loads fine on its own terms first, so the test is about the version check and not about the signature. I verified it by deleting_check_not_a_downgradeand watching it, and only it plus the same-version case, fail.Versions compare as integer tuples, so
1.10.0beats1.9.0— which string comparison gets backwards, and there is a test for that. An unorderable version (1.0.0-rc1,"",1.0.x) is refused at load, because discovering at the first reload that the running bundle's version was never comparable is worse than refusing to start with it.Design details
cmcp-policy-bundle-v1|<bundle_hash>. Reuses the hash the gateway already computes and measures.signatureinmanifest.json, base64url, excluded from the hashed manifest — it cannot be inside the pre-image it signs. Same idiomDelegationCredential.body()uses.The POLICY-003 startup refusal from #492 is narrowed rather than removed: an interval alongside only a pinned hash still aborts, but hash + interval + key is now the supported production shape.
Stated limit: no revocation
A compromised signing key is replaced by changing
CMCP_POLICY_SIGNING_KEYand restarting. No revocation list, no key set, no expiry.Called out in
STATUS.md,docs/configuration.mdand the design doc in those words, because it bounds what this buys: faster policy change, not faster key change. A deployment whose threat model needs key revocation inside a fleet-restart window should keep pinning a hash. Adding revocation later is compatible — it constrains which keys are acceptable and touches neither the signature nor the version rule.Tests
20 new tests in
tests/unit/test_policy_signing.py, plus two startup tests. Deliberately covering the pinned configuration production actually runs, not only the dev shape — testing only the unpinned shape is what hid the original defect for however long it was there.Suite: 1117 passed, 24 skipped.
ruffandmypyclean.Follow-up
#495 —
CMCP_CATALOG_HASHhas the identical pin andload_catalogthe identical shape. Whatever is decided there should be deliberate rather than a copy of this.🤖 Generated with Claude Code