> 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…
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>
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);
+ }
}