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::{json, Value};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use super::audit_trail::AuditEntry;
18
19const SIGNING_AGENT: &str = "lean-ctx";
20
21pub struct BundleSpec {
22    /// RFC 3339 inclusive lower bound.
23    pub from: String,
24    /// RFC 3339 inclusive upper bound.
25    pub to: String,
26    /// Framework mapping to include a coverage report for.
27    pub framework: Option<String>,
28    /// Pack name/path override; defaults to the framework reference pack,
29    /// the project pack, or `baseline` (first match wins).
30    pub pack: Option<String>,
31    pub out: Option<PathBuf>,
32}
33
34#[derive(Debug)]
35pub struct BundleResult {
36    pub path: PathBuf,
37    pub sha256: String,
38    pub entries: usize,
39    pub files: Vec<String>,
40}
41
42/// Canonical JSON: object keys sorted (serde_json's default `Map` is a
43/// BTreeMap), compact separators. Structs round-trip through `Value` so
44/// field declaration order can never leak into the bytes.
45fn canonical_json(value: &Value) -> String {
46    serde_json::to_string(value).expect("canonical json")
47}
48
49fn sha256_hex(bytes: &[u8]) -> String {
50    use sha2::{Digest, Sha256};
51    let mut hasher = Sha256::new();
52    hasher.update(bytes);
53    format!("{:x}", hasher.finalize())
54}
55
56/// Generate the bundle. Fails loudly on every inconsistency — an evidence
57/// artifact with silently missing parts would be worse than none.
58pub fn generate(spec: &BundleSpec) -> Result<BundleResult, String> {
59    let from = chrono::DateTime::parse_from_rfc3339(&spec.from)
60        .map_err(|e| format!("--from is not RFC 3339: {e}"))?;
61    let to = chrono::DateTime::parse_from_rfc3339(&spec.to)
62        .map_err(|e| format!("--to is not RFC 3339: {e}"))?;
63    if from > to {
64        return Err("--from must not be after --to".to_string());
65    }
66
67    // ── audit segment (original lines preserved — hash-stable) ──────────
68    let trail_path = crate::core::data_dir::lean_ctx_data_dir()
69        .map_err(|e| format!("data dir: {e}"))?
70        .join("audit")
71        .join("trail.jsonl");
72    let trail_raw = std::fs::read_to_string(&trail_path)
73        .map_err(|e| format!("no audit trail at {}: {e}", trail_path.display()))?;
74
75    let mut segment_lines: Vec<String> = Vec::new();
76    let mut anchor_prev_hash: Option<String> = None;
77    let mut head_hash = String::new();
78    for (lineno, line) in trail_raw.lines().enumerate() {
79        // Concurrent appends have historically produced lines holding two
80        // back-to-back JSON objects (`…}{…`). A stream deserializer splits
81        // them losslessly — entry hashes are untouched, so the chain still
82        // proves integrity. Lines that don't parse AT ALL are tolerated
83        // only outside the attested window; inside it they are a hard
84        // error — an evidence artifact must never paper over a gap.
85        let mut stream = serde_json::Deserializer::from_str(line).into_iter::<serde_json::Value>();
86        let mut parsed_any = false;
87        loop {
88            let value = match stream.next() {
89                None => break,
90                Some(Ok(v)) => v,
91                Some(Err(e)) => {
92                    if segment_lines.is_empty() && !parsed_any {
93                        break; // pre-window garbage
94                    }
95                    return Err(format!(
96                        "corrupt trail line {} inside the attested period: {e}",
97                        lineno + 1
98                    ));
99                }
100            };
101            parsed_any = true;
102            let entry: AuditEntry = match serde_json::from_value(value) {
103                Ok(e) => e,
104                Err(e) => {
105                    if segment_lines.is_empty() {
106                        continue;
107                    }
108                    return Err(format!("malformed audit entry at line {}: {e}", lineno + 1));
109                }
110            };
111            let ts = chrono::DateTime::parse_from_rfc3339(&entry.timestamp)
112                .map_err(|e| format!("corrupt trail timestamp at line {}: {e}", lineno + 1))?;
113            if ts < from || ts > to {
114                continue;
115            }
116            if anchor_prev_hash.is_none() {
117                anchor_prev_hash = Some(entry.prev_hash.clone());
118            }
119            head_hash.clone_from(&entry.entry_hash);
120            // Re-serialize one-object-per-line; field order is the struct's
121            // declaration order, identical to what `record()` writes.
122            segment_lines.push(serde_json::to_string(&entry).map_err(|e| e.to_string())?);
123        }
124    }
125    if segment_lines.is_empty() {
126        return Err(format!(
127            "no audit entries between {} and {} — nothing to attest",
128            spec.from, spec.to
129        ));
130    }
131    let audit_jsonl = format!("{}\n", segment_lines.join("\n"));
132    let anchor_prev_hash = anchor_prev_hash.expect("non-empty segment");
133
134    // ── policy pack (resolved view) ──────────────────────────────────────
135    let pack_name = spec.pack.clone().unwrap_or_else(|| {
136        spec.framework
137            .as_deref()
138            .and_then(|fw| crate::core::compliance::get(fw))
139            .map_or_else(
140                || {
141                    if Path::new(".lean-ctx/policy.toml").exists() {
142                        ".lean-ctx/policy.toml".to_string()
143                    } else {
144                        "baseline".to_string()
145                    }
146                },
147                |m| m.reference_pack.clone(),
148            )
149    });
150    let pack = if Path::new(&pack_name)
151        .extension()
152        .is_some_and(|e| e.eq_ignore_ascii_case("toml"))
153    {
154        crate::core::policy::parse_file(Path::new(&pack_name))
155            .map_err(|e| format!("pack {pack_name}: {e}"))?
156    } else {
157        crate::core::policy::builtin::get(&pack_name)
158            .ok_or_else(|| format!("unknown builtin pack '{pack_name}'"))?
159    };
160    let resolved =
161        crate::core::policy::resolve(&pack).map_err(|e| format!("pack {pack_name}: {e}"))?;
162    let resolved_value = serde_json::to_value(&resolved).map_err(|e| e.to_string())?;
163    let policy_file = (
164        format!("policies/{}.resolved.json", resolved.name),
165        canonical_json(&resolved_value).into_bytes(),
166    );
167
168    // ── coverage reports ─────────────────────────────────────────────────
169    let cgb_checks = crate::core::policy::coverage::assess(&resolved);
170    let cgb_doc = json!({
171        "benchmark": crate::core::policy::coverage::BENCHMARK_ID,
172        "pack": { "name": resolved.name, "version": resolved.version },
173        "checks": serde_json::to_value(&cgb_checks).map_err(|e| e.to_string())?,
174        "summary": serde_json::to_value(crate::core::policy::coverage::summarize(&cgb_checks))
175            .map_err(|e| e.to_string())?,
176    });
177    let mut files: Vec<(String, Vec<u8>)> = vec![
178        ("audit/trail.jsonl".to_string(), audit_jsonl.into_bytes()),
179        policy_file,
180        (
181            "coverage/cgb.json".to_string(),
182            canonical_json(&cgb_doc).into_bytes(),
183        ),
184    ];
185
186    if let Some(fw) = &spec.framework {
187        let mapping = crate::core::compliance::get(fw).ok_or_else(|| {
188            format!(
189                "unknown framework '{fw}' (supported: {})",
190                crate::core::compliance::names().join(", ")
191            )
192        })?;
193        let report = crate::core::compliance::report(mapping, Some(&resolved));
194        let value = serde_json::to_value(&report).map_err(|e| e.to_string())?;
195        files.push((
196            format!("coverage/{fw}.json"),
197            canonical_json(&value).into_bytes(),
198        ));
199    }
200
201    files.sort_by(|a, b| a.0.cmp(&b.0));
202
203    // ── manifest ─────────────────────────────────────────────────────────
204    let project = std::env::current_dir()
205        .ok()
206        .and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()))
207        .unwrap_or_else(|| "unknown".to_string());
208
209    let file_hashes: Vec<Value> = files
210        .iter()
211        .map(|(path, bytes)| json!({ "path": path, "sha256": sha256_hex(bytes) }))
212        .collect();
213
214    // Resolve the keypair once: the public key goes into the manifest and the
215    // signature is computed over that manifest's digest — both must come from
216    // the same key or the embedded key can never verify the signature.
217    let signing_key = crate::core::agent_identity::get_or_create_keypair(SIGNING_AGENT)
218        .map_err(|e| format!("signing identity: {e}"))?;
219    let public_key =
220        crate::core::agent_identity::hex_encode(signing_key.verifying_key().as_bytes());
221
222    let mut manifest = json!({
223        "bundle": "evidence-bundle",
224        "version": 1,
225        "period": { "from": spec.from, "to": spec.to },
226        "subject": { "agent_id": SIGNING_AGENT, "project": project },
227        "framework": spec.framework,
228        "files": file_hashes,
229        "chain": {
230            "entries": segment_lines.len(),
231            "anchor_prev_hash": anchor_prev_hash,
232            "head_hash": head_hash,
233        },
234        "signing": {
235            "algorithm": "ed25519",
236            "public_key": public_key,
237            "signed_digest": "",
238            "signature": "",
239        }
240    });
241
242    let digest = sha256_hex(canonical_json(&manifest).as_bytes());
243    let signature = crate::core::agent_identity::hex_encode(
244        &crate::core::agent_identity::sign_bytes_with(&signing_key, digest.as_bytes()),
245    );
246    manifest["signing"]["signed_digest"] = Value::String(digest);
247    manifest["signing"]["signature"] = Value::String(signature);
248
249    // manifest.json sorts first lexicographically anyway, but be explicit.
250    files.insert(
251        0,
252        (
253            "manifest.json".to_string(),
254            canonical_json(&manifest).into_bytes(),
255        ),
256    );
257    files.sort_by(|a, b| a.0.cmp(&b.0));
258
259    // ── deterministic ZIP ────────────────────────────────────────────────
260    let out_path = spec.out.clone().unwrap_or_else(|| {
261        PathBuf::from(format!(
262            "evidence-bundle_{}_{}.zip",
263            spec.from.replace(':', ""),
264            spec.to.replace(':', "")
265        ))
266    });
267    let mut buf: Vec<u8> = Vec::new();
268    {
269        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut buf));
270        let options: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default()
271            .compression_method(zip::CompressionMethod::Stored)
272            .last_modified_time(zip::DateTime::default());
273        for (path, bytes) in &files {
274            zip.start_file(path, options).map_err(|e| e.to_string())?;
275            zip.write_all(bytes).map_err(|e| e.to_string())?;
276        }
277        zip.finish().map_err(|e| e.to_string())?;
278    }
279    std::fs::write(&out_path, &buf).map_err(|e| format!("write {}: {e}", out_path.display()))?;
280
281    Ok(BundleResult {
282        path: out_path,
283        sha256: sha256_hex(&buf),
284        entries: segment_lines.len(),
285        files: files.into_iter().map(|(p, _)| p).collect(),
286    })
287}