Skip to main content

lean_ctx/core/
evidence_bundle.rs

1//! Evidence bundle generator (`evidence-bundle-v1`, GL #425, H3 Epic A).
2//!
3//! Composes the engine's evidence surfaces — audit-chain segment, resolved
4//! policy pack, coverage reports — into one deterministic, offline-
5//! verifiable ZIP. The independent verifier lives in
6//! `packages/leanctx-verify/` and implements the contract
7//! (`docs/contracts/evidence-bundle-v1.md`), not this code.
8//!
9//! Determinism: identical inputs ⇒ byte-identical archive (sorted entry
10//! order, Stored compression, ZIP-epoch timestamps, canonical JSON, no
11//! wall-clock fields in the manifest).
12
13use serde_json::{Value, json};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use super::audit_trail::AuditEntry;
18use super::canonical::{canonical_serialize, sign_manifest};
19
20const SIGNING_AGENT: &str = "lean-ctx";
21
22pub struct BundleSpec {
23    /// RFC 3339 inclusive lower bound.
24    pub from: String,
25    /// RFC 3339 inclusive upper bound.
26    pub to: String,
27    /// Pack name/path override; defaults to the project pack, else
28    /// `baseline`.
29    pub pack: Option<String>,
30    pub out: Option<PathBuf>,
31}
32
33#[derive(Debug)]
34pub struct BundleResult {
35    pub path: PathBuf,
36    pub sha256: String,
37    pub entries: usize,
38    pub files: Vec<String>,
39}
40
41/// Build a signed evidence bundle from already-materialized run artifacts.
42///
43/// Unlike [`generate`], this does not infer an audit period or policy pack.
44/// It is for flows that have already captured their complete evidence set and
45/// need an offline-verifiable archive containing those exact bytes.
46pub fn generate_artifact_bundle(
47    out: &Path,
48    mut files: Vec<(String, Vec<u8>)>,
49    run_metadata: &Value,
50    signing_key: &super::canonical::AgentIdentity,
51) -> Result<BundleResult, String> {
52    if files.is_empty() {
53        return Err("evidence artifact bundle requires at least one file".to_string());
54    }
55
56    files.sort_by(|left, right| left.0.cmp(&right.0));
57    let mut seen_paths = std::collections::BTreeSet::new();
58    for (path, _) in &files {
59        if path == "manifest.json" || !safe_artifact_path(path) {
60            return Err(format!("unsafe evidence artifact path: {path}"));
61        }
62        if !seen_paths.insert(path) {
63            return Err(format!("duplicate evidence artifact path: {path}"));
64        }
65    }
66
67    let project = std::env::current_dir()
68        .ok()
69        .and_then(|dir| {
70            dir.file_name()
71                .map(|name| name.to_string_lossy().into_owned())
72        })
73        .unwrap_or_else(|| "unknown".to_string());
74    let file_hashes: Vec<Value> = files
75        .iter()
76        .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
77        .collect();
78    let public_key =
79        crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
80    let mut manifest = json!({
81        "bundle": "evidence-bundle",
82        "version": 1,
83        "subject": { "agent_id": SIGNING_AGENT, "project": project },
84        "run": run_metadata,
85        "files": file_hashes,
86        "signing": {
87            "algorithm": "ed25519",
88            "public_key": public_key,
89            "signed_digest": "",
90            "signature": "",
91        },
92    });
93
94    // The offline verifier signs the canonical SHA-256 digest with both
95    // signing fields reset to empty strings.  Preserve that contract here.
96    let digest = sha256_hex(&canonical_serialize(&manifest));
97    manifest["signing"]["signed_digest"] = Value::String(digest.clone());
98    let signature = crate::core::agent_identity::hex_encode(
99        &crate::core::agent_identity::sign_bytes_with(signing_key, digest.as_bytes()),
100    );
101    manifest["signing"]["signature"] = Value::String(signature);
102
103    files.push(("manifest.json".to_string(), canonical_serialize(&manifest)));
104    files.sort_by(|left, right| left.0.cmp(&right.0));
105
106    let mut bytes = Vec::new();
107    {
108        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut bytes));
109        let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
110            .compression_method(zip::CompressionMethod::Stored)
111            .last_modified_time(zip::DateTime::default());
112        for (path, contents) in &files {
113            zip.start_file(path, options)
114                .map_err(|error| error.to_string())?;
115            zip.write_all(contents).map_err(|error| error.to_string())?;
116        }
117        zip.finish().map_err(|error| error.to_string())?;
118    }
119    std::fs::write(out, &bytes).map_err(|error| format!("write {}: {error}", out.display()))?;
120
121    Ok(BundleResult {
122        path: out.to_path_buf(),
123        sha256: sha256_hex(&bytes),
124        entries: files.len() - 1,
125        files: files.into_iter().map(|(path, _)| path).collect(),
126    })
127}
128
129fn safe_artifact_path(path: &str) -> bool {
130    let path = Path::new(path);
131    !path.is_absolute()
132        && !path
133            .components()
134            .any(|component| matches!(component, std::path::Component::ParentDir))
135}
136
137fn sha256_hex(bytes: &[u8]) -> String {
138    use sha2::{Digest, Sha256};
139    let mut hasher = Sha256::new();
140    hasher.update(bytes);
141    crate::core::agent_identity::hex_encode(&hasher.finalize())
142}
143
144/// Generate the bundle. Fails loudly on every inconsistency — an evidence
145/// artifact with silently missing parts would be worse than none.
146/// Answers the framework part of a bundle (the licensed runtime in production).
147pub type AssessFrameworks =
148    fn(
149        &crate::core::compliance_evidence::facts::ComplianceFactsV1,
150    ) -> Result<crate::core::compliance_evidence::frameworks::FrameworksAnswer, String>;
151
152pub fn generate(spec: &BundleSpec) -> Result<BundleResult, String> {
153    generate_with(spec, None)
154}
155
156/// [`generate`], plus the coverage report of one framework (`coverage/<id>.json`,
157/// `manifest.framework`) when `framework` names it and how to assess it.
158pub fn generate_with(
159    spec: &BundleSpec,
160    framework: Option<(&str, AssessFrameworks)>,
161) -> Result<BundleResult, String> {
162    let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
163        .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
164    let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
165        .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
166    if from > to {
167        return Err("--from must not be after --to".to_string());
168    }
169
170    // ── audit segment (original lines preserved — hash-stable) ──────────
171    let trail_path = crate::core::data_dir::lean_ctx_data_dir()
172        .map_err(|e| format!("data dir: {e}"))?
173        .join("audit")
174        .join("trail.jsonl");
175    // Sealed segments that end before the period are not read.
176    let trail_raw = crate::core::audit_trail::read_trail_since(&trail_path, Some(from.to_utc()))
177        .map_err(|e| format!("no audit trail at {}: {e}", trail_path.display()))?
178        .ok_or_else(|| format!("no audit trail at {}", trail_path.display()))?;
179
180    let mut segment_lines: Vec<String> = Vec::new();
181    let mut anchor_prev_hash: Option<String> = None;
182    let mut head_hash = String::new();
183    for (lineno, line) in trail_raw.lines().enumerate() {
184        // Concurrent appends have historically produced lines holding two
185        // back-to-back JSON objects (`…}{…`). A stream deserializer splits
186        // them losslessly — entry hashes are untouched, so the chain still
187        // proves integrity. Lines that don't parse AT ALL are tolerated
188        // only outside the attested window; inside it they are a hard
189        // error — an evidence artifact must never paper over a gap.
190        let mut stream = serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>();
191        let mut parsed_any = false;
192        loop {
193            let value = match stream.next() {
194                None => break,
195                Some(Ok(v)) => v,
196                Some(Err(e)) => {
197                    if segment_lines.is_empty() && !parsed_any {
198                        break; // pre-window garbage
199                    }
200                    return Err(format!(
201                        "corrupt trail line {} inside the attested period: {e}",
202                        lineno + 1
203                    ));
204                }
205            };
206            parsed_any = true;
207            let entry: AuditEntry = match serde_json::from_value(value) {
208                Ok(e) => e,
209                Err(e) => {
210                    if segment_lines.is_empty() {
211                        continue;
212                    }
213                    return Err(format!("malformed audit entry at line {}: {e}", lineno + 1));
214                }
215            };
216            let ts = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
217                .map_err(|e| format!("corrupt trail timestamp at line {}: {e}", lineno + 1))?;
218            if ts < from || ts > to {
219                continue;
220            }
221            if anchor_prev_hash.is_none() {
222                anchor_prev_hash = Some(entry.prev_hash.clone());
223            }
224            head_hash.clone_from(&entry.entry_hash);
225            // Re-serialize one-object-per-line; field order is the struct's
226            // declaration order, identical to what `record()` writes.
227            segment_lines.push(serde_json::to_string(&entry).map_err(|e| e.to_string())?);
228        }
229    }
230    if segment_lines.is_empty() {
231        return Err(format!(
232            "no audit entries between {} and {} — nothing to attest",
233            spec.from, spec.to
234        ));
235    }
236    let audit_jsonl = format!("{}\n", segment_lines.join("\n"));
237    let anchor_prev_hash = anchor_prev_hash.expect("non-empty segment");
238
239    // ── policy pack (resolved view) ──────────────────────────────────────
240    let pack_name = spec.pack.clone().unwrap_or_else(|| {
241        if Path::new(".lean-ctx/policy.toml").exists() {
242            ".lean-ctx/policy.toml".to_string()
243        } else {
244            "baseline".to_string()
245        }
246    });
247    let pack = if Path::new(&pack_name)
248        .extension()
249        .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
250    {
251        crate::core::policy::parse_file(Path::new(&pack_name))
252            .map_err(|e| format!("pack {pack_name}: {e}"))?
253    } else {
254        crate::core::policy::builtin::get(&pack_name)
255            .ok_or_else(|| crate::core::policy::builtin::unknown_pack_hint(&pack_name))?
256    };
257    let resolved =
258        crate::core::policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))?;
259    let resolved_value = serde_json::to_value(&resolved).map_err(|e| e.to_string())?;
260    let policy_file = (
261        format!("policies/{}.resolved.json", resolved.name),
262        canonical_serialize(&resolved_value),
263    );
264
265    // ── coverage reports ─────────────────────────────────────────────────
266    let cgb_checks = crate::core::policy::coverage::assess(&resolved);
267    let cgb_doc = json!({
268        "benchmark": crate::core::policy::coverage::BENCHMARK_ID,
269        "pack": { "name": resolved.name, "version": resolved.version },
270        "checks": serde_json::to_value(&cgb_checks).map_err(|e| e.to_string())?,
271        "summary": serde_json::to_value(crate::core::policy::coverage::summarize(&cgb_checks))
272            .map_err(|e| e.to_string())?,
273    });
274    let mut files: Vec<(String, Vec<u8>)> = vec![
275        ("audit/trail.jsonl".to_string(), audit_jsonl.into_bytes()),
276        policy_file,
277        (
278            "coverage/cgb.json".to_string(),
279            canonical_serialize(&cgb_doc),
280        ),
281    ];
282
283    if let Some((id, assess)) = framework {
284        let mut answer = crate::core::compliance_evidence::assess_frameworks(
285            &[id.to_string()],
286            Some(&resolved),
287            assess,
288        )?;
289        let report = answer.frameworks.remove(0);
290        let value = serde_json::to_value(&report).map_err(|e| e.to_string())?;
291        files.push((format!("coverage/{id}.json"), canonical_serialize(&value)));
292    }
293
294    files.sort_by(|a, b| a.0.cmp(&b.0));
295
296    // ── manifest ─────────────────────────────────────────────────────────
297    let project = std::env::current_dir()
298        .ok()
299        .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
300        .unwrap_or_else(|| "unknown".to_string());
301
302    let file_hashes: Vec<Value> = files
303        .iter()
304        .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
305        .collect();
306
307    // Resolve the keypair once: the public key goes into the manifest and the
308    // signature is computed over the canonical manifest — both must come from
309    // the same key or the embedded key can never verify the signature.
310    let signing_key = crate::core::agent_identity::get_or_create_keypair(SIGNING_AGENT)
311        .map_err(|e| format!("signing identity: {e}"))?;
312    let public_key =
313        crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
314
315    let mut manifest = json!({
316        "bundle": "evidence-bundle",
317        "version": 1,
318        "period": { "from": spec.from, "to": spec.to },
319        "subject": { "agent_id": SIGNING_AGENT, "project": project },
320        // Framework coverage is LeanCTX Enterprise; without it the v1 field is null.
321        "framework": framework.map(|(id, _)| id),
322        "files": file_hashes,
323        "chain": {
324            "entries": segment_lines.len(),
325            "anchor_prev_hash": anchor_prev_hash,
326            "head_hash": head_hash,
327        },
328        "signing": {
329            "algorithm": "ed25519",
330            "public_key": public_key,
331            "signed_digest": "",
332            "signature": "",
333        }
334    });
335
336    let digest = sha256_hex(&canonical_serialize(&manifest));
337    manifest["signing"]["signed_digest"] = Value::String(digest);
338    let signature =
339        sign_manifest(&manifest, &signing_key).map_err(|e| format!("manifest signature: {e}"))?;
340    manifest["signing"]["signature"] = Value::String(signature);
341
342    // manifest.json sorts first lexicographically anyway, but be explicit.
343    files.insert(
344        0,
345        ("manifest.json".to_string(), canonical_serialize(&manifest)),
346    );
347    files.sort_by(|a, b| a.0.cmp(&b.0));
348
349    // ── deterministic ZIP ────────────────────────────────────────────────
350    let out_path = spec.out.clone().unwrap_or_else(|| {
351        PathBuf::from(format!(
352            "evidence-bundle_{}_{}.zip",
353            spec.from.replace(':', ""),
354            spec.to.replace(':', "")
355        ))
356    });
357    let mut buf: Vec<u8> = Vec::new();
358    {
359        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
360        let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
361            .compression_method(zip::CompressionMethod::Stored)
362            .last_modified_time(zip::DateTime::default());
363        for (path, bytes) in &files {
364            zip.start_file(path, options).map_err(|e| e.to_string())?;
365            zip.write_all(bytes).map_err(|e| e.to_string())?;
366        }
367        zip.finish().map_err(|e| e.to_string())?;
368    }
369    std::fs::write(&out_path, &buf).map_err(|e| format!("write {}: {e}", out_path.display()))?;
370
371    Ok(BundleResult {
372        path: out_path,
373        sha256: sha256_hex(&buf),
374        entries: segment_lines.len(),
375        files: files.into_iter().map(|(p, _)| p).collect(),
376    })
377}