lean_ctx/core/compliance_report/
model.rs1use 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct Period {
26 pub from: String,
27 pub to: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct OwaspRow {
33 pub id: String,
34 pub title: String,
35 pub coverage: String,
37}
38
39#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct EnforcementSection {
51 pub blocked: usize,
53 pub redacted: usize,
55 pub tool_calls: usize,
57 pub other_security: usize,
59 pub by_event: Vec<(String, usize)>,
61 pub by_tool_blocked: Vec<(String, usize)>,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct AuditSection {
68 pub entries_in_period: usize,
69 pub chain_valid: bool,
71 pub anchor_prev_hash: String,
73 pub head_hash: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct RetentionSection {
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub policy_pack: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
85 pub policy_audit_retention_days: Option<u32>,
86 pub plan: String,
88 pub plan_source: String,
90 pub plan_audit_retention_days: u32,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub plan_covers_policy: Option<bool>,
95}
96
97#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111pub struct ComplianceReportV1 {
112 pub schema_version: u32,
113 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 #[serde(skip_serializing_if = "Option::is_none")]
127 pub signer_public_key: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 pub signature: Option<String>,
131}
132
133impl ComplianceReportV1 {
134 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 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 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 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}