tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1182Shell 87Rust 63Markdown 541592 Definitions

H1b (proof-carrying recipes): checker `swm-verify --evidence` que ejecuta la evidencia

El verificador que corre cada check en el sandbox reproducible y falla ⇒ no proponer
(SDD 15 §H1). Piezas: (1) hammer-core: EvidenceCheck::evaluate(exit, stdout) -> CheckOutcome
(pura, testeada: exige expected_exit y, si hay, blake3(stdout)==expected_output) +
ArtifactHash::of_bytes. (2) hammer-build: Sandbox::run_capture -> CmdOutcome (captura stdout
completo + exit, tee de stderr) + run_evidence(recipe,cfg,store) -> EvidenceReport que
reproduce el artefacto (cache-hit), levanta un sandbox con fuente+build-deps+el artefacto
instalado como capa overlay (binarios en PATH) y corre cada check. (3) CLI: swm-verify
--evidence reconstruye cada source_patch y corre run_evidence; imprime veredicto por check +
estrato máximo alcanzado; exit != 0 si algún check falla. Es un runner de comandos con hash
del output, no un framework — la confianza vive en el checker.

Verificado e2e: tree con [[evidence.checks]] cmd-exit 'tree --version' → pack cache-hitea
(evidencia no cambia el hash) → swm-verify --evidence corre el check en el sandbox: pasa (exit
0) y falla con expected_exit=7 (exit 1, 'NO proponer'). Núcleo puro con tests unitarios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

497d0d00e75be17b864b21f4cec0d810071bd495 · by sergio · (3 months ago) · parent 39f0f26e7 · browse the tree at this commit

Semantic delta · 18

crates/hammer-build/src/lib.rs

crates/hammer-build/src/sandbox.rs

crates/hammer-cli/src/main.rs

crates/hammer-core/src/hash.rs

crates/hammer-core/src/recipe.rs

6 files · +316 −5

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<EvidenceOutcome>,
+}
+
+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 <nivel>"). `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<hammer_core::EvidenceKind> {
+        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<EvidenceReport> {
+    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<String>, 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<u8>,
+}
+
 #[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<CmdOutcome> {
+        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::<String>::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<String> {
         let mut args: Vec<String> = 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<PathBuf>,
+        /// [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 `<name>.ed25519`
     /// (privada, 0600) y `<name>.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);
+    }
 }