Skip to main content

lean_ctx/core/compliance_report/
model.rs

1//! The signed compliance-report artifact (GL #677).
2//!
3//! [`ComplianceReportV1`] is the deliverable a CISO hands an auditor: OWASP
4//! Top-10-for-Agents coverage, framework coverage (EU AI Act / ISO 42001 /
5//! SOC 2), what enforcement **blocked/redacted** over a date range, and the
6//! retention posture — all bound together and **Ed25519-signed**.
7//!
8//! Signing mirrors [`crate::core::savings_ledger::signed_batch`]: the two
9//! signature fields are cleared while computing the canonical bytes, so a
10//! verifier reproduces the exact signed payload from the artifact alone and
11//! checks it **offline, without the audit trail or LeanCTX**. The embedded
12//! `audit.head_hash` binds the report's counts to the precise append-only
13//! audit segment that produced them.
14
15use ed25519_dalek::{Signer, SigningKey};
16use serde::{Deserialize, Serialize};
17
18use crate::core::compliance::FrameworkReport;
19
20pub const SCHEMA_VERSION: u32 = 1;
21pub const KIND: &str = "lean-ctx.compliance-report";
22
23/// Coverage window (inclusive RFC 3339 bounds).
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Period {
26    pub from: String,
27    pub to: String,
28}
29
30/// One OWASP-Top-10-for-Agents row, copied from the static alignment table.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct OwaspRow {
33    pub id: String,
34    pub title: String,
35    /// `full` | `partial` | `minimal`.
36    pub coverage: String,
37}
38
39/// OWASP alignment section.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct OwaspSection {
42    pub full: usize,
43    pub partial: usize,
44    pub minimal: usize,
45    pub rows: Vec<OwaspRow>,
46}
47
48/// What enforcement did over the period — privacy-preserving counts only.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EnforcementSection {
51    /// `ToolDenied` events (role / policy-pack / egress blocks).
52    pub blocked: usize,
53    /// `SecretDetected` events (redaction fired on output).
54    pub redacted: usize,
55    /// `ToolCall` events (the allowed-action denominator).
56    pub tool_calls: usize,
57    /// Other non-`ToolCall` security events.
58    pub other_security: usize,
59    /// `(event_label, count)`, sorted by label.
60    pub by_event: Vec<(String, usize)>,
61    /// `(tool, blocked_count)`, top rows by count.
62    pub by_tool_blocked: Vec<(String, usize)>,
63}
64
65/// The audit segment that backs the enforcement counts.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AuditSection {
68    pub entries_in_period: usize,
69    /// Whole-chain SHA-256 integrity at report time.
70    pub chain_valid: bool,
71    /// `prev_hash` of the first in-window entry.
72    pub anchor_prev_hash: String,
73    /// `entry_hash` of the last in-window entry.
74    pub head_hash: String,
75}
76
77/// Retention posture: the pack's governance intent vs. the plan entitlement.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RetentionSection {
80    /// Resolved pack identity (`name vX.Y.Z`) the report was assessed against.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub policy_pack: Option<String>,
83    /// `audit_retention_days` declared by the resolved pack (governance intent).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub policy_audit_retention_days: Option<u32>,
86    /// Effective commercial plan id (`free`, `team`, `business`, …).
87    pub plan: String,
88    /// Where the plan came from (`live`, `cached`, `unverified`).
89    pub plan_source: String,
90    /// `audit_retention_days` entitlement of the effective plan (hosted plane).
91    pub plan_audit_retention_days: u32,
92    /// `Some(true)` when the plan window covers the pack's declared intent.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub plan_covers_policy: Option<bool>,
95}
96
97/// Outcome of verifying a [`ComplianceReportV1`] signature — offline.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ReportVerifyResult {
100    pub signature_valid: bool,
101    pub signer_public_key: Option<String>,
102    pub error: Option<String>,
103}
104
105/// A signed, exportable CISO compliance report over one date range.
106///
107/// `signature` / `signer_public_key` are excluded from the signed payload (set
108/// to `None` while computing the canonical bytes), exactly like
109/// [`crate::core::savings_ledger::signed_batch::SignedSavingsBatchV1`].
110#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct ComplianceReportV1 {
112    pub schema_version: u32,
113    /// Discriminator so a verifier can refuse unrelated signed JSON.
114    pub kind: String,
115    pub created_at: String,
116    pub lean_ctx_version: String,
117    pub agent_id: String,
118    pub project: String,
119    pub period: Period,
120    pub owasp: OwaspSection,
121    pub frameworks: Vec<FrameworkReport>,
122    pub enforcement: EnforcementSection,
123    pub audit: AuditSection,
124    pub retention: RetentionSection,
125    /// Ed25519 public key (hex). `None` until signed.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub signer_public_key: Option<String>,
128    /// Ed25519 signature over the canonical bytes (hex). `None` until signed.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub signature: Option<String>,
131}
132
133impl ComplianceReportV1 {
134    /// Deterministic bytes that get signed/verified: the whole struct with the
135    /// two signature fields cleared. Identical on sign and verify.
136    pub fn canonical_bytes(&self) -> Result<Vec<u8>, String> {
137        let mut clone = self.clone();
138        clone.signature = None;
139        clone.signer_public_key = None;
140        serde_json::to_vec(&clone).map_err(|e| format!("serialize for signing: {e}"))
141    }
142
143    /// Signs with the persistent machine identity (`agent_identity` keystore).
144    pub fn sign(&mut self, agent_id: &str) -> Result<(), String> {
145        let key = crate::core::agent_identity::get_or_create_keypair(agent_id)?;
146        self.sign_with_key(&key)
147    }
148
149    /// Signs with an explicit key (used by `sign` and by hermetic tests). The
150    /// public key is embedded so the artifact is self-verifying.
151    pub fn sign_with_key(&mut self, key: &SigningKey) -> Result<(), String> {
152        self.signature = None;
153        self.signer_public_key = None;
154        let canonical = self.canonical_bytes()?;
155        let sig = key.sign(&canonical);
156        self.signer_public_key = Some(crate::core::agent_identity::hex_encode(
157            &key.verifying_key().to_bytes(),
158        ));
159        self.signature = Some(crate::core::agent_identity::hex_encode(&sig.to_bytes()));
160        Ok(())
161    }
162
163    /// Verifies the embedded signature against the embedded public key —
164    /// offline, no audit trail needed. A failure means the artifact was altered
165    /// or was never validly signed.
166    pub fn verify(&self) -> ReportVerifyResult {
167        let fail = |msg: &str| ReportVerifyResult {
168            signature_valid: false,
169            signer_public_key: self.signer_public_key.clone(),
170            error: Some(msg.to_string()),
171        };
172        let (Some(sig_hex), Some(pk_hex)) = (&self.signature, &self.signer_public_key) else {
173            return fail("artifact is not signed");
174        };
175        let (Ok(sig_bytes), Ok(pk_bytes)) = (
176            crate::core::agent_identity::hex_decode(sig_hex),
177            crate::core::agent_identity::hex_decode(pk_hex),
178        ) else {
179            return fail("malformed signature or public key hex");
180        };
181        let canonical = match self.canonical_bytes() {
182            Ok(c) => c,
183            Err(e) => return fail(&e),
184        };
185        if crate::core::agent_identity::verify_signature(&pk_bytes, &canonical, &sig_bytes) {
186            ReportVerifyResult {
187                signature_valid: true,
188                signer_public_key: Some(pk_hex.clone()),
189                error: None,
190            }
191        } else {
192            fail("signature does not match payload (tampered or wrong key)")
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::core::compliance;
201    use crate::core::policy::{builtin, resolve};
202
203    fn sample() -> ComplianceReportV1 {
204        let mapping = compliance::get("soc2").unwrap();
205        let resolved = resolve(&builtin::get("soc2-context").unwrap()).unwrap();
206        let report = compliance::report(mapping, Some(&resolved));
207        ComplianceReportV1 {
208            schema_version: SCHEMA_VERSION,
209            kind: KIND.to_string(),
210            created_at: "2026-06-15T00:00:00+00:00".to_string(),
211            lean_ctx_version: "test".to_string(),
212            agent_id: "local".to_string(),
213            project: "proj".to_string(),
214            period: Period {
215                from: "2026-05-01T00:00:00+00:00".to_string(),
216                to: "2026-06-01T00:00:00+00:00".to_string(),
217            },
218            owasp: OwaspSection {
219                full: 8,
220                partial: 2,
221                minimal: 0,
222                rows: vec![OwaspRow {
223                    id: "OWASP-AGENT-01".to_string(),
224                    title: "Excessive Agency".to_string(),
225                    coverage: "full".to_string(),
226                }],
227            },
228            frameworks: vec![report],
229            enforcement: EnforcementSection {
230                blocked: 3,
231                redacted: 5,
232                tool_calls: 100,
233                other_security: 0,
234                by_event: vec![("tool_denied".to_string(), 3)],
235                by_tool_blocked: vec![("ctx_url_read".to_string(), 3)],
236            },
237            audit: AuditSection {
238                entries_in_period: 108,
239                chain_valid: true,
240                anchor_prev_hash: "genesis".to_string(),
241                head_hash: "abc123".to_string(),
242            },
243            retention: RetentionSection {
244                policy_pack: Some("soc2-context v1.0.0".to_string()),
245                policy_audit_retention_days: Some(365),
246                plan: "free".to_string(),
247                plan_source: "unverified".to_string(),
248                plan_audit_retention_days: 0,
249                plan_covers_policy: Some(false),
250            },
251            signer_public_key: None,
252            signature: None,
253        }
254    }
255
256    fn key() -> SigningKey {
257        let mut seed = [0u8; 32];
258        getrandom::fill(&mut seed).unwrap();
259        SigningKey::from_bytes(&seed)
260    }
261
262    #[test]
263    fn canonical_bytes_exclude_signature_fields() {
264        let mut r = sample();
265        let before = r.canonical_bytes().unwrap();
266        r.signature = Some("deadbeef".into());
267        r.signer_public_key = Some("cafe".into());
268        let after = r.canonical_bytes().unwrap();
269        assert_eq!(
270            before, after,
271            "signature fields must not affect signed bytes"
272        );
273    }
274
275    #[test]
276    fn sign_then_verify_roundtrips() {
277        let mut r = sample();
278        r.sign_with_key(&key()).unwrap();
279        assert!(
280            r.verify().signature_valid,
281            "freshly signed report must verify"
282        );
283    }
284
285    #[test]
286    fn verify_detects_tampered_counts() {
287        let mut r = sample();
288        r.sign_with_key(&key()).unwrap();
289        r.enforcement.blocked = 999;
290        assert!(
291            !r.verify().signature_valid,
292            "edited counts must fail verification"
293        );
294    }
295
296    #[test]
297    fn verify_detects_tampered_audit_head() {
298        let mut r = sample();
299        r.sign_with_key(&key()).unwrap();
300        r.audit.head_hash = "0000".into();
301        assert!(
302            !r.verify().signature_valid,
303            "rewriting the chain head must fail"
304        );
305    }
306
307    #[test]
308    fn verify_rejects_unsigned() {
309        assert!(!sample().verify().signature_valid);
310    }
311
312    #[test]
313    fn json_roundtrip_is_byte_faithful_and_verifies() {
314        let mut r = sample();
315        r.sign_with_key(&key()).unwrap();
316        let json = serde_json::to_string_pretty(&r).unwrap();
317        let loaded: ComplianceReportV1 = serde_json::from_str(&json).unwrap();
318        assert_eq!(loaded, r, "round-trip must preserve every field");
319        assert!(
320            loaded.verify().signature_valid,
321            "loaded artifact still verifies"
322        );
323    }
324}