jouleclaw: bind a policy receipt to the rules, not to a version number #177

Merged
dcharlot merged 1 commit from feat/policy-digest into main 2026-10-09 02:49:56 -04:00
10 changed files with 728 additions and 6 deletions

View file

@ -377,12 +377,71 @@ wire shape.
"engine_id": "<id>",
"audit_sequence": 7,
"policy_version": 2,
"policy_digest": "<blake3 hex, OPTIONAL>",
"agent": "<did:key handle>",
"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

View file

@ -4443,6 +4443,7 @@ dependencies = [
name = "jouleclaw-policy"
version = "0.1.0"
dependencies = [
"blake3",
"serde",
"serde_json",
"thiserror 2.0.20",

View file

@ -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,

View file

@ -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,

View file

@ -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(),

View file

@ -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"
}
]
}

View file

@ -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");

View file

@ -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<String>,
/// 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()));
}
}

View file

@ -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]

View file

@ -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<String>,
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<AuditEvent>;
/// 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<String> {
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<String> {
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<u64, PolicyError> {
// 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<AuditEvent> {
Vec::new()
}
fn audit_since(&self, _since: u64) -> Vec<AuditEvent> {
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(&current).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;
}
}