pub mod manifest;
use crate::hashing::sha256_hex;
use crate::paths::Paths;
use crate::run::Run;
use anyhow::{Context, Result};
use flate2::Compression;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
pub use manifest::{Manifest, Member};
use std::io::Read;
use std::path::{Path, PathBuf};
pub fn export(paths: &Paths, run: &Run, out_dir: Option<&Path>) -> Result<PathBuf> {
let mut members: Vec<(String, Vec<u8>)> = Vec::new();
collect_dir(&run.dir, &run.dir, &mut members)?;
let spec_dir = crate::spec::Spec::dir(paths, &run.meta.spec);
if spec_dir.is_dir() {
collect_dir(&spec_dir, spec_dir.parent().unwrap_or(&spec_dir), &mut members)?;
}
let steering = paths.steering();
if steering.is_dir() {
collect_dir(&steering, &paths.store(), &mut members)?;
}
members.sort_by(|a, b| a.0.cmp(&b.0));
members.dedup_by(|a, b| a.0 == b.0);
let readme = readme(paths, run)?;
members.insert(0, ("README.md".to_string(), readme.into_bytes()));
let entries: Vec<Member> = members
.iter()
.map(|(path, bytes)| Member {
path: path.clone(),
bytes: bytes.len() as u64,
sha256: sha256_hex(bytes),
})
.collect();
let manifest = Manifest::new(&run.meta, entries);
let manifest_bytes = format!("{}\n", serde_json::to_string_pretty(&manifest)?).into_bytes();
members.insert(1, ("manifest.json".to_string(), manifest_bytes));
let dir = out_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| paths.keel().join("bundles"));
std::fs::create_dir_all(&dir)?;
let archive_path = dir.join(format!("keel-{}.tar.gz", run.meta.id));
let file = std::fs::File::create(&archive_path)
.with_context(|| format!("creating {}", archive_path.display()))?;
let encoder = GzEncoder::new(file, Compression::default());
let mut tar = tar::Builder::new(encoder);
for (path, bytes) in &members {
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_cksum();
tar.append_data(&mut header, path, bytes.as_slice())
.with_context(|| format!("adding {path} to the archive"))?;
}
tar.into_inner()?.finish()?;
Ok(archive_path)
}
pub fn read_members(archive: &Path) -> Result<Vec<(String, Vec<u8>)>> {
let file = std::fs::File::open(archive)
.with_context(|| format!("opening {}", archive.display()))?;
let mut tar = tar::Archive::new(GzDecoder::new(file));
let mut out = Vec::new();
for entry in tar.entries()? {
let mut entry = entry?;
let raw = entry.path()?.to_string_lossy().to_string();
let path = raw.trim_start_matches("./").to_string();
if path.is_empty() || path == "." {
continue;
}
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
out.push((path, bytes));
}
Ok(out)
}
#[derive(Debug, Clone)]
pub struct Verification {
pub manifest: Manifest,
pub tampered: Vec<String>,
pub missing: Vec<String>,
pub unlisted: Vec<String>,
}
impl Verification {
pub fn is_intact(&self) -> bool {
self.tampered.is_empty() && self.missing.is_empty() && self.unlisted.is_empty()
}
}
pub fn verify(archive: &Path) -> Result<Verification> {
let members = read_members(archive)?;
let manifest_bytes = members
.iter()
.find(|(p, _)| p == "manifest.json")
.map(|(_, b)| b.clone())
.ok_or_else(|| anyhow::anyhow!("{} has no manifest.json", archive.display()))?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes)
.with_context(|| format!("parsing the manifest in {}", archive.display()))?;
let mut tampered = Vec::new();
let mut missing = Vec::new();
let mut unlisted = Vec::new();
for m in &manifest.members {
match members.iter().find(|(p, _)| *p == m.path) {
None => missing.push(m.path.clone()),
Some((_, bytes)) => {
if sha256_hex(bytes) != m.sha256 {
tampered.push(m.path.clone());
}
}
}
}
for (path, _) in &members {
if path == "manifest.json" {
continue;
}
if manifest.find(path).is_none() {
unlisted.push(path.clone());
}
}
Ok(Verification { manifest, tampered, missing, unlisted })
}
fn readme(paths: &Paths, run: &Run) -> Result<String> {
let meta = &run.meta;
let mut s = String::new();
s.push_str(&format!("# keel evidence bundle — run {}\n\n", meta.id));
s.push_str(&format!(
"Spec `{}`{}, driver `{}`, keel {}.\nStarted {}{}.\n\n",
meta.spec,
meta.task.as_ref().map(|t| format!(" task `{t}`")).unwrap_or_default(),
meta.driver.clone().unwrap_or_else(|| "none".into()),
meta.keel_version,
meta.started_at,
meta.finished_at.as_ref().map(|f| format!(", finished {f}")).unwrap_or_default()
));
if let Some(c) = &meta.base_commit {
s.push_str(&format!("Base commit `{c}`.\n\n"));
}
s.push_str("## What changed\n\n");
let diff_stat = run.evidence_dir().join("diff-stat.txt");
match std::fs::read_to_string(&diff_stat) {
Ok(t) => s.push_str(&format!("```\n{}\n```\n\n", t.trim_end())),
Err(_) => s.push_str("_No diff was recorded for this run._\n\n"),
}
s.push_str("## Gates\n\n");
let results = run.gate_results()?;
if results.is_empty() {
s.push_str("_No gate ran._\n\n");
} else {
s.push_str("| gate | verdict | passed | failed | blocked |\n| --- | --- | --- | --- | --- |\n");
for r in &results {
let (p, f, b) = r.counts();
s.push_str(&format!("| {} | **{}** | {p} | {f} | {b} |\n", r.gate, r.verdict.glyph()));
}
s.push('\n');
for r in &results {
let bad: Vec<&crate::gate::Check> = r
.checks
.iter()
.filter(|c| c.verdict != crate::gate::Verdict::Pass)
.collect();
if bad.is_empty() {
continue;
}
s.push_str(&format!("### {} — what did not pass\n\n", r.gate));
for c in bad {
s.push_str(&format!("- **{}** ({})", c.id, c.verdict.glyph()));
if let Some(d) = &c.detail {
s.push_str(&format!(" — {d}"));
} else if let (Some(e), Some(a)) = (&c.expected, &c.actual) {
s.push_str(&format!("\n - expected: {e}\n - actual: {a}"));
}
if let Some(ev) = &c.evidence {
s.push_str(&format!("\n - evidence: `{ev}`"));
}
s.push('\n');
}
s.push('\n');
}
}
let events = crate::trajectory::read(&run.trajectory_path()).unwrap_or_default();
s.push_str("## The record\n\n");
s.push_str(&format!(
"`trajectory.jsonl` holds {} events, one JSON object per line, in sequence order.\n\
{} tokens were put in front of the model across {} injection(s).\n\n",
events.len(),
crate::trajectory::token_total(&events),
events.iter().filter(|e| e.payload.kind() == "inject").count()
));
s.push_str(
"Verify this bundle has not been edited since it was written:\n\n\
```sh\n\
keel export --verify keel-<run>.tar.gz\n\
```\n\n\
Every member's SHA-256 is recorded in `manifest.json`.\n\n",
);
s.push_str("## Contents\n\n");
s.push_str("- `README.md` — this file\n- `manifest.json` — member hashes\n");
s.push_str(&format!("- `{}/` — run metadata, trajectory, gate results, evidence\n", meta.id));
s.push_str(&format!("- `{}/` — the spec, plan and tasks as agreed\n", meta.spec));
s.push_str("- `steering/` — the durable context the agent was given\n");
let _ = paths;
Ok(s)
}
fn collect_dir(dir: &Path, relative_to: &Path, out: &mut Vec<(String, Vec<u8>)>) -> Result<()> {
if !dir.is_dir() {
return Ok(());
}
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(|e| e.ok().map(|e| e.path()))
.collect();
entries.sort();
for path in entries {
if path.is_dir() {
collect_dir(&path, relative_to, out)?;
continue;
}
let rel = path
.strip_prefix(relative_to)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let bytes = std::fs::read(&path).with_context(|| format!("reading {}", path.display()))?;
out.push((rel, bytes));
}
Ok(())
}
pub fn write_schema(paths: &Paths) -> Result<PathBuf> {
let dir = paths.repo.join("schemas");
std::fs::create_dir_all(&dir)?;
let p = dir.join("manifest.json");
std::fs::write(&p, manifest::JSON_SCHEMA)?;
Ok(p)
}