From 3116ccfc93dcf98986fb9a16ba252961e3692ae9 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 2 Jul 2026 12:41:23 -0400 Subject: [PATCH] H1a (proof-carrying recipes): bloque `evidence` en receta y .swm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontera AI-nativa SDD 15 §H1. Agrega `Evidence { checks: Vec }` a Recipe (TOML) y a Mutation::SourcePatch (.swm YAML): cada check es {kind, cmd, expected_exit, expected_output?} con kind ∈ {cmd-exit, proptest, contract, kani} (estratos de confianza crecientes, EvidenceKind: Ord). DECISIÓN CLAVE: la evidencia NO entra en hash_inputs — certifica comportamiento, no identidad ⇒ no mueve el artifact_hash (baseline de reproducibilidad intacto). Round-trip completo: from_recipe (forward) + swm_bridge synthesize_recipe (reverse) + camino de pack (cli). verify_schema valida forma (cmd no vacío, expected_output con prefijo b3:). El checker que EJECUTA la evidencia es H1b; el cableado al Orchestrator VERIFY es H1c (marcado con evidence: _). Tests: recipe + swm, incl. que la evidencia no cambia el hash. Sin warnings clippy nuevos. Co-Authored-By: Claude Opus 4.8 --- crates/hammer-agent/src/orchestrator.rs | 1 + crates/hammer-build/src/swm_bridge.rs | 15 +- crates/hammer-cli/src/main.rs | 1 + crates/hammer-core/src/lib.rs | 5 +- crates/hammer-core/src/recipe.rs | 202 ++++++++++++++++++++++++ crates/hammer-core/src/swm.rs | 95 ++++++++++- crates/hammerd/src/bus.rs | 1 + 7 files changed, 315 insertions(+), 5 deletions(-) diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index 128c7ea2..a86d5621 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -170,6 +170,7 @@ impl Orchestrator { target_bin, expected_hash, deps: _, + evidence: _, // H1c cableará la ejecución de la evidencia en el paso VERIFY } => { let CompileMode::ViaBus { store, timeout, .. } = &self.compile else { skipped_source_patches += 1; diff --git a/crates/hammer-build/src/swm_bridge.rs b/crates/hammer-build/src/swm_bridge.rs index dc77ce38..7477659c 100644 --- a/crates/hammer-build/src/swm_bridge.rs +++ b/crates/hammer-build/src/swm_bridge.rs @@ -73,14 +73,14 @@ pub fn recipe_from_source_patch( name: Option<&str>, catalog_dir: &Path, ) -> hammer_core::Result { - let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps) = + let (repo, commit, tarball, sha256, strip_components, patch, patch_url, build_cfg, target_bin, deps, evidence) = match mutation { Mutation::SourcePatch { repo, commit, tarball, sha256, strip_components, patch, patch_url, build, - target_bin, deps, .. + target_bin, deps, evidence, .. } => ( repo, commit, tarball, sha256, strip_components, patch, patch_url, build, - target_bin, deps, + target_bin, deps, evidence, ), _ => { return Err(hammer_core::Error::Recipe( @@ -133,6 +133,7 @@ pub fn recipe_from_source_patch( build_cfg, *strip_components, deps, + evidence, patches, catalog_dir, ) @@ -156,6 +157,7 @@ fn synthesize_recipe( build_cfg: &SwmBuild, strip_components: Option, deps: &hammer_core::Deps, + evidence: &hammer_core::Evidence, patches: Vec, base_dir: &Path, ) -> hammer_core::Result { @@ -213,6 +215,9 @@ flags = [] // necesitan, pero la resolución de build-deps SÍ. `install` puebla el catálogo con los // `{dep}.toml` antes de construir; sin deps, el dir simplemente no se consulta. recipe.deps = deps.clone(); + // Evidencia de comportamiento (H1): viaja en el .swm y se reinyecta en la receta efímera para + // que el checker la vea al reproducir. No afecta el hash del artefacto (comportamiento ≠ identidad). + recipe.evidence = evidence.clone(); recipe.base_dir = base_dir.to_path_buf(); Ok(recipe) } @@ -272,6 +277,7 @@ mod tests { &fake_swm_build(), None, &hammer_core::Deps::default(), + &hammer_core::Evidence::default(), vec![], d.path(), ) @@ -300,6 +306,7 @@ mod tests { &fake_swm_build(), None, &hammer_core::Deps::default(), + &hammer_core::Evidence::default(), vec![], d.path(), ) @@ -334,6 +341,7 @@ mod tests { target_bin: "/usr/bin/jq".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }; let recipe = recipe_from_source_patch(&m, None, &catalog).unwrap(); assert_eq!(recipe.source.patches.len(), 1); @@ -388,6 +396,7 @@ mod tests { target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }; let store = Store::open(d.path().join("store")).unwrap(); let cfg = BuildConfig::defaults_for_store(store.root()); diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 4d8af8e9..635c04d8 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -2350,6 +2350,7 @@ fn build_export_mutations( target_bin, expected_hash: Some(art_hash), deps: recipe.deps.clone(), + evidence: recipe.evidence.clone(), }); stats.source_patches += 1; } diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index 2d250192..6ae4c989 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -19,7 +19,10 @@ pub mod swm; pub use caps::{AgentCapsConfig, CapRule}; pub use hash::ArtifactHash; pub use installed::{InstalledDb, InstalledPackage}; -pub use recipe::{Compiler, Deps, LinkMode, Phases, Recipe, Source, SourceKind}; +pub use recipe::{ + Compiler, Deps, Evidence, EvidenceCheck, EvidenceKind, LinkMode, Phases, Recipe, Source, + SourceKind, +}; pub use repo::{PackageEntry, RepoIndex}; pub use sign::{KeyPair, SigStatus, TrustStore}; pub use store::Store; diff --git a/crates/hammer-core/src/recipe.rs b/crates/hammer-core/src/recipe.rs index 75ee9bea..3e9a9615 100644 --- a/crates/hammer-core/src/recipe.rs +++ b/crates/hammer-core/src/recipe.rs @@ -14,6 +14,11 @@ pub struct Recipe { pub build: Build, #[serde(default)] pub deps: Deps, + /// Evidencia de COMPORTAMIENTO que acompaña la receta (H1 — proof-carrying recipes, SDD 15). + /// Opcional. **No entra en `hash_inputs`**: certifica comportamiento, no identidad ⇒ agregar o + /// cambiar evidencia NO mueve el `artifact_hash` (el baseline de reproducibilidad no se toca). + #[serde(default, skip_serializing_if = "Evidence::is_empty")] + pub evidence: Evidence, /// Directorio base contra el que se resuelven rutas relativas de la receta /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío. #[serde(skip, default)] @@ -158,6 +163,100 @@ impl Deps { } } +/// Evidencia de comportamiento de una receta (H1 — proof-carrying recipes, SDD 15 §H1). La IA +/// entrega, junto al build, un conjunto de `checks` que el verificador (`hammer swm-verify +/// --evidence`) corre DENTRO del sandbox reproducible; la mutación sólo se propone si todos pasan. +/// La confianza vive en el checker (pequeño, auditable), no en el generador: el sistema mejora solo +/// sin poder corromperse solo. Deliberadamente FUERA de `Recipe::hash_inputs` (comportamiento ≠ +/// identidad). Frontera honesta: garantiza que *lo declarado pasa*, no que *lo declarado basta*. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Evidence { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub checks: Vec, +} + +impl Evidence { + /// `true` si no hay ningún check. Sirve a serde (`skip_serializing_if`) para no emitir el + /// bloque `evidence` vacío en una receta/`.swm`. + pub fn is_empty(&self) -> bool { + self.checks.is_empty() + } + + /// Valida la forma del bloque (schema, no ejecución): cada `cmd` no vacío y cada + /// `expected_output`, si está, con prefijo `b3:`. El checker de H1b corre esto antes de ejecutar. + pub fn validate(&self) -> crate::Result<()> { + for (i, c) in self.checks.iter().enumerate() { + if c.cmd.trim().is_empty() { + return Err(crate::Error::Recipe(format!( + "evidence.checks[{i}] ({}): `cmd` vacío", + c.kind.as_str() + ))); + } + if let Some(h) = &c.expected_output { + if !h.starts_with("b3:") { + return Err(crate::Error::Recipe(format!( + "evidence.checks[{i}] ({}): `expected_output` debe ser un hash `b3:…`, es {h:?}", + c.kind.as_str() + ))); + } + } + } + Ok(()) + } +} + +/// Un ítem de evidencia: un comando + el resultado exigido. `kind` estratifica la CONFIANZA +/// (`cmd-exit` < `proptest` < `contract` < `kani`), que se reporta tal cual — H1 no finge que todo +/// es prueba formal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceCheck { + pub kind: EvidenceKind, + /// Comando shell a correr en el sandbox reproducible tras el build (misma vía que las fases). + pub cmd: String, + /// Exit code exigido. Default 0. + #[serde(default)] + pub expected_exit: i32, + /// Hash BLAKE3 (`b3:…`) opcional del stdout, para checks deterministas cuyo OUTPUT también se + /// ancla (no sólo el exit). `None` ⇒ sólo se exige el exit code. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_output: Option, +} + +/// Estrato de confianza de un `EvidenceCheck`, en orden creciente (`Ord` = comparación de estratos). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EvidenceKind { + /// El comando sale con `expected_exit`. La garantía más débil (un smoke test). + CmdExit, + /// Property tests (proptest/quickcheck): pasan sobre entradas generadas. + Proptest, + /// Contratos / aserciones de pre y post-condición. + Contract, + /// Prueba formal acotada (Kani/Creusot). La garantía más fuerte. + Kani, +} + +impl EvidenceKind { + /// Nivel numérico de confianza (0 = más débil), para reportar/ordenar. + pub fn level(&self) -> u8 { + match self { + EvidenceKind::CmdExit => 0, + EvidenceKind::Proptest => 1, + EvidenceKind::Contract => 2, + EvidenceKind::Kani => 3, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + EvidenceKind::CmdExit => "cmd-exit", + EvidenceKind::Proptest => "proptest", + EvidenceKind::Contract => "contract", + EvidenceKind::Kani => "kani", + } + } +} + /// 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)] @@ -472,4 +571,107 @@ commit = "deadbeef" let r = Recipe::load_from_path(&recipe_path).unwrap(); assert_eq!(r.base_dir, dir.path()); } + + const WITH_EVIDENCE: &str = r#" +name = "grep" +version = "3.11" +[source] +repo = "git://git.savannah.gnu.org/grep.git" +commit = "a1b2c3d4e5f6" +[build] +[deps] +build = ["pcre2"] + +[[evidence.checks]] +kind = "cmd-exit" +cmd = "grep --version" + +[[evidence.checks]] +kind = "proptest" +cmd = "cd /src && cargo test --release proptest_" +expected_exit = 0 + +[[evidence.checks]] +kind = "cmd-exit" +cmd = "printf hola | grep -q hola" +expected_output = "b3:deadbeef" +"#; + + #[test] + fn parse_evidence_block() { + let r = Recipe::from_toml(WITH_EVIDENCE).expect("parse"); + assert_eq!(r.evidence.checks.len(), 3); + assert_eq!(r.evidence.checks[0].kind, EvidenceKind::CmdExit); + assert_eq!(r.evidence.checks[0].cmd, "grep --version"); + assert_eq!(r.evidence.checks[0].expected_exit, 0); // default + assert_eq!(r.evidence.checks[1].kind, EvidenceKind::Proptest); + assert_eq!(r.evidence.checks[2].expected_output.as_deref(), Some("b3:deadbeef")); + r.evidence.validate().expect("schema válido"); + } + + #[test] + fn evidence_absent_is_empty_and_omitted() { + let r = Recipe::from_toml(SAMPLE).unwrap(); + assert!(r.evidence.is_empty()); + // `skip_serializing_if` ⇒ una receta sin evidencia no emite el bloque. + let toml = r.to_toml().unwrap(); + assert!(!toml.contains("evidence"), "toml no debería traer `evidence`: {toml}"); + } + + #[test] + fn evidence_does_not_affect_artifact_hash() { + // La clave de H1a: la evidencia certifica COMPORTAMIENTO, no IDENTIDAD ⇒ el hash del + // artefacto es idéntico con y sin bloque evidence (el baseline de reproducibilidad no se mueve). + // Comparamos LA MISMA receta con y sin evidencia (clon + limpieza), no dos recetas distintas. + let con = Recipe::from_toml(WITH_EVIDENCE).unwrap(); + let mut sin = con.clone(); + sin.evidence = Evidence::default(); + assert!(con.evidence.checks.len() == 3 && sin.evidence.is_empty()); + assert_eq!( + sin.hash_inputs(&[]).unwrap(), + con.hash_inputs(&[]).unwrap(), + "la evidencia NO debe entrar en hash_inputs" + ); + } + + #[test] + fn evidence_validate_rejects_empty_cmd_and_bad_hash() { + let bad_cmd = r#" +name = "x" +version = "0" +[source] +repo = "git://x" +commit = "deadbeef" +[build] +[[evidence.checks]] +kind = "cmd-exit" +cmd = " " +"#; + let r = Recipe::from_toml(bad_cmd).unwrap(); + assert!(r.evidence.validate().unwrap_err().to_string().contains("vacío")); + + let bad_hash = r#" +name = "x" +version = "0" +[source] +repo = "git://x" +commit = "deadbeef" +[build] +[[evidence.checks]] +kind = "cmd-exit" +cmd = "true" +expected_output = "sha256:zzz" +"#; + let r = Recipe::from_toml(bad_hash).unwrap(); + assert!(r.evidence.validate().unwrap_err().to_string().contains("b3:")); + } + + #[test] + fn evidence_kind_levels_are_stratified() { + assert!(EvidenceKind::CmdExit < EvidenceKind::Proptest); + assert!(EvidenceKind::Proptest < EvidenceKind::Contract); + assert!(EvidenceKind::Contract < EvidenceKind::Kani); + assert_eq!(EvidenceKind::CmdExit.level(), 0); + assert_eq!(EvidenceKind::Kani.level(), 3); + } } diff --git a/crates/hammer-core/src/swm.rs b/crates/hammer-core/src/swm.rs index 6e2690fb..e298abf1 100644 --- a/crates/hammer-core/src/swm.rs +++ b/crates/hammer-core/src/swm.rs @@ -101,6 +101,11 @@ pub enum Mutation { /// Vacío ⇒ paquete autocontenido (el caso común, binarios estáticos sin deps). #[serde(default, skip_serializing_if = "crate::recipe::Deps::is_empty")] deps: crate::recipe::Deps, + /// Evidencia de comportamiento que acompaña al paquete (H1 — proof-carrying recipes). El + /// receptor puede correr `hammer swm-verify --evidence` para reproducir cada check en el + /// sandbox antes de aceptar la mutación. Vacío ⇒ paquete sin evidencia (sólo reproducibilidad). + #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")] + evidence: crate::recipe::Evidence, }, ConfigEdit { file: String, @@ -290,6 +295,7 @@ impl Swm { target_bin: target_bin.into(), expected_hash, deps: recipe.deps.clone(), + evidence: recipe.evidence.clone(), }], signature: None, }; @@ -326,7 +332,7 @@ impl Mutation { use crate::recipe::SourceKind; match self { Mutation::SourcePatch { - repo, commit, tarball, sha256, patch, patch_url, target_bin, .. + repo, commit, tarball, sha256, patch, patch_url, target_bin, evidence, .. } => { match swm_source_kind( repo.as_deref(), @@ -361,6 +367,9 @@ impl Mutation { "source_patch: target_bin debe ser ruta absoluta, no '{target_bin}'" )); } + // La evidencia (H1) viaja con el paquete: validamos su FORMA acá (cmd no vacío, + // hash bien formado). La EJECUCIÓN la hace `hammer swm-verify --evidence` (H1b). + evidence.validate().map_err(|e| e.to_string())?; Ok(()) } Mutation::ConfigEdit { file, inline_diff } => { @@ -578,6 +587,7 @@ mutations: target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }], signature: None, }; @@ -608,6 +618,7 @@ mutations: target_bin: "/bin/grep".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }], signature: None, }; @@ -635,6 +646,7 @@ mutations: target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("no ambos"), "mensaje inesperado: {err}"); @@ -735,8 +747,89 @@ zig_version = "0.13.0" target_bin: "/bin/x".into(), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }; let err = m.verify_schema().unwrap_err(); assert!(err.contains("faltan campos"), "mensaje inesperado: {err}"); } + + #[test] + fn from_recipe_carries_evidence() { + // H1a: la evidencia de la receta viaja al .swm (source_patch), roundtripea por YAML, + // y verify_schema la valida. + let toml = r#" +name = "grep" +version = "3.11" +[source] +tarball = "https://ftp.gnu.org/gnu/grep/grep-3.11.tar.gz" +sha256 = "deadbeef" +[build] +[[evidence.checks]] +kind = "cmd-exit" +cmd = "grep --version" +[[evidence.checks]] +kind = "proptest" +cmd = "cd /src && cargo test proptest_" +expected_exit = 0 +"#; + let recipe = crate::Recipe::from_toml(toml).unwrap(); + let swm = Swm::from_recipe(&recipe, "/usr/bin/grep", None, None, "dev").unwrap(); + // La evidencia llegó a la mutación. + match &swm.mutations[0] { + Mutation::SourcePatch { evidence, .. } => { + assert_eq!(evidence.checks.len(), 2); + assert_eq!(evidence.checks[0].kind, crate::recipe::EvidenceKind::CmdExit); + assert_eq!(evidence.checks[1].kind, crate::recipe::EvidenceKind::Proptest); + } + other => panic!("esperaba SourcePatch, obtuve {other:?}"), + } + // Roundtrip YAML: el bloque evidence sobrevive. + let yaml = swm.to_yaml().unwrap(); + assert!(yaml.contains("evidence"), "el YAML debe traer evidence: {yaml}"); + let back = Swm::from_yaml(&yaml).unwrap(); + match &back.mutations[0] { + Mutation::SourcePatch { evidence, .. } => assert_eq!(evidence.checks.len(), 2), + other => panic!("esperaba SourcePatch, obtuve {other:?}"), + } + back.verify_schema().unwrap(); + } + + #[test] + fn verify_schema_rejects_evidence_with_empty_cmd() { + let yaml = r#" +swm_version: 1 +base: + distro_version: "dev" +mutations: + - type: source_patch + tarball: "https://x/a.tar.gz" + sha256: "deadbeef" + build: {} + target_bin: "/usr/bin/x" + evidence: + checks: + - kind: cmd-exit + cmd: " " +"#; + let swm = Swm::from_yaml(yaml).unwrap(); + let err = swm.verify_schema().unwrap_err().to_string(); + assert!(err.contains("vacío"), "mensaje inesperado: {err}"); + } + + #[test] + fn source_patch_without_evidence_omits_block() { + // skip_serializing_if ⇒ un paquete sin evidencia no emite el bloque (compat hacia atrás). + let toml = r#" +name = "x" +version = "0" +[source] +repo = "git://x" +commit = "deadbeef" +[build] +"#; + let recipe = crate::Recipe::from_toml(toml).unwrap(); + let swm = Swm::from_recipe(&recipe, "/usr/bin/x", None, None, "dev").unwrap(); + let yaml = swm.to_yaml().unwrap(); + assert!(!yaml.contains("evidence"), "no debería emitir evidence: {yaml}"); + } } diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 84a28116..9b802971 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -379,6 +379,7 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { target_bin: format!("/usr/bin/{name}"), expected_hash: None, deps: Default::default(), + evidence: Default::default(), }; let store = match hammer_core::Store::open(&store_root) { Ok(s) => s,