diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index 906bb1f9..0b65a8f1 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -322,6 +322,100 @@ pub fn build( Ok(h) } +/// El resultado de correr un check de evidencia, con su veredicto (H1b). +#[derive(Debug, Clone)] +pub struct EvidenceOutcome { + pub kind: hammer_core::EvidenceKind, + pub cmd: String, + pub outcome: hammer_core::CheckOutcome, +} + +/// Reporte de la ejecución de TODA la evidencia de una receta (H1b). `all_passed` es el gate: +/// si es `false`, el bucle agéntico NO debe proponer la mutación (SDD 15 §H1: "falla ⇒ no se propone"). +#[derive(Debug, Clone)] +pub struct EvidenceReport { + pub results: Vec, +} + +impl EvidenceReport { + pub fn all_passed(&self) -> bool { + self.results.iter().all(|r| r.outcome.passed) + } + /// El estrato de confianza MÁS ALTO que pasó (para reportar "verificado hasta "). `None` + /// si no hay checks. No promete que ese nivel *baste* — sólo que lo declarado a ese nivel pasó. + pub fn max_level_passed(&self) -> Option { + self.results + .iter() + .filter(|r| r.outcome.passed) + .map(|r| r.kind) + .max() + } +} + +/// Corre la evidencia de comportamiento de una receta (H1b, proof-carrying recipes). Reproduce el +/// artefacto (cache-hit si ya está sellado), levanta un sandbox reproducible con la fuente + +/// build-deps + el artefacto instalado montado como capa overlay (sus `/usr/bin` en PATH ⇒ los +/// smoke tests tipo `bin --version` encuentran el binario), y ejecuta cada check comparando +/// (exit, blake3(stdout)) contra lo declarado. Es "un runner de comandos con hash del output", no un +/// framework — la confianza vive acá (pequeño y auditable), no en el generador de la receta. +/// +/// Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta* (eso lo juzga el +/// humano). Los checks corren con el entorno BASE del sandbox; un check que recompila (proptest) usa +/// el toolchain que traigan las build-deps en PATH. +pub fn run_evidence( + recipe: &Recipe, + cfg: &BuildConfig, + store: &Store, +) -> hammer_core::Result { + recipe.evidence.validate()?; + if recipe.evidence.checks.is_empty() { + return Ok(EvidenceReport { results: Vec::new() }); + } + + // 1. Reproducir: construir/sellar el artefacto (cache-hit si existe). + let h = build(recipe, cfg, store)?; + let artifact_dir = store.path_of(&h, &recipe.name); + + // 2. Preparar el sandbox: mismas capas que el build (rootfs + build-deps) MÁS el artefacto + // instalado, para que sus binarios estén en /usr/bin (PATH). La fuente se re-materializa + // para los checks que la necesitan (proptest/kani corren `cargo test` en /src). + let zig_dir = effective_zig_dir(cfg, recipe)?; + sandbox::ensure_layout(&cfg.rootfs, &zig_dir)?; + let mut dep_dirs = materialize_build_deps(recipe, cfg, store)?; + dep_dirs.push(artifact_dir); + + let src_tree = fetch::fetch(recipe, &cfg.work_root)?; + if !recipe.source.patches.is_empty() { + fetch::apply_patches(recipe, &src_tree)?; + } + let out_dir = unique_out_dir(&cfg.work_root, &format!("{}-evidence", recipe.name), &h)?; + + let sb = Sandbox { + rootfs: cfg.rootfs.clone(), + zig_dir, + src_dir: src_tree, + out_dir, + cache_dir: cfg.cache_root.clone(), + deps: dep_dirs, + env: Vec::new(), + }; + + // 3. Ejecutar cada check y evaluarlo (la comparación exit/hash es pura, vive en hammer-core). + let mut results = Vec::with_capacity(recipe.evidence.checks.len()); + for c in &recipe.evidence.checks { + tracing::info!(kind = c.kind.as_str(), cmd = %c.cmd, "evidence: check"); + let out = sb.run_capture(&c.cmd)?; + let outcome = c.evaluate(out.exit_code, &out.stdout); + tracing::info!(passed = outcome.passed, detail = %outcome.detail, "evidence: veredicto"); + results.push(EvidenceOutcome { + kind: c.kind, + cmd: c.cmd.clone(), + outcome, + }); + } + Ok(EvidenceReport { results }) +} + /// Sistemas de build que reconoce la heurística por inspección del árbol del source. /// Ver `docs/02-build-lab.md` §4. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs index 1c20e649..c33e46a6 100644 --- a/crates/hammer-build/src/sandbox.rs +++ b/crates/hammer-build/src/sandbox.rs @@ -9,7 +9,7 @@ //! - Red: aislada (`--unshare-all` incluye `--unshare-net`). use std::collections::VecDeque; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; @@ -57,6 +57,14 @@ fn push_tail(buf: &mut VecDeque, line: String, cap: usize) { buf.push_back(line); } +/// Resultado de correr un comando en el sandbox con captura (H1b). A diferencia de `run`, NO falla +/// en exit != 0: el checker de evidencia compara `exit_code` y `blake3(stdout)` contra lo declarado. +#[derive(Debug, Clone)] +pub struct CmdOutcome { + pub exit_code: i32, + pub stdout: Vec, +} + #[derive(Debug, Clone)] pub struct Sandbox { pub rootfs: PathBuf, @@ -126,6 +134,45 @@ impl Sandbox { Ok(()) } + /// Como [`run`](Self::run), pero **captura el stdout completo** (no sólo la cola) y devuelve el + /// exit code en vez de fallar en != 0 — el caller decide contra lo esperado. El stderr se teea al + /// padre en vivo (logging), como en `run`. Es el motor de ejecución del checker de evidencia (H1b): + /// correr un `cmd` en el sandbox reproducible y comparar (exit, blake3(stdout)) contra lo declarado. + pub fn run_capture(&self, cmd: &str) -> hammer_core::Result { + let args = self.bwrap_args(cmd); + tracing::debug!(?args, "bwrap (capture)"); + let mut child = Command::new("bwrap") + .args(&args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("spawn bwrap: {e}")))?; + + let mut out = child.stdout.take().expect("stdout piped"); + let err = child.stderr.take().expect("stderr piped"); + // stdout: capturamos TODO en memoria (para hashearlo). stderr: al ring/tee del padre. + let h_out = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = out.read_to_end(&mut buf); + buf + }); + let tail = Arc::new(Mutex::new(VecDeque::::new())); + let h_err = spawn_pump(err, tail); + + let status = child + .wait() + .map_err(|e| hammer_core::Error::Other(anyhow::anyhow!("wait bwrap: {e}")))?; + let stdout = h_out.join().unwrap_or_default(); + let _ = h_err.join(); + + Ok(CmdOutcome { + // -1 si el proceso murió por señal (sin exit code): un check nunca lo espera ⇒ falla. + exit_code: status.code().unwrap_or(-1), + stdout, + }) + } + fn bwrap_args(&self, cmd: &str) -> Vec { let mut args: Vec = vec![ "--overlay-src".into(), diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 635c04d8..cf8b32b2 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -134,6 +134,10 @@ enum Cmd { /// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09). #[arg(long)] trust: Option, + /// [H1] Además del schema/firma, REPRODUCE cada paquete source_patch y corre su bloque + /// `evidence` (proof-carrying recipes) en el sandbox. Falla ⇒ exit != 0 (no proponer). + #[arg(long)] + evidence: bool, }, /// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `.ed25519` /// (privada, 0600) y `.ed25519.pub` en `--out` (default: /var/lib/hammer/trust). @@ -798,8 +802,14 @@ fn main() -> anyhow::Result<()> { None, // `apply` aplica un .swm genérico: no conoce el nombre del paquete )?; } - Cmd::SwmVerify { file, base_ref, trust } => { - run_swm_verify(&file, base_ref.as_deref(), trust.as_deref())?; + Cmd::SwmVerify { file, base_ref, trust, evidence } => { + run_swm_verify( + &file, + base_ref.as_deref(), + trust.as_deref(), + evidence, + std::path::Path::new(&cli.store), + )?; } Cmd::Keygen { name, out } => { run_keygen(&name, out.as_deref())?; @@ -1208,6 +1218,8 @@ fn run_swm_verify( file: &str, base_ref: Option<&std::path::Path>, trust: Option<&std::path::Path>, + evidence: bool, + store_path: &std::path::Path, ) -> anyhow::Result<()> { let swm = load_swm(file)?; swm.verify_schema() @@ -1228,9 +1240,78 @@ fn run_swm_verify( eprintln!("verify_base: omitido (sin --base-ref)"); } print_sig_status(&swm, trust)?; + if evidence { + run_swm_evidence(&swm, store_path)?; + } Ok(()) } +/// [H1b] Reproduce cada paquete `source_patch` del `.swm` y corre su bloque `evidence` en el sandbox +/// reproducible. Imprime el veredicto por check y ABORTA (exit != 0) si alguno falla — el gate de +/// "reproduce + estas N pruebas pasan" antes de que el humano proponga (SDD 15 §H1). Frontera: +/// paquetes con build-deps necesitan esas recetas resolubles en el catálogo del lab (como `install`); +/// para el caso self-contained (binario estático sin deps) alcanza con el `.swm`. +fn run_swm_evidence(swm: &hammer_core::Swm, store_path: &std::path::Path) -> anyhow::Result<()> { + // Corto-circuito ANTES de tocar el store/catálogo: si ninguna mutación trae evidencia, no hay + // nada que reproducir (y no forzamos abrir un store que quizá no exista/no sea escribible). + let has_evidence = swm.mutations.iter().any(|m| { + matches!(m, hammer_core::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty()) + }); + if !has_evidence { + println!("evidence: ninguna mutación declara evidencia (nada que verificar)"); + return Ok(()); + } + + let store = hammer_core::Store::open(store_path)?; + let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); + let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(&cfg, None); + std::fs::create_dir_all(&catalog_dir)?; + + let mut ran = 0usize; + let mut failed = 0usize; + for m in &swm.mutations { + let hammer_core::Mutation::SourcePatch { evidence, target_bin, .. } = m else { + continue; + }; + if evidence.is_empty() { + continue; + } + let recipe = + hammer_build::swm_bridge::recipe_from_source_patch(m, None, &catalog_dir)?; + println!( + "evidence: reproduciendo {target_bin} y corriendo {} check(s)…", + evidence.checks.len() + ); + let report = hammer_build::run_evidence(&recipe, &cfg, &store)?; + for r in &report.results { + let mark = if r.outcome.passed { "✓" } else { "✗" }; + println!( + " {mark} [{}] {} — {}", + r.kind.as_str(), + r.cmd, + r.outcome.detail + ); + } + ran += report.results.len(); + failed += report.results.iter().filter(|r| !r.outcome.passed).count(); + if report.all_passed() { + if let Some(lvl) = report.max_level_passed() { + println!(" → verificado hasta el estrato '{}'", lvl.as_str()); + } + } + } + + if ran == 0 { + println!("evidence: ninguna mutación declara evidencia (nada que verificar)"); + Ok(()) + } else if failed > 0 { + anyhow::bail!("evidence: {failed}/{ran} check(s) fallaron ⇒ NO proponer"); + } else { + println!("evidence: {ran} check(s), todos pasan ✓"); + Ok(()) + } +} + const DEFAULT_TRUST_DIR: &str = "/var/lib/hammer/trust"; /// Carga el TrustStore (de `--trust` o el default) y reporta el estado de la firma. La firma diff --git a/crates/hammer-core/src/hash.rs b/crates/hammer-core/src/hash.rs index d3ae0dc9..03b7f37f 100644 --- a/crates/hammer-core/src/hash.rs +++ b/crates/hammer-core/src/hash.rs @@ -97,6 +97,13 @@ impl ArtifactHash { std::io::copy(&mut f, &mut hasher)?; Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))) } + + /// BLAKE3 crudo de un buffer en memoria — exactamente `blake3(bytes)`, la misma convención que + /// [`of_file`](Self::of_file). Lo usa el checker de evidencia (H1b) para anclar el stdout de un + /// check contra su `expected_output`: quien recompute `blake3` de la salida obtiene el mismo `b3:…`. + pub fn of_bytes(bytes: &[u8]) -> ArtifactHash { + ArtifactHash(format!("b3:{}", blake3::hash(bytes).to_hex())) + } } /// Recorre `root` recursivamente acumulando rutas **relativas a `root`** en `out`. No sigue diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 6ae4c989..ae166c0c 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -20,8 +20,8 @@ pub use caps::{AgentCapsConfig, CapRule}; pub use hash::ArtifactHash; pub use installed::{InstalledDb, InstalledPackage}; pub use recipe::{ - Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe, Source, - SourceKind, + CheckOutcome, Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe, + Source, SourceKind, }; pub use repo::{PackageEntry, RepoIndex}; pub use sign::{KeyPair, SigStatus, TrustStore}; diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index 3e9a9615..df969568 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -257,6 +257,46 @@ impl EvidenceKind { } } +/// Veredicto de un `EvidenceCheck` tras ejecutarlo (H1b). Se computa comparando el resultado +/// REAL (exit code + stdout) contra lo declarado — sin tocar el sandbox (eso lo hace el runner). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CheckOutcome { + pub passed: bool, + /// Explicación legible de por qué pasó o falló (para el reporte al humano). + pub detail: String, +} + +impl EvidenceCheck { + /// Evalúa el resultado real de correr `cmd` contra lo declarado: exige `expected_exit` y, si + /// hay `expected_output`, que `blake3(stdout)` coincida. **Función pura** — la ejecución en el + /// sandbox reproducible es H1b; acá vive la lógica auditable de "¿el resultado es el exigido?". + pub fn evaluate(&self, exit_code: i32, stdout: &[u8]) -> CheckOutcome { + if exit_code != self.expected_exit { + return CheckOutcome { + passed: false, + detail: format!("exit {exit_code} ≠ esperado {}", self.expected_exit), + }; + } + if let Some(want) = &self.expected_output { + let got = ArtifactHash::of_bytes(stdout); + if got.as_str() != want { + return CheckOutcome { + passed: false, + detail: format!("stdout {got} ≠ esperado {want}"), + }; + } + return CheckOutcome { + passed: true, + detail: format!("exit {exit_code} ok + stdout {want} ok"), + }; + } + CheckOutcome { + passed: true, + detail: format!("exit {exit_code} ok"), + } + } +} + /// Compilador del lab, POR RECETA (no global). `zig-cc` por defecto; escotilla a clang/gcc /// para paquetes con gcc-ismos. Ver ADR 0003. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] @@ -674,4 +714,46 @@ expected_output = "sha256:zzz" assert_eq!(EvidenceKind::CmdExit.level(), 0); assert_eq!(EvidenceKind::Kani.level(), 3); } + + fn check(expected_exit: i32, expected_output: Option<&str>) -> EvidenceCheck { + EvidenceCheck { + kind: EvidenceKind::CmdExit, + cmd: "irrelevante".into(), + expected_exit, + expected_output: expected_output.map(|s| s.into()), + } + } + + #[test] + fn evaluate_exit_only_pass_and_fail() { + let c = check(0, None); + assert!(c.evaluate(0, b"lo que sea").passed); + let bad = c.evaluate(1, b""); + assert!(!bad.passed); + assert!(bad.detail.contains("exit 1"), "{}", bad.detail); + } + + #[test] + fn evaluate_non_zero_expected_exit() { + // Un check puede exigir un exit != 0 (p.ej. "el binario rechaza input inválido con 2"). + let c = check(2, None); + assert!(c.evaluate(2, b"").passed); + assert!(!c.evaluate(0, b"").passed); + } + + #[test] + fn evaluate_output_hash_pass_and_fail() { + let want = ArtifactHash::of_bytes(b"hola\n"); + let c = check(0, Some(want.as_str())); + // stdout correcto ⇒ pasa. + let ok = c.evaluate(0, b"hola\n"); + assert!(ok.passed, "{}", ok.detail); + assert!(ok.detail.contains("hash") || ok.detail.contains(want.as_str())); + // stdout distinto ⇒ falla aunque el exit sea correcto. + let bad = c.evaluate(0, b"chau\n"); + assert!(!bad.passed); + assert!(bad.detail.contains("stdout"), "{}", bad.detail); + // exit malo ⇒ falla antes de mirar el hash. + assert!(!c.evaluate(1, b"hola\n").passed); + } }