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:
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user