From 959b9d8f4280538209b73456ad8ff67c6b5d652c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 6 Oct 2026 17:21:59 -0400 Subject: [PATCH] jouleclaw: bind a policy receipt to the rules, not to a version number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- jouleclaw/SPEC.md | 65 ++- jouleclaw/jouleclaw-rs/Cargo.lock | 1 + .../crates/jouleclaw-cli/src/main.rs | 3 + .../crates/jouleclaw-ledger/src/lib.rs | 3 + .../crates/jouleclaw-otel/src/lib.rs | 11 + .../conformance/vectors-policy.json | 28 +- .../examples/gen_policy_vectors.rs | 44 +- .../jouleclaw-policy-receipt/src/lib.rs | 203 +++++++++- .../crates/jouleclaw-policy/Cargo.toml | 1 + .../crates/jouleclaw-policy/src/lib.rs | 375 ++++++++++++++++++ 10 files changed, 728 insertions(+), 6 deletions(-) diff --git a/jouleclaw/SPEC.md b/jouleclaw/SPEC.md index 93b5dd539..4c632c300 100644 --- a/jouleclaw/SPEC.md +++ b/jouleclaw/SPEC.md @@ -377,12 +377,71 @@ wire shape. "engine_id": "", "audit_sequence": 7, "policy_version": 2, + "policy_digest": "", "agent": "", "action": { "kind": "filesystem", "path": "/workspace/x", "mode": "read" }, "decision": { "kind": "allow", "rule": "read-workspace" } } ``` +### 10.1 Binding the receipt to the rules + +`policy_version` is an **installation counter**, not a content address. A +conforming engine SHOULD refuse a non-increasing install, which makes the +number identify a ruleset *within one engine instance*; across a restart, a +replica, or a different backend it does not, because each starts its own count. +`decision.rule` likewise names the rule that decided by **name**. A receipt +carrying only those is a pointer into state that only its producer holds: a +verifier must be told what engine-A's version 2 contained, by the party whose +decision is under audit. + +A receipt therefore **MAY** carry `policy_digest`, a BLAKE3 hex digest of the +rules themselves, and an implementation that emits one **MUST** compute it as: + +``` +BLAKE3( "jouleclaw.policy/1" || rule_count(8, BE) || per rule: + len(name) || name + "allow" | "deny" + "agents:any" | "agents:one_of" || count(8, BE) || (len||id)* [ids SORTED] + "action:filesystem" || len(path_glob) || path_glob || count || (len||mode_tag)* + | "action:network" || len(host_glob) || host_glob || from(2,BE) || to(2,BE) + || count || (len||method_tag)* + | "action:process" || len(binary_glob) || binary_glob ) +``` + +Three rules make it a digest of the *policy* rather than of a serialization: + +- **Rule order is included.** Evaluation 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 the other way round. + +`version` is **excluded** from the digest: 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. + +Every variable-length part is length-prefixed, so no two distinct policies can +share a preimage by moving a field boundary. The mode and method tags are +written out above rather than derived from a serializer, so the digest does not +move if a `serde` rename or a variant reordering ever lands. + +The digest **MUST** be the one in force when the decision was made, not the +engine's current policy — the two differ across an install, and attributing a +decision to rules installed after it was made is the error the field exists to +prevent. Implementations SHOULD record it on the audit event at decision time. + +When absent, the field **MUST** be omitted from the signed core entirely, not +emitted as a null or an empty string: a receipt without the binding then has a +signed core byte-identical to one produced before the field existed, which is +why the three `policy/1` vectors published before it are unchanged and +`policy/1` verifiers keep working. A consumer that requires the binding +**MUST** check for the field's presence rather than defaulting it. + - The canonical signed core is the det-CBOR (RFC 8949 §4.2.1 length-first map ordering) of the receipt as JSON. - The COSE_Sign1 envelope is identical in shape to the JCR-1 v1 @@ -391,12 +450,14 @@ wire shape. `signature = Ed25519(Sig_structure)`. - Verifiers reject envelopes whose `kid` (in the protected header) disagrees with the embedded `steward`. -- Conforming implementations **MUST** byte-match the three reference +- Conforming implementations **MUST** byte-match the four reference vectors in [`jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json`](jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json). Reference verifiers in Rust, Python, and Go ship alongside the vectors; all three produce byte-identical canonical bytes for the - same receipt. + same receipt. Vectors 1-3 omit `policy_digest` and keep the bytes they + shipped with; vector 4 is vector 1's decision with the binding, so the two + differ by exactly that key. Policy receipts and cascade receipts share the same JSONL "unified log" file format: readers dispatch on the `jc_receipt` schema tag diff --git a/jouleclaw/jouleclaw-rs/Cargo.lock b/jouleclaw/jouleclaw-rs/Cargo.lock index 186388cc8..a56e3783d 100644 --- a/jouleclaw/jouleclaw-rs/Cargo.lock +++ b/jouleclaw/jouleclaw-rs/Cargo.lock @@ -4443,6 +4443,7 @@ dependencies = [ name = "jouleclaw-policy" version = "0.1.0" dependencies = [ + "blake3", "serde", "serde_json", "thiserror 2.0.20", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs index f97c57d29..6a07fd93f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs @@ -632,6 +632,9 @@ mod tests { engine_id: "engine-A".into(), audit_sequence: 1, policy_version: 1, + // The legacy shape: no digest, so the signed core is the one that + // shipped before the field existed. + policy_digest: None, agent: agent.into(), action, decision, diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-ledger/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-ledger/src/lib.rs index bce97bef2..512581700 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-ledger/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-ledger/src/lib.rs @@ -1179,6 +1179,9 @@ mod tests { engine_id: "engine-A".to_string(), audit_sequence: sequence, policy_version: 1, + // The legacy shape: no digest, so the signed core is the one that + // shipped before the field existed. + policy_digest: None, agent: agent.to_string(), action, decision, diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-otel/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-otel/src/lib.rs index a1cab854a..fcdc40d02 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-otel/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-otel/src/lib.rs @@ -402,6 +402,14 @@ pub fn policy_receipt_to_span(p: &PolicyReceipt) -> SpanData { "jouleclaw.policy.policy_version".into(), AttributeValue::Int(p.policy_version as i64), ); + // Exported beside the version, and only when the receipt carries it. A + // span that reported the version alone would name a policy in telemetry + // without saying what it was, which is 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". + if let Some(d) = &p.policy_digest { + attrs.insert("jouleclaw.policy.policy_digest".into(), AttributeValue::s(d)); + } attrs.insert( "jouleclaw.steward".into(), AttributeValue::s(&p.steward), @@ -658,6 +666,9 @@ mod tests { engine_id: "engine-test".into(), audit_sequence: 42, policy_version: 1, + // The legacy shape: no digest, so the signed core is the one that + // shipped before the field existed. + policy_digest: None, agent: "did:key:zAgent".into(), action: PolicyActionWire::Filesystem { path: "/workspace/notes.md".into(), diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json index 3a4a1e98a..27855260e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/conformance/vectors-policy.json @@ -10,7 +10,7 @@ "note": "TEST KEY ONLY. Same seed as JCR-1 conformance/test-keys.json.", "steward_kid": "did:keri:EJ9testStewardAID000000000000000" }, - "vector_count": 3, + "vector_count": 4, "vectors": [ { "cose_sign1_hex": "d284582fa201270458296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030a0590123aa6269646c706f6c726370742d30303031656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316e61756469745f73657175656e6365016e706f6c6963795f76657273696f6e015840eceb966d0bc3b90faa8ea33442a846712c66c995450125e1d71b709f24dea47e9fcabb1ad9b7eb110572ecf24b745b15dc20fb1399643167ee4d96d13ea4dc06", @@ -88,6 +88,32 @@ "steward": "did:keri:EJ9testStewardAID000000000000000" }, "signed_core_cbor_hex": "aa6269646c706f6c726370742d30303033656167656e746d6469643a6b65793a7a4265746166616374696f6ea464686f73746f6170692e6578616d706c652e636f6d646b696e64676e6574776f726b64706f72741901bb666d6574686f6467636f6e6e656374677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e646464656e7966726561736f6e716e6f5f6d61746368696e675f616c6c6f7769636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316e61756469745f73657175656e6365036e706f6c6963795f76657273696f6e01" + }, + { + "cose_sign1_hex": "d284582fa201270458296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030a0590173ab6269646c706f6c726370742d30303034656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316d706f6c6963795f6469676573747840616234653464376135393034376235333464306362643238333235306430353961323966353232393761333264373631363962366134383432653866346563396e61756469745f73657175656e6365046e706f6c6963795f76657273696f6e015840f8f76d5c857176584653f1335f7668ee8495d86606ce1dad3c8a9abddfeb25f13306c29d6e9a0de659100346f1023c7d6dd398e0a06875f15574ccd2157f490d", + "description": "Policy-bound allow — same decision as vector 1, with the digest of the rules that produced it. Vectors 1-3 omit the field and keep the signed core they shipped with.", + "n": 4, + "receipt": { + "action": { + "kind": "filesystem", + "mode": "read", + "path": "/workspace/notes.md" + }, + "agent": "did:key:zAlpha", + "audit_sequence": 4, + "closed_at": "2026-06-02T12:00:00Z", + "decision": { + "kind": "allow", + "rule": "read-workspace" + }, + "engine_id": "engine-A", + "id": "polrcpt-0004", + "jc_receipt": "policy/1", + "policy_digest": "ab4e4d7a59047b534d0cbd283250d059a29f52297a32d76169b6a4842e8f4ec9", + "policy_version": 1, + "steward": "did:keri:EJ9testStewardAID000000000000000" + }, + "signed_core_cbor_hex": "ab6269646c706f6c726370742d30303034656167656e746e6469643a6b65793a7a416c70686166616374696f6ea3646b696e646a66696c6573797374656d646d6f646564726561646470617468732f776f726b73706163652f6e6f7465732e6d64677374657761726478296469643a6b6572693a454a397465737453746577617264414944303030303030303030303030303030686465636973696f6ea2646b696e6465616c6c6f776472756c656e726561642d776f726b737061636569636c6f7365645f617474323032362d30362d30325431323a30303a30305a69656e67696e655f696468656e67696e652d416a6a635f7265636569707468706f6c6963792f316d706f6c6963795f6469676573747840616234653464376135393034376235333464306362643238333235306430353961323966353232393761333264373631363962366134383432653866346563396e61756469745f73657175656e6365046e706f6c6963795f76657273696f6e01" } ] } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/examples/gen_policy_vectors.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/examples/gen_policy_vectors.rs index c41d94941..c8d1efd45 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/examples/gen_policy_vectors.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/examples/gen_policy_vectors.rs @@ -84,6 +84,10 @@ fn receipt( engine_id: ENGINE_ID.to_string(), audit_sequence: audit_seq, policy_version: 1, + // The first three vectors carry no digest, which is what keeps their + // signed core byte-identical to the shipped `policy/1` bytes. Vector 4 + // carries one. + policy_digest: None, agent: agent.to_string(), action, decision, @@ -167,12 +171,48 @@ fn main() { &key, ); + // Vector 4 — the policy binding. Same decision as vector 1, with the + // digest of the rules that made it. Its signed core therefore differs from + // vector 1's by exactly that key, which is the additivity claim made + // checkable: vectors 1-3 keep the bytes they shipped with, and a verifier + // that encodes the published map needs no change to handle this one. + let mut bound = receipt( + 4, + 4, + PolicyDecisionWire::Allow { rule: "read-workspace".to_string() }, + PolicyActionWire::Filesystem { + path: "/workspace/notes.md".to_string(), + mode: "read".to_string(), + }, + "did:key:zAlpha", + ); + // The digest of a real policy, not a literal: a hand-written constant here + // would not detect a change to the rendering it is supposed to pin. + let policy = jouleclaw_policy::Policy::new(vec![jouleclaw_policy::Rule { + name: "read-workspace".to_string(), + effect: jouleclaw_policy::Effect::Allow, + agents: jouleclaw_policy::AgentScope::one_of(["did:key:zAlpha"]), + action: jouleclaw_policy::ActionPredicate::Filesystem { + path_glob: "/workspace/**".to_string(), + modes: [jouleclaw_policy::FsMode::Read].into_iter().collect(), + }, + }]); + bound.policy_digest = Some(policy.digest_hex()); + let v4 = make_vector( + 4, + "Policy-bound allow — same decision as vector 1, with the digest of the \ + rules that produced it. Vectors 1-3 omit the field and keep the signed \ + core they shipped with.", + bound, + &key, + ); + let doc = json!({ "spec": "JouleClaw Policy Receipt v1", "schema_id": "policy/1", "canonical_cbor": "RFC 8949 4.2.1 core deterministic (length-first key order)", "envelope": "COSE_Sign1 (RFC 9052), EdDSA/Ed25519", - "vector_count": 3, + "vector_count": 4, "test_key": { "ed25519_seed_hex": SIGNING_SEED_HEX, "ed25519_public_raw_hex": hex_encode(&public_key), @@ -180,7 +220,7 @@ fn main() { "cose_alg": "EdDSA (-8)", "note": "TEST KEY ONLY. Same seed as JCR-1 conformance/test-keys.json." }, - "vectors": [v1, v2, v3] + "vectors": [v1, v2, v3, v4] }); let pretty = serde_json::to_string_pretty(&doc).expect("pretty json"); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/src/lib.rs index 8ba9c96ac..dcad5f00c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy-receipt/src/lib.rs @@ -142,7 +142,35 @@ pub struct PolicyReceipt { /// Monotonic sequence within the source engine's audit log. pub audit_sequence: u64, /// Policy version under which the decision was made. + /// + /// A per-engine installation counter. Within one engine instance it + /// identifies a ruleset; across instances or backends it does not — see + /// `policy_digest`. pub policy_version: u64, + /// `Policy::digest_hex` of the rules that produced this decision. + /// + /// `policy_version` is an installation counter, unique to a ruleset only + /// within one engine instance (the bundled engine refuses a non-increasing + /// install; the trait does not require one). Across a restart, a replica, + /// or another backend, the same number names different rules — so a receipt + /// citing the version and `engine_id` alone is a pointer into state only + /// its producer holds. This field is what binds the receipt to the rules + /// themselves. + /// + /// It is the digest recorded on the `AuditEvent` **at decision time**, not + /// the engine's current policy: the two differ across an `install_policy`, + /// and attributing a decision to rules installed after it was made is the + /// error this field exists to prevent. + /// + /// `None` is the older shape, for an engine that cannot report a digest. + /// It is skipped on the wire *and* omitted from [`canonical_wire`], so a + /// receipt without it has a signed core byte-identical to one produced + /// before the field existed — the published `policy/1` vectors are + /// unchanged, and `policy/1` verifiers keep working. A consumer that + /// requires the binding must check for the field, which is why its absence + /// is representable rather than defaulted to something. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_digest: Option, /// The agent the action was attributed to. pub agent: String, pub action: PolicyActionWire, @@ -268,6 +296,9 @@ where engine_id: self.engine_id.clone(), audit_sequence: event.sequence, policy_version: event.policy_version, + // Copied from the event, so the receipt names the rules that + // decided rather than whatever is installed now. + policy_digest: event.policy_digest.clone(), agent: event.agent.as_str().to_string(), action: PolicyActionWire::from(&event.action), decision: PolicyDecisionWire::from(&event.decision), @@ -313,6 +344,12 @@ pub fn canonical_wire(receipt: &PolicyReceipt) -> Value { obj.insert("engine_id".into(), json!(receipt.engine_id)); obj.insert("audit_sequence".into(), json!(receipt.audit_sequence)); obj.insert("policy_version".into(), json!(receipt.policy_version)); + // Inserted only when present. The JCR-1 canonicaliser refuses nulls, and a + // key carrying an empty string would change the core bytes for every + // pre-existing receipt — so absence must be absence, not a placeholder. + if let Some(d) = &receipt.policy_digest { + obj.insert("policy_digest".into(), json!(d)); + } obj.insert("agent".into(), json!(receipt.agent)); obj.insert("action".into(), action_to_value(&receipt.action)); obj.insert("decision".into(), decision_to_value(&receipt.decision)); @@ -1024,7 +1061,7 @@ mod tests { let seed = hex_decode(seed_hex); let key = SigningKey::from_bytes(seed.as_slice().try_into().unwrap()); let vectors = doc["vectors"].as_array().expect("vectors array"); - assert_eq!(vectors.len(), 3, "this version pins 3 vectors"); + assert_eq!(vectors.len(), 4, "this version pins 4 vectors"); for v in vectors { let n = v["n"].as_u64().expect("n"); let receipt: PolicyReceipt = serde_json::from_value(v["receipt"].clone()) @@ -1139,4 +1176,168 @@ mod tests { let r: PolicyReceipt = serde_json::from_str(legacy).expect("legacy parse"); assert!(r.context.is_none()); } + // ── The policy binding ───────────────────────────────────────── + + #[test] + fn two_engines_at_one_version_hold_different_rules_and_only_the_digest_says_so() { + // The hole, stated accurately. `InMemoryPolicyEngine::install_policy` + // does enforce strictly-increasing versions, so *within one instance* a + // version identifies a ruleset. What it cannot do is make the number + // mean anything outside that instance: a restart, a replica, or a + // second host starts at version 1 with whatever rules it was given. + // + // `engine_id` distinguishes the producers, but nothing binds an + // (engine_id, version) pair to a ruleset — a verifier has to be *told* + // what engine-A's version 1 contained, by the same party that produced + // the receipt. The digest is what makes the receipt self-describing. + let narrow = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow( + "read-ws", + AgentScope::Any, + ) + .filesystem("/workspace/**", [FsMode::Read])])); + let wide = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow( + "read-ws", + AgentScope::Any, + ) + .filesystem("/**", [FsMode::Read])])); + + assert_eq!(narrow.policy_version(), wide.policy_version(), "both are version 1"); + let act = Action::filesystem("/workspace/notes.md", FsMode::Read); + let a = build_one(act.clone(), &narrow); + let b = build_one(act, &wide); + + // Everything a `policy/1` receipt said about the policy is identical, + // and the decisions match too — yet one engine would also have allowed + // /etc/shadow and the other would not. + assert_eq!(a.policy_version, b.policy_version); + assert_eq!(a.decision, b.decision); + assert!(wide + .evaluate(&AgentId::new("agent-007"), &Action::filesystem("/etc/shadow", FsMode::Read)) + .is_allow()); + assert!(!narrow + .evaluate(&AgentId::new("agent-007"), &Action::filesystem("/etc/shadow", FsMode::Read)) + .is_allow()); + + // Only the digest separates them. + assert_ne!(a.policy_digest, b.policy_digest); + assert!(a.policy_digest.is_some() && b.policy_digest.is_some()); + + // And it is in the signed core, so it is a binding rather than an + // annotation a producer can drop on the way out. + assert_ne!( + canonical_signed_core(&a).expect("core"), + canonical_signed_core(&b).expect("core") + ); + assert!(String::from_utf8_lossy(&canonical_signed_core(&a).unwrap()) + .contains("policy_digest")); + } + + #[test] + fn a_receipt_names_the_rules_in_force_when_the_decision_was_made() { + // Within one engine the version does move, and the digest must move + // with it — on the event, not read off the engine afterwards. + let engine = InMemoryPolicyEngine::new(Policy::new(vec![Rule::allow( + "read-ws", + AgentScope::Any, + ) + .filesystem("/workspace/**", [FsMode::Read])])); + let before = engine.policy_digest().expect("the bundled engine reports one"); + let first = build_one(Action::filesystem("/workspace/a.md", FsMode::Read), &engine); + assert_eq!(first.policy_digest.as_deref(), Some(before.as_str())); + + engine + .install_policy(Policy::new_at_version( + vec![Rule::allow("read-all", AgentScope::Any).filesystem("/**", [FsMode::Read])], + 2, + )) + .expect("install"); + let after = engine.policy_digest().expect("still reports one"); + assert_ne!(before, after); + + // The receipt built from the earlier event still names the earlier + // rules — the point of recording the digest at decision time. + let mut builder = PolicyReceiptBuilder { + steward: "did:key:test".to_string(), + engine_id: "engine-A".to_string(), + now: deterministic_now, + id_gen: deterministic_ids(), + }; + let replayed = builder.build(&engine.audit()[0]); + assert_eq!(replayed.policy_digest.as_deref(), Some(before.as_str())); + assert_eq!(replayed.policy_version, 1); + } + + #[test] + fn a_receipt_without_a_digest_has_the_core_it_always_had() { + // Additivity, checked against the published bytes rather than asserted: + // vector 1 omits the field, and its signed core must still be the hex + // that shipped. `policy_vectors_match_committed_conformance` covers all + // four; this names why vector 1 in particular matters. + let doc: serde_json::Value = serde_json::from_str(POLICY_VECTORS_JSON).unwrap(); + let v1 = &doc["vectors"][0]; + let r1: PolicyReceipt = serde_json::from_value(v1["receipt"].clone()).unwrap(); + assert_eq!(r1.policy_digest, None, "vector 1 is the digest-less shape"); + let core = canonical_signed_core(&r1).expect("core"); + assert!( + !String::from_utf8_lossy(&core).contains("policy_digest"), + "an absent digest must not appear in the signed bytes at all" + ); + assert_eq!(hex_encode(&core), v1["signed_core_cbor_hex"].as_str().unwrap()); + + // Vector 4 is the same decision with the binding, so the two differ by + // exactly that key — which is what makes the additivity claim + // non-vacuous rather than a statement about an empty set. + let v4 = &doc["vectors"][3]; + let r4: PolicyReceipt = serde_json::from_value(v4["receipt"].clone()).unwrap(); + assert!(r4.policy_digest.is_some()); + assert_ne!( + v4["signed_core_cbor_hex"].as_str(), + v1["signed_core_cbor_hex"].as_str() + ); + let (w1, w4) = (canonical_wire(&r1), canonical_wire(&r4)); + let only_in_4: Vec<&String> = w4 + .as_object() + .unwrap() + .keys() + .filter(|k| !w1.as_object().unwrap().contains_key(*k)) + .collect(); + assert_eq!(only_in_4, vec!["policy_digest"]); + } + + #[test] + fn a_shipped_receipt_carries_the_digest_of_the_rules_in_force_at_the_decision() { + // `PolicyShipper` reads events after the fact. If the receipt took the + // digest from the engine at shipping time it would attribute a decision + // to rules installed after it was made. + let first = Policy::new(vec![Rule::allow("read-ws", AgentScope::Any) + .filesystem("/workspace/**", [FsMode::Read])]); + let engine = InMemoryPolicyEngine::new(first); + let d1 = engine.policy_digest().unwrap(); + let agent = AgentId::new("did:key:zAlpha"); + engine.evaluate(&agent, &Action::filesystem("/workspace/a.md", FsMode::Read)); + + engine + .install_policy(Policy::new_at_version( + vec![Rule::allow("read-all", AgentScope::Any) + .filesystem("/**", [FsMode::Read])], + 2, + )) + .expect("install"); + let d2 = engine.policy_digest().unwrap(); + engine.evaluate(&agent, &Action::filesystem("/etc/hosts", FsMode::Read)); + assert_ne!(d1, d2); + + let mut shipper = PolicyShipper::new( + "did:key:test", + "engine-A", + deterministic_now, + deterministic_ids(), + ); + let mut sink = InMemoryPolicyReceiptSink::new(); + shipper.ship(&engine, &mut sink).expect("ship"); + let shipped: Vec<&PolicyReceipt> = sink.iter().collect(); + assert_eq!(shipped.len(), 2); + assert_eq!(shipped[0].policy_digest.as_deref(), Some(d1.as_str())); + assert_eq!(shipped[1].policy_digest.as_deref(), Some(d2.as_str())); + } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/Cargo.toml index 342889fd8..3ae383288 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/Cargo.toml @@ -10,6 +10,7 @@ description = "JouleClaw out-of-process deny-by-default policy engine — filesy [dependencies] serde = { workspace = true } +blake3 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/src/lib.rs index 50f6214bb..f306bdc30 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-policy/src/lib.rs @@ -410,6 +410,119 @@ impl Policy { version: 1, } } + + /// Content address of this policy — BLAKE3 over the rules themselves, + /// hex-encoded. + /// + /// `version` is a per-engine counter, not a content address. + /// [`InMemoryPolicyEngine::install_policy`] does enforce strictly + /// increasing versions, so *within one instance* a version identifies a + /// ruleset. What the counter cannot do is mean anything outside that + /// instance: a restart, a replica, or a second host starts at version 1 + /// with whatever rules it was given, and the monotonic guard lives in the + /// bundled engine rather than in the [`PolicyEngine`] contract, so another + /// backend need not have one at all. Either way a record citing the + /// 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. This is what makes the record self-describing. + /// + /// **Order is in the digest**, because order is semantics: [`decide`] + /// scans `Deny` rules first and short-circuits, so reordering rules can + /// change the outcome. Within a rule, the agent list is *sorted* before + /// hashing — `AgentScope::OneOf` is matched by membership, so two policies + /// differing only in that order are the same policy and must have the same + /// digest. + /// + /// `version` is deliberately **excluded**. A digest answers "which rules", + /// and the version travels beside it answering "which installation"; folding + /// the counter in would make the same ruleset hash differently per engine + /// and defeat the comparison the digest exists for. + /// + /// The byte rendering is written out rather than delegated to a serializer, + /// so a second implementation can reproduce it from this source. Every + /// variable-length part is preceded by its length, so no two distinct + /// policies can produce the same preimage by moving where one field ends. + pub fn digest_hex(&self) -> String { + let mut h = blake3::Hasher::new(); + h.update(b"jouleclaw.policy/1"); + h.update(&(self.rules.len() as u64).to_be_bytes()); + for r in &self.rules { + field(&mut h, r.name.as_bytes()); + h.update(match r.effect { + Effect::Allow => b"allow", + Effect::Deny => b"deny", + }); + match &r.agents { + AgentScope::Any => { + h.update(b"agents:any"); + } + AgentScope::OneOf { ids } => { + h.update(b"agents:one_of"); + let mut sorted: Vec<&str> = ids.iter().map(|a| a.0.as_str()).collect(); + sorted.sort_unstable(); + h.update(&(sorted.len() as u64).to_be_bytes()); + for id in sorted { + field(&mut h, id.as_bytes()); + } + } + } + match &r.action { + ActionPredicate::Filesystem { path_glob, modes } => { + h.update(b"action:filesystem"); + field(&mut h, path_glob.as_bytes()); + h.update(&(modes.len() as u64).to_be_bytes()); + for m in modes { + field(&mut h, fs_mode_tag(*m).as_bytes()); + } + } + ActionPredicate::Network { host_glob, ports, methods } => { + h.update(b"action:network"); + field(&mut h, host_glob.as_bytes()); + // Rendered as given, not normalised: a range whose `from` + // exceeds its `to` matches nothing, which is a different + // policy from the same pair the other way round. + h.update(&ports.from.to_be_bytes()); + h.update(&ports.to.to_be_bytes()); + h.update(&(methods.len() as u64).to_be_bytes()); + for m in methods { + field(&mut h, net_method_tag(*m).as_bytes()); + } + } + ActionPredicate::Process { binary_glob } => { + h.update(b"action:process"); + field(&mut h, binary_glob.as_bytes()); + } + } + } + h.finalize().to_hex().to_string() + } +} + +/// Length-prefix one variable-length field so no two distinct policies share a +/// preimage by shifting a boundary. +fn field(h: &mut blake3::Hasher, bytes: &[u8]) { + h.update(&(bytes.len() as u64).to_be_bytes()); + h.update(bytes); +} + +/// Wire tag for a filesystem mode. Written out so the digest does not move if +/// a serde rename or variant order ever changes. +fn fs_mode_tag(m: FsMode) -> &'static str { + match m { + FsMode::Read => "read", + FsMode::Write => "write", + FsMode::Execute => "execute", + FsMode::List => "list", + } +} + +/// Wire tag for a network method, for the same reason as [`fs_mode_tag`]. +fn net_method_tag(m: NetMethod) -> &'static str { + match m { + NetMethod::Connect => "connect", + NetMethod::Listen => "listen", + NetMethod::Resolve => "resolve", + } } // ───────────────────────────────────────────────────────────────────── @@ -451,6 +564,19 @@ pub struct AuditEvent { pub sequence: u64, /// Policy version under which the evaluation ran. pub policy_version: u64, + /// [`Policy::digest_hex`] of the policy that actually decided. + /// + /// Recorded here, at decision time, rather than read off the engine when + /// the event is later shipped: the live policy can be replaced in between, + /// and a record that cited the *then-current* digest would attribute the + /// decision to rules that had not been installed when it was made. + /// + /// `None` on events from an engine that does not report one. That is the + /// older, weaker shape and is skipped on the wire, so an `AuditEvent` + /// serialized before this field existed still deserializes and still + /// round-trips byte-identically. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_digest: Option, pub agent: AgentId, pub action: Action, pub decision: Decision, @@ -536,6 +662,21 @@ pub trait PolicyEngine: Send + Sync { fn audit_since(&self, since: u64) -> Vec; /// Currently-installed policy version. fn policy_version(&self) -> u64; + /// [`Policy::digest_hex`] of the currently-installed policy, where the + /// backend can produce one. + /// + /// Defaults to `None` so an engine that holds its rules 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 + /// should refuse such an engine explicitly, which it can only do if the + /// absence is reported rather than papered over. + /// + /// This is the *current* policy. For the policy a given decision was made + /// under, read [`AuditEvent::policy_digest`]; the two differ across an + /// [`PolicyEngine::install_policy`]. + fn policy_digest(&self) -> Option { + None + } /// Replace the live policy. The default implementation returns /// [`PolicyError::EnginePoisoned`] as a stand-in for "this backend /// is read-only / does not support live policy updates" — the @@ -615,6 +756,7 @@ impl PolicyEngine for InMemoryPolicyEngine { let event = AuditEvent { sequence: g.next_seq, policy_version: g.policy.version, + policy_digest: Some(g.policy.digest_hex()), agent: agent.clone(), action: action.clone(), decision: decision.clone(), @@ -652,6 +794,14 @@ impl PolicyEngine for InMemoryPolicyEngine { g.policy.version } + fn policy_digest(&self) -> Option { + let g = match self.inner.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + Some(g.policy.digest_hex()) + } + fn install_policy(&self, new_policy: Policy) -> Result { // Delegate to the inherent method so the wire path and the // in-process call share one implementation. @@ -667,6 +817,7 @@ impl PolicyEngine for InMemoryPolicyEngine { let event = AuditEvent { sequence: g.next_seq, policy_version: g.policy.version, + policy_digest: Some(g.policy.digest_hex()), agent: agent.clone(), action: action.clone(), decision: traced.decision.clone(), @@ -1360,4 +1511,228 @@ mod tests { let back: TracedDecision = serde_json::from_str(&json).expect("from_str"); assert_eq!(back, traced); } + // ─── the policy digest ─────────────────────────────────────────── + + fn fs_rule(name: &str, glob: &str) -> Rule { + Rule { + name: name.to_string(), + effect: Effect::Allow, + agents: AgentScope::Any, + action: ActionPredicate::Filesystem { + path_glob: glob.to_string(), + modes: [FsMode::Read].into_iter().collect(), + }, + } + } + + #[test] + fn swapping_the_rules_under_one_version_changes_the_digest() { + // The hole. `policy_version` is an installation counter, so a record + // citing it alone keeps verifying after the ruleset is replaced — while + // describing a decision the new rules would not make. + let open = Policy::new_at_version(vec![fs_rule("read-ws", "/workspace/**")], 3); + let wide = Policy::new_at_version(vec![fs_rule("read-ws", "/**")], 3); + assert_eq!(open.version, wide.version, "the counter cannot tell these apart"); + assert_ne!(open.digest_hex(), wide.digest_hex(), "the digest must"); + // And the same rules at a different version share a digest: the digest + // answers "which rules", the version answers "which installation". + assert_eq!( + open.digest_hex(), + Policy::new_at_version(vec![fs_rule("read-ws", "/workspace/**")], 99).digest_hex() + ); + } + + #[test] + fn rule_order_is_in_the_digest_because_order_is_semantics() { + // `decide` scans Deny rules first and short-circuits, so reordering can + // change the outcome — two orderings are two policies. + let deny = Rule { + name: "no-secrets".to_string(), + effect: Effect::Deny, + agents: AgentScope::Any, + action: ActionPredicate::Filesystem { + path_glob: "/workspace/.secrets/**".to_string(), + modes: [FsMode::Read].into_iter().collect(), + }, + }; + let allow = fs_rule("read-ws", "/workspace/**"); + let a = Policy::new(vec![deny.clone(), allow.clone()]); + let b = Policy::new(vec![allow, deny]); + assert_ne!(a.digest_hex(), b.digest_hex()); + } + + #[test] + fn agent_list_order_is_not_in_the_digest_because_it_is_a_set() { + // `AgentScope::OneOf` is matched by membership. Two policies differing + // only in that order are the same policy, and must not look different. + let mk = |ids: [&str; 3]| { + Policy::new(vec![Rule { + name: "r".to_string(), + effect: Effect::Allow, + agents: AgentScope::one_of(ids), + action: ActionPredicate::Process { binary_glob: "/bin/ls".to_string() }, + }]) + }; + assert_eq!( + mk(["a", "b", "c"]).digest_hex(), + mk(["c", "a", "b"]).digest_hex() + ); + // A different membership is a different policy. + assert_ne!(mk(["a", "b", "c"]).digest_hex(), mk(["a", "b", "d"]).digest_hex()); + } + + #[test] + fn no_two_policies_share_a_preimage_by_shifting_a_boundary() { + // Where the length prefixes are actually load-bearing: two *arbitrary* + // strings that sit next to each other in the preimage. The agent id + // list is the only such place — elsewhere a constant tag ("allow", + // "action:filesystem", ...) separates the variable parts, so a rule + // named "ab" with glob "c" is already distinguishable from one named + // "a" with glob "bc" without any prefix at all. + // + // Here nothing separates one id from the next, and the count is the + // same either way, so without the prefixes these two render to the + // identical byte run "abc". + let ids = |a: &str, b: &str| { + Policy::new(vec![Rule { + name: "r".to_string(), + effect: Effect::Allow, + agents: AgentScope::one_of([a, b]), + action: ActionPredicate::Process { binary_glob: "/bin/ls".to_string() }, + }]) + }; + assert_ne!(ids("ab", "c").digest_hex(), ids("a", "bc").digest_hex()); + // Sorting puts both pairs in the same order first, so this is a + // boundary collision and not an ordering difference. + assert_ne!(ids("c", "ab").digest_hex(), ids("bc", "a").digest_hex()); + + // One rule is not two, and the rule count is what says so. + let one = Policy::new(vec![fs_rule("ab", "/x")]); + let two = Policy::new(vec![fs_rule("a", "/x"), fs_rule("b", "/x")]); + assert_ne!(one.digest_hex(), two.digest_hex()); + } + + #[test] + fn a_port_range_is_digested_as_written_not_normalised() { + // `from` > `to` matches nothing; the same pair reversed matches five + // ports. Normalising before hashing would merge an always-deny rule + // with a permissive one. + let mk = |from: u16, to: u16| { + Policy::new(vec![Rule { + name: "r".to_string(), + effect: Effect::Allow, + agents: AgentScope::Any, + action: ActionPredicate::Network { + host_glob: "*".to_string(), + ports: PortRange { from, to }, + methods: [NetMethod::Connect].into_iter().collect(), + }, + }]) + }; + assert!(!PortRange { from: 5, to: 1 }.contains(3), "premise: this matches nothing"); + assert!(PortRange { from: 1, to: 5 }.contains(3)); + assert_ne!(mk(5, 1).digest_hex(), mk(1, 5).digest_hex()); + } + + #[test] + fn an_empty_policy_still_has_a_digest_distinct_from_any_rule() { + let empty = Policy::empty(); + assert_eq!(empty.digest_hex().len(), 64); + assert_ne!(empty.digest_hex(), Policy::new(vec![fs_rule("r", "/x")]).digest_hex()); + } + + #[test] + fn an_audit_event_records_the_policy_that_decided_not_the_one_installed_later() { + // The reason the digest is captured at decision time. Reading it off + // the engine at shipping time would attribute a decision to rules that + // were not installed when it was made. + let engine = InMemoryPolicyEngine::new(Policy::new(vec![fs_rule("read-ws", "/workspace/**")])); + let first = engine.policy_digest().expect("the bundled engine reports one"); + let agent = AgentId::new("did:key:zAlpha"); + engine.evaluate( + &agent, + &Action::Filesystem(FsAction { path: "/workspace/notes.md".into(), mode: FsMode::Read }), + ); + + engine + .install_policy(Policy::new_at_version(vec![fs_rule("read-ws", "/**")], 2)) + .expect("install"); + let second = engine.policy_digest().expect("still reports one"); + assert_ne!(first, second, "the ruleset really changed"); + + let events = engine.audit(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].policy_digest.as_deref(), + Some(first.as_str()), + "the event must still name the rules that decided it" + ); + // A decision after the install names the new rules. + engine.evaluate( + &agent, + &Action::Filesystem(FsAction { path: "/etc/hosts".into(), mode: FsMode::Read }), + ); + assert_eq!(engine.audit()[1].policy_digest.as_deref(), Some(second.as_str())); + // The traced path records it too — it is a second construction site, + // and a digest on one but not the other is a gap that looks like data. + engine.evaluate_traced( + &agent, + &Action::Filesystem(FsAction { path: "/etc/hosts".into(), mode: FsMode::Read }), + ); + assert_eq!(engine.audit()[2].policy_digest.as_deref(), Some(second.as_str())); + } + + #[test] + fn an_engine_that_cannot_hash_its_rules_reports_absence_rather_than_inventing_one() { + struct Remote; + impl PolicyEngine for Remote { + fn evaluate(&self, _a: &AgentId, _x: &Action) -> Decision { + Decision::Deny { reason: DenyReason::NoMatchingAllow } + } + fn audit(&self) -> Vec { + Vec::new() + } + fn audit_since(&self, _since: u64) -> Vec { + Vec::new() + } + fn policy_version(&self) -> u64 { + 7 + } + } + assert_eq!(Remote.policy_digest(), None); + } + + #[test] + fn an_audit_event_without_a_digest_round_trips_as_it_always_did() { + // The field is additive: an event serialized before it existed still + // deserializes, and one with no digest serializes without the key. + // Built by serializing a real event and deleting the key, rather than + // hand-written: a hand-written literal that drifts from the real shape + // fails for the wrong reason and proves nothing about the field. + let current = AuditEvent { + sequence: 1, + policy_version: 1, + policy_digest: Some("deadbeef".to_string()), + agent: AgentId::new("did:key:zAlpha"), + action: Action::Filesystem(FsAction { path: "/x".into(), mode: FsMode::Read }), + decision: Decision::Deny { reason: DenyReason::NoMatchingAllow }, + }; + let mut v: serde_json::Value = serde_json::to_value(¤t).expect("to_value"); + assert!(v.get("policy_digest").is_some(), "premise: the field is on the wire"); + v.as_object_mut().unwrap().remove("policy_digest"); + let legacy = serde_json::to_string(&v).expect("to_string"); + + let e: AuditEvent = serde_json::from_str(&legacy).expect("legacy event still parses"); + assert_eq!(e.policy_digest, None); + let back = serde_json::to_string(&e).expect("to_string"); + assert!(!back.contains("policy_digest"), "absence must not become a null"); + // Compared as values, not strings: `serde_json::Value` sorts keys and + // the struct does not, so a string comparison would fail on ordering + // and say nothing about the field. + assert_eq!( + serde_json::to_value(&e).expect("to_value"), v, + "a digest-less event round-trips to exactly the legacy wire" + ); + let _ = back; + } } -- 2.54.0 (Apple Git-157)