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>
This commit is contained in:
2026-07-02 13:03:34 -04:00
co-authored by Claude Opus 4.8
parent 39f0f26e7b
commit 497d0d00e7
6 changed files with 316 additions and 5 deletions
+94
View File
@@ -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)]
+48 -1
View File
@@ -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(),
+83 -2
View File
@@ -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
+7
View File
@@ -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
+2 -2
View File
@@ -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};
+82
View File
@@ -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);
}
}