Skip to main content

mnemo_mesh/
lib.rs

1//! v0.4.0 (P0-2) — Cloudflare Mesh runtime adapter.
2//!
3//! Cloudflare Mesh (announced 2026-04-24) defines the
4//! lifecycle-attestation envelope agent infrastructure is moving to:
5//! every workload presents a SPIFFE-style identity + an attestation
6//! token, and every privileged op carries an audit envelope back to
7//! a chained ledger. This crate makes Mnemo speak that protocol so
8//! Mesh-deployed agents can use Mnemo as their memory plane without
9//! losing the lifecycle-attestation chain.
10//!
11//! Three pieces:
12//!
13//! 1. [`identity::MeshIdentity`] — the (workload_spiffe_id,
14//!    attestation_token) pair the caller presents on every op.
15//! 2. [`policy::MeshPolicyEnforcer`] — pluggable ACL that decides
16//!    whether the caller can perform a [`MemOp`] against a
17//!    [`Namespace`].
18//! 3. [`MeshAuditEnvelope`] — chained-HMAC envelope that links each
19//!    decision back to the existing memory-provenance chain head, so
20//!    audit-log export emits one continuous ledger instead of two
21//!    parallel ones.
22
23pub mod identity;
24pub mod policy;
25
26use std::time::SystemTime;
27
28use serde::{Deserialize, Serialize};
29use sha2::{Digest, Sha256};
30
31pub use identity::MeshIdentity;
32pub use policy::{MeshPolicyEnforcer, PolicyDecision, StaticPolicyEnforcer};
33
34/// Tenant + scope qualifier the policy decides against. Matches
35/// Cloudflare Mesh namespace shape: `<tenant>/<scope>`.
36#[derive(Debug, Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
37pub struct Namespace {
38    pub tenant: String,
39    pub scope: String,
40}
41
42impl Namespace {
43    pub fn new(tenant: impl Into<String>, scope: impl Into<String>) -> Self {
44        Self {
45            tenant: tenant.into(),
46            scope: scope.into(),
47        }
48    }
49
50    pub fn as_label(&self) -> String {
51        format!("{}/{}", self.tenant, self.scope)
52    }
53}
54
55/// The privileged operations Mesh ACLs gate. Matches the verbs an
56/// LLM-host agent could try to invoke against Mnemo. New verbs land
57/// here when new privileged tools appear in the MCP catalog.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)]
59pub enum MemOp {
60    Recall,
61    Write,
62    Forget,
63    Branch,
64    ReplayAsOf,
65    ExportProvenance,
66}
67
68impl MemOp {
69    pub fn as_str(&self) -> &'static str {
70        match self {
71            MemOp::Recall => "recall",
72            MemOp::Write => "write",
73            MemOp::Forget => "forget",
74            MemOp::Branch => "branch",
75            MemOp::ReplayAsOf => "replay_as_of",
76            MemOp::ExportProvenance => "export_provenance",
77        }
78    }
79}
80
81/// Audit envelope appended to the chained ledger after every authorized
82/// op. The `prev_chain_head` matches the existing
83/// `mnemo-core::provenance` HMAC chain, so an export joins memory
84/// receipts and Mesh decisions on a single timeline.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct MeshAuditEnvelope {
87    pub caller_spiffe: String,
88    pub op: MemOp,
89    pub namespace: Namespace,
90    pub decided: PolicyDecision,
91    /// HMAC of the existing provenance chain right before this op.
92    /// 32 raw bytes; serialise as hex/base64 at the wire boundary.
93    pub prev_chain_head: [u8; 32],
94    pub envelope_at: SystemTime,
95}
96
97impl MeshAuditEnvelope {
98    /// Hash this envelope into the next chain head. Pure fn so the
99    /// audit-log writer can compute heads without touching the
100    /// caller's HMAC key.
101    pub fn next_chain_head(&self, prev_head: &[u8; 32]) -> [u8; 32] {
102        let mut h = Sha256::new();
103        h.update(prev_head);
104        h.update(self.caller_spiffe.as_bytes());
105        h.update(b"|");
106        h.update(self.op.as_str().as_bytes());
107        h.update(b"|");
108        h.update(self.namespace.as_label().as_bytes());
109        h.update(b"|");
110        h.update(self.decided.as_str().as_bytes());
111        h.update(b"|");
112        h.update(
113            chrono::DateTime::<chrono::Utc>::from(self.envelope_at)
114                .to_rfc3339()
115                .as_bytes(),
116        );
117        h.finalize().into()
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    fn env(decision: PolicyDecision) -> MeshAuditEnvelope {
126        MeshAuditEnvelope {
127            caller_spiffe: "spiffe://t1/a1".into(),
128            op: MemOp::Recall,
129            namespace: Namespace::new("t1", "shared"),
130            decided: decision,
131            prev_chain_head: [0u8; 32],
132            envelope_at: SystemTime::UNIX_EPOCH,
133        }
134    }
135
136    #[test]
137    fn next_chain_head_is_deterministic() {
138        let e = env(PolicyDecision::Allow);
139        let h1 = e.next_chain_head(&[0u8; 32]);
140        let h2 = e.next_chain_head(&[0u8; 32]);
141        assert_eq!(h1, h2);
142    }
143
144    #[test]
145    fn next_chain_head_changes_with_decision() {
146        let allow = env(PolicyDecision::Allow);
147        let deny = env(PolicyDecision::DenyMissingIdentity);
148        assert_ne!(
149            allow.next_chain_head(&[0u8; 32]),
150            deny.next_chain_head(&[0u8; 32])
151        );
152    }
153
154    #[test]
155    fn next_chain_head_changes_with_namespace() {
156        let mut e = env(PolicyDecision::Allow);
157        let h1 = e.next_chain_head(&[1u8; 32]);
158        e.namespace = Namespace::new("t1", "private");
159        let h2 = e.next_chain_head(&[1u8; 32]);
160        assert_ne!(h1, h2);
161    }
162
163    #[test]
164    fn memop_strings_round_trip() {
165        for op in [
166            MemOp::Recall,
167            MemOp::Write,
168            MemOp::Forget,
169            MemOp::Branch,
170            MemOp::ReplayAsOf,
171            MemOp::ExportProvenance,
172        ] {
173            let s = op.as_str();
174            assert!(!s.is_empty());
175            assert_eq!(s, s.to_lowercase());
176        }
177    }
178}