Skip to main content

lean_ctx/core/compliance_evidence/
model.rs

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