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; + } }