Skip to main content

lean_ctx/core/compliance_evidence/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Signed compliance evidence (GL #677) on the one engine.
3//!
4//! The engine owns everything an auditor must be able to check without a
5//! LeanCTX license: the facts it measured, the enforcement counts folded from
6//! the append-only audit chain ([`aggregate`]), the OWASP alignment table, the
7//! retention posture, the signed artifact ([`model::ComplianceReportV1`]) and
8//! its offline verification, plus the renderings ([`render`], [`pdf`]).
9//!
10//! Framework coverage (EU AI Act, ISO 42001, SOC 2, …) is an Enterprise
11//! capability of the licensed runtime: the engine sends the projection in
12//! [`facts`] and accepts the answer only after [`frameworks::FrameworksAnswer::check`].
13//! The mappings and the per-control assessment never live in this tree.
14
15pub mod aggregate;
16pub mod facts;
17pub mod frameworks;
18pub mod model;
19pub mod pdf;
20pub mod render;
21
22use std::path::{Path, PathBuf};
23
24pub use model::{ComplianceReportV1, ReportVerifyResult};
25
26use crate::core::owasp_alignment;
27use crate::core::policy::{self, ResolvedPolicy};
28use facts::ComplianceFactsV1;
29use frameworks::FrameworksAnswer;
30use model::{
31    AuditSection, EnforcementSection, KIND, OwaspRow, OwaspSection, Period, RetentionSection,
32    SCHEMA_VERSION,
33};
34
35/// What to attest. Empty `frameworks` ⇒ every framework the runtime supports.
36pub struct ReportSpec {
37    /// RFC 3339 inclusive lower bound.
38    pub from: String,
39    /// RFC 3339 inclusive upper bound.
40    pub to: String,
41    pub frameworks: Vec<String>,
42    /// Pack name/path override; defaults to the project pack, else `baseline`.
43    pub pack: Option<String>,
44}
45
46/// Build the unsigned report; `assess` answers the framework part (the
47/// licensed runtime in production). Fails loudly on any inconsistency — a
48/// compliance artifact with a silently missing part is worse than none.
49pub fn build(
50    spec: &ReportSpec,
51    assess: impl FnOnce(&ComplianceFactsV1) -> Result<FrameworksAnswer, String>,
52) -> Result<ComplianceReportV1, String> {
53    let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
54        .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
55    let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
56        .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
57    if from > to {
58        return Err("--from must not be after --to".to_string());
59    }
60
61    let resolved = resolve_pack(spec.pack.as_deref())?;
62    let frameworks = assess_frameworks(&spec.frameworks, Some(&resolved), assess)?;
63    let agg = aggregate::aggregate(from, to)?;
64    let chain_valid = crate::core::audit_trail::verify_chain_full().valid;
65
66    let project = std::env::current_dir()
67        .ok()
68        .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
69        .unwrap_or_else(|| "unknown".to_string());
70
71    Ok(ComplianceReportV1 {
72        schema_version: SCHEMA_VERSION,
73        kind: KIND.to_string(),
74        created_at: chrono::Utc::now().to_rfc3339(),
75        lean_ctx_version: env!("CARGO_PKG_VERSION").to_string(),
76        agent_id: crate::core::agent_identity::current_agent_id().to_string(),
77        project,
78        period: Period {
79            from: spec.from.clone(),
80            to: spec.to.clone(),
81        },
82        owasp: build_owasp(),
83        frameworks: frameworks.frameworks,
84        enforcement: EnforcementSection {
85            blocked: agg.blocked,
86            redacted: agg.redacted,
87            tool_calls: agg.tool_calls,
88            other_security: agg.other_security,
89            by_event: agg.by_event,
90            by_tool_blocked: agg.by_tool_blocked,
91        },
92        audit: AuditSection {
93            entries_in_period: agg.entries,
94            chain_valid,
95            anchor_prev_hash: agg.anchor_prev_hash,
96            head_hash: agg.head_hash,
97        },
98        retention: build_retention(&resolved),
99        signer_public_key: None,
100        signature: None,
101    })
102}
103
104/// Ask `assess` for the framework reports of `requested` against `policy` and
105/// accept only an answer that matches the request (see [`FrameworksAnswer::check`]).
106pub fn assess_frameworks(
107    requested: &[String],
108    policy: Option<&ResolvedPolicy>,
109    assess: impl FnOnce(&ComplianceFactsV1) -> Result<FrameworksAnswer, String>,
110) -> Result<FrameworksAnswer, String> {
111    if let Some(bad) = requested.iter().find(|id| !frameworks::valid_id(id)) {
112        return Err(format!("invalid framework id '{bad}'"));
113    }
114    let projection = facts::facts(requested, policy);
115    let answer = assess(&projection)?;
116    let label = policy.map(facts::pack_label);
117    answer.check(requested, label.as_deref())?;
118    Ok(answer)
119}
120
121/// Resolve the pack to assess: explicit name or `.toml` path, else the project
122/// pack (`.lean-ctx/policy.toml`), else `baseline`.
123pub fn resolve_pack(pack: Option<&str>) -> Result<ResolvedPolicy, String> {
124    let pack_name = pack.map_or_else(
125        || {
126            if Path::new(".lean-ctx/policy.toml").exists() {
127                ".lean-ctx/policy.toml".to_string()
128            } else {
129                "baseline".to_string()
130            }
131        },
132        str::to_owned,
133    );
134    let pack = if Path::new(&pack_name)
135        .extension()
136        .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
137    {
138        policy::parse_file(Path::new(&pack_name)).map_err(|e| format!("pack {pack_name}: {e}"))?
139    } else {
140        policy::builtin::get(&pack_name).ok_or_else(|| {
141            format!(
142                "pack {pack_name}: {}",
143                policy::builtin::unknown_pack_hint(&pack_name)
144            )
145        })?
146    };
147    policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))
148}
149
150/// Project the static OWASP alignment table into a report section. `minimal`
151/// stays in the v1 format and is always 0 since the table has no such row.
152fn build_owasp() -> OwaspSection {
153    let mappings = owasp_alignment::alignment();
154    let label = |c: owasp_alignment::Coverage| match c {
155        owasp_alignment::Coverage::Full => "full",
156        owasp_alignment::Coverage::Partial => "partial",
157    };
158    let count = |c: owasp_alignment::Coverage| mappings.iter().filter(|m| m.coverage == c).count();
159    OwaspSection {
160        full: count(owasp_alignment::Coverage::Full),
161        partial: count(owasp_alignment::Coverage::Partial),
162        minimal: 0,
163        rows: mappings
164            .iter()
165            .map(|m| OwaspRow {
166                id: m.owasp_id.to_string(),
167                title: m.owasp_title.to_string(),
168                coverage: label(m.coverage).to_string(),
169            })
170            .collect(),
171    }
172}
173
174/// Retention posture: the pack's declared intent vs. the plan window. The
175/// engine verifies no plan of its own (accounts live in LeanCTX | Hive), so
176/// the plan window is reported as unverified. An unverified window cannot
177/// answer whether it covers the pack's intent, so `plan_covers_policy` stays
178/// unset instead of claiming `false` for every pack (F11); the local trail
179/// itself never deletes sealed segments.
180fn build_retention(resolved: &ResolvedPolicy) -> RetentionSection {
181    let plan = crate::core::product_capabilities::Plan::Community;
182    RetentionSection {
183        policy_pack: Some(facts::pack_label(resolved)),
184        policy_audit_retention_days: resolved.audit_retention_days,
185        plan: plan.as_str().to_string(),
186        plan_source: UNVERIFIED_PLAN.to_string(),
187        plan_audit_retention_days: 0,
188        plan_covers_policy: None,
189    }
190}
191
192/// `plan_source` of a plan window the engine could not verify.
193pub(crate) const UNVERIFIED_PLAN: &str = "unverified";
194
195/// Default artifact location: `<data_dir>/compliance/report-v1_<utc-stamp>.json`.
196pub fn default_artifact_path() -> Result<PathBuf, String> {
197    let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("compliance");
198    std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir compliance: {e}"))?;
199    let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
200    Ok(dir.join(format!("report-v1_{stamp}.json")))
201}
202
203/// Pretty-prints the signed JSON artifact to `out` (creating parent dirs).
204pub fn write_artifact(report: &ComplianceReportV1, out: &Path) -> Result<PathBuf, String> {
205    if let Some(parent) = out.parent() {
206        std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
207    }
208    let json = serde_json::to_string_pretty(report).map_err(|e| format!("serialize: {e}"))?;
209    std::fs::write(out, json).map_err(|e| format!("write {}: {e}", out.display()))?;
210    Ok(out.to_path_buf())
211}
212
213/// Loads and parses a signed report artifact, rejecting unrelated JSON by `kind`.
214pub fn load_artifact(path: &Path) -> Result<ComplianceReportV1, String> {
215    let raw = std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
216    let report: ComplianceReportV1 =
217        serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
218    if report.kind != KIND {
219        return Err(format!("not a {KIND} artifact (kind = {:?})", report.kind));
220    }
221    Ok(report)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn spec(from: &str, to: &str, frameworks: &[&str]) -> ReportSpec {
229        ReportSpec {
230            from: from.into(),
231            to: to.into(),
232            frameworks: frameworks.iter().map(|s| (*s).to_string()).collect(),
233            pack: Some("strict-redaction".into()),
234        }
235    }
236
237    /// The report binds the runtime's assessment to the pack it measured, and
238    /// refuses an answer for another pack, an inverted period or a bad id.
239    #[test]
240    fn build_binds_assessment_to_the_measured_pack() {
241        let _data = crate::core::data_dir::isolated_data_dir();
242        let label = facts::pack_label(&resolve_pack(Some("strict-redaction")).unwrap());
243        let ok = |facts: &ComplianceFactsV1| {
244            assert_eq!(
245                facts.policy.as_ref().map(|p| p.pack.as_str()),
246                Some(label.as_str())
247            );
248            Ok(frameworks::tests::answer(Some(&label)))
249        };
250        let report = build(
251            &spec(
252                "2026-01-01T00:00:00+00:00",
253                "2026-12-31T00:00:00+00:00",
254                &["framework-a"],
255            ),
256            ok,
257        )
258        .unwrap();
259        assert_eq!(report.kind, KIND);
260        assert_eq!(report.frameworks.len(), 1);
261        assert_eq!(report.owasp.rows.len(), 10);
262        assert!(
263            report.signature.is_none(),
264            "build yields an unsigned report"
265        );
266
267        let foreign = |_: &ComplianceFactsV1| Ok(frameworks::tests::answer(Some("other v9")));
268        let inverted = |_: &ComplianceFactsV1| -> Result<FrameworksAnswer, String> {
269            unreachable!("an inverted period never reaches the runtime")
270        };
271        let year = ("2026-01-01T00:00:00+00:00", "2026-12-31T00:00:00+00:00");
272        assert!(build(&spec(year.0, year.1, &["framework-a"]), foreign).is_err());
273        assert!(build(&spec(year.1, year.0, &[]), inverted).is_err());
274        assert!(build(&spec(year.0, year.1, &["../etc"]), inverted).is_err());
275    }
276
277    /// F11: an unverified plan window never claims it fails to cover the
278    /// pack's declared retention, and renders as unverified, not 0 days.
279    #[test]
280    fn unverified_plan_makes_no_retention_coverage_claim() {
281        let resolved = resolve_pack(Some("strict-redaction")).unwrap();
282        assert_eq!(resolved.audit_retention_days, Some(180));
283        let retention = build_retention(&resolved);
284        assert_eq!(retention.plan_source, UNVERIFIED_PLAN);
285        assert_eq!(retention.plan_covers_policy, None);
286
287        let mut report = model::tests::sample();
288        report.retention = retention;
289        let text = render::to_text(&report);
290        assert!(text.contains("Plan retention:     unverified"), "{text}");
291        assert!(!text.contains("Plan covers intent"), "{text}");
292        assert!(render::to_csv(&report).contains("plan=unverified"));
293    }
294}