jouleclaw: bind a policy receipt to the rules, not to a version number #177
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/policy-digest"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What a policy receipt said about the policy
A
PolicyReceiptrecordedpolicy_versionand, on an allow,decision.rule—a counter and a name. Neither says what the rules were.
The obvious form of this hole is already closed, and it is worth being precise
about that:
InMemoryPolicyEngine::install_policyrefuses a non-increasinginstall, so within one engine instance a version does identify a ruleset.
What the counter cannot do is mean anything outside that instance:
rules it was handed.
PolicyEnginecontract, so another backend need not have one.
Either way a receipt citing
(engine_id, policy_version)is a pointer intostate only its producer holds. A verifier has to be told what engine-A's
version 1 contained — by the party whose decision is under audit. The headline
test is exactly that: two engines, both at version 1, one of which would also
have allowed
/etc/shadow, producing receipts whosepolicy_versionanddecisionare identical.The digest
Policy::digest_hexcontent-addresses the rules. Three choices make it a digestof the policy rather than of a serialization, each with its own test:
decidescansDenyrules first and short-circuits, so reordering can change the outcome — two orderings are two policiesagents:one_ofis matched by membership, so two policies differing only in that order are the same policy and must share a digestfrom>tomatches nothing, which is a different policy from the same pair reversed — normalising would merge an always-deny rule with a permissive oneversionis excluded: the digest answers "which rules", the version travelsbeside it answering "which installation", and folding the counter in would make
one ruleset hash differently per engine. Mode and method tags are written out
rather than taken from serde, so the digest does not move if a rename or a
variant reordering lands.
A test that proved nothing, caught by falsifying
Every variable-length part is length-prefixed. The first test I wrote for that
did not exercise it: a rule named
"ab"with glob"c"is alreadydistinguishable from one named
"a"with glob"bc", because the constant tags(
"allow","action:filesystem") sit between them. Stripping the prefixes leftthe suite green.
The prefixes are load-bearing in the one place two arbitrary strings are
adjacent — the sorted agent id list, where nothing separates one id from the
next and the count is equal either way, so
["ab","c"]and["a","bc"]renderto the identical run
"abc". The test now pins that case and fails without theprefixes.
Captured at decision time, not at shipping time
The digest is recorded on the
AuditEventwhen the decision is made, not readoff the engine when the event is later shipped — the live policy can be replaced
in between, and attributing a decision to rules installed afterwards is the
error the field exists to prevent. Both construction sites set it (
evaluateand
evaluate_traced), because a digest on one but not the other is a gap thatlooks like data.
PolicyEngine::policy_digestdefaults toNoneso a backend that holds itsrules somewhere it cannot hash — a remote service, a kernel module — stays
conformant rather than being forced to invent a digest. A consumer that requires
the binding can only refuse such an engine if the absence is reported.
Additive, and the published bytes prove it
policy_digestis skipped when absent and omitted fromcanonical_wire, soa receipt without it has a signed core byte-identical to one produced before the
field existed. Regenerating the pack is the proof rather than the claim:
signed_core_cbor_hexandcose_sign1_hexexactly;
differ by exactly one key;
verifier change, because each encodes the map the vector publishes.
Falsifying this is the sharpest check in the set: emitting an absent digest as
an empty string instead of omitting it breaks the committed conformance
vectors.
Three other crates build a
PolicyReceiptby struct literal in test fixtures(
jouleclaw-cli,jouleclaw-ledger,jouleclaw-otel), each now passingNone. A targetedcargo check -p jouleclaw-policy-receipt --all-targetsdidnot reach them; only the whole-workspace run did.
jouleclaw-otelalso exportsthe digest beside the version it already exported.
Spec
§10.1 Binding the receipt to the rules: the preimage written out
byte-for-byte, the three rendering rules as MUSTs, the requirement that the
digest be the one in force at the decision, and the requirement that an absent
digest be omitted rather than nulled.
Verification
Whole
jouleclaw-rsworkspace: 1457 passed. One failure,jouleclaw-energy --test process_cpu, is pre-existing and unrelated — itasserts a child process burns ≥ 0.2 J in a 300 ms wall window, which the dev
Mac's load (average 149 during the run) starves to 0.147 J. It passes on a rerun
and touches nothing in this change; it is being fixed separately, together with
the same class of flake in
eoc-meter.Eight falsifications from the committed baseline:
rule_order_is_in_the_digest_because_order_is_semanticsagent_list_order_is_not_in_the_digest_because_it_is_a_setno_two_policies_share_a_preimage_by_shifting_a_boundarya_port_range_is_digested_as_written_not_normalised""evaluate_tracedomits the digestan_audit_event_records_the_policy_that_decided_not_the_one_installed_later🤖 Generated with Claude Code
A `PolicyReceipt` recorded `policy_version` and, on an allow, `decision.rule` — a counter and a name. Neither says what the rules were. The hole is narrower than it first looks, and worth stating precisely because the obvious version of it is already closed: `InMemoryPolicyEngine::install_policy` refuses a non-increasing install, so *within one engine instance* a version does identify a ruleset. What the counter cannot do is mean anything outside that instance. A restart, a replica, or a second host starts its own count at 1 with whatever rules it was handed, and the monotonic guard lives in the bundled engine rather than in the `PolicyEngine` contract, so another backend need not have one. Either way a receipt citing `(engine_id, policy_version)` is a pointer into state only its producer holds: a verifier has to be *told* what engine-A's version 1 contained, by the party whose decision is under audit. `Policy::digest_hex` content-addresses the rules. Three choices make it a digest of the policy rather than of a serialization, each with a test: - **Rule order is in the digest.** `decide` scans `Deny` rules first and short-circuits, so reordering can change the outcome: two orderings are two policies. - **The agent list is sorted before hashing.** `agents:one_of` is matched by membership, so two policies differing only in that order are the same policy and must share a digest. - **A port range is hashed as written, not normalised.** A range whose `from` exceeds its `to` matches nothing, which is a different policy from the same pair reversed. `version` is excluded: the digest answers "which rules", the version travels beside it answering "which installation", and folding the counter in would make one ruleset hash differently per engine. Mode and method tags are written out rather than taken from serde, so the digest does not move if a rename or a variant reordering lands. Every variable-length part is length-prefixed. The first test I wrote for that did not actually exercise it — a rule named "ab" with glob "c" is already distinguishable from one named "a" with glob "bc", because the constant tags ("allow", "action:filesystem") sit between them. Falsification caught it: stripping the prefixes left the suite green. The prefixes are load-bearing in the one place two *arbitrary* strings are adjacent — the sorted agent id list, where nothing separates one id from the next and the count is equal either way, so `["ab","c"]` and `["a","bc"]` render to the identical run "abc". The test now pins that case, and fails without the prefixes. The digest is recorded on the `AuditEvent` at decision time, not read off the engine when the event is later shipped: the live policy can be replaced in between, and attributing a decision to rules installed after it was made is the error the field exists to prevent. Both construction sites set it — `evaluate` and `evaluate_traced` — because a digest on one but not the other is a gap that looks like data. `PolicyEngine::policy_digest` defaults to `None` so a backend that holds its rules somewhere it cannot hash stays conformant rather than being forced to invent one; a consumer requiring the binding can only refuse such an engine if the absence is reported. Additive on the wire. `policy_digest` is skipped when absent *and* omitted from `canonical_wire`, so a receipt without it has a signed core byte-identical to one produced before the field existed. Regenerating the pack proves it: vectors 1-3 keep their published `signed_core_cbor_hex` and `cose_sign1_hex` exactly, and new vector 4 is vector 1's decision with the binding, differing by exactly that one key. The Rust, Python and Go reference verifiers all verify 4/4 with no verifier change, because each encodes the map the vector publishes. Three other crates build a `PolicyReceipt` by struct literal in test fixtures (`jouleclaw-cli`, `jouleclaw-ledger`, `jouleclaw-otel`); each passes `None`, the legacy shape. A targeted `cargo check -p jouleclaw-policy-receipt --all-targets` did not reach them — only the whole-workspace run did. `jouleclaw-otel` exports the digest beside the version it already exported. A span naming the version alone has the same gap the receipt field closes. Absent is left absent rather than exported as an empty string, so a query can tell "no binding" from "a binding that is blank". SPEC §10 gains §10.1: the preimage written out byte-for-byte, the three rendering rules as MUSTs, the requirement that the digest be the one in force at the decision, and the requirement that an absent digest be omitted rather than nulled. Co-Authored-By: Claude Opus 5 <[email protected]>e222aa7d689f2085ae9e9f2085ae9e959b9d8f42paths:matches the push delta #191