Skip to content

feat(policy): sign the bundle manifest so policy can change at runtime - #497

Merged
imran-siddique merged 1 commit into
mainfrom
feat/signed-policy-bundles
Aug 10, 2026
Merged

feat(policy): sign the bundle manifest so policy can change at runtime#497
imran-siddique merged 1 commit into
mainfrom
feat/signed-policy-bundles

Conversation

@imran-siddique

Copy link
Copy Markdown
Member

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_HASH says 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.

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

A bundle whose manifest.signature verifies over the bundle hash and whose manifest.version increased is installed with no restart.

⚠️ The version rule is what makes this safe, 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. 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 — 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_downgrade and watching it, and only it plus the same-version case, fail.

Versions compare as integer tuples, so 1.10.0 beats 1.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

Decision Answer
Signature covers The bundle hash, domain-separated: cmcp-policy-bundle-v1|<bundle_hash>. Reuses the hash the gateway already computes and measures.
Where it lives signature in manifest.json, base64url, excluded from the hashed manifest — it cannot be inside the pre-image it signs. Same idiom DelegationCredential.body() uses.
Backward compatibility Stripping a key that was never present is a no-op, so every bundle hash issued to date is unchanged. There is a test asserting the hash is identical before and after signing.
Key rotation Restart only. Rotation is rarer than policy change; a deliberate choice, not an omission.
In-flight sessions New bundle applies from the next evaluation, including for open sessions.
Unsigned bundle, no key pinned Loads. Signing is opt-in.
Unsigned bundle, key pinned Refused. Having asked for signed policy, being handed unsigned policy is a refusal, not a downgrade to the unsigned path.

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_KEY and restarting. No revocation list, no key set, no expiry.

Called out in STATUS.md, docs/configuration.md and 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. ruff and mypy clean.

Follow-up

#495CMCP_CATALOG_HASH has the identical pin and load_catalog the identical shape. Whatever is decided there should be deliberate rather than a copy of this.

🤖 Generated with Claude Code

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>
@imran-siddique
imran-siddique merged commit 077dc91 into main Aug 10, 2026
12 checks passed
@imran-siddique
imran-siddique deleted the feat/signed-policy-bundles branch August 10, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant