From 6ac6438c00ef554cc399483408f6331bf09b1091 Mon Sep 17 00:00:00 2001 From: sergio Date: Thu, 2 Jul 2026 13:33:25 -0400 Subject: [PATCH] H1c (proof-carrying recipes): cablea la evidencia al Orchestrator VERIFY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La evidencia corre del lado del BUILD (hammer-agent no depende de hammer-build: separación PROPONE/CONSTRUYE). Piezas: - proto: RecipeInline lleva `evidence`; Event::BuildReady lleva `verdict: Option` (+ EvidenceCheckVerdict). Ambos con skip_serializing_if ⇒ wire compatible con clientes pre-H1c. - hammerd/bus: run_compile ejecuta la evidencia declarada tras sellar el artefacto (run_evidence vía swm_bridge::recipe_from_source_patch) y adjunta el veredicto en BuildReady. Si ni se pudo ejecutar ⇒ veredicto fallido sintético (no pasa en silencio). El lab reporta; el gate vive en el agente. - client: compile() devuelve CompileOutcome { artifact, verdict }. - orchestrator: VERIFY lee el veredicto; si all_passed=false empuja VerifyCheck::fail y ABORTA antes de hidratar (artefacto sellado, no toca el sistema). Nuevo constructor VerifyCheck::fail. Tests: roundtrip del veredicto en proto; stub-bus refleja evidencia→verdict; nueva prueba de integración orchestrator_evidence_gate (veredicto fallido ⇒ run() aborta con "no se propone" y no hidrata). Suite completa verde (33 suites). Co-Authored-By: Claude Opus 4.8 --- crates/hammer-agent/src/client.rs | 12 +- crates/hammer-agent/src/lib.rs | 2 +- crates/hammer-agent/src/orchestrator.rs | 41 ++++- .../tests/client_against_stub_bus.rs | 53 +++++- .../tests/orchestrator_evidence_gate.rs | 158 ++++++++++++++++++ crates/hammer-core/src/proto.rs | 65 ++++++- crates/hammerd/src/bus.rs | 77 ++++++++- crates/hammerd/tests/bus_e2e.rs | 1 + docs/15-frontier-ai-native.md | 12 +- 9 files changed, 401 insertions(+), 20 deletions(-) create mode 100644 crates/hammer-agent/tests/orchestrator_evidence_gate.rs diff --git a/crates/hammer-agent/src/client.rs b/crates/hammer-agent/src/client.rs index 9ed1243a..b01145e7 100644 --- a/crates/hammer-agent/src/client.rs +++ b/crates/hammer-agent/src/client.rs @@ -44,6 +44,14 @@ pub struct Welcome { pub peer: Peer, } +/// Resultado de `compile`: el hash del artefacto sellado más el veredicto de la evidencia (H1c). +/// `verdict` es `None` cuando la receta no declaró evidencia. +#[derive(Debug, Clone)] +pub struct CompileOutcome { + pub artifact: String, + pub verdict: Option, +} + /// Estado compartido entre el reader thread y el caller. struct Inbox { /// Eventos asíncronos (Modified/Crashed) en orden de llegada. @@ -123,10 +131,10 @@ impl AgentClient { /// Bloquea hasta `BuildReady` o `BuildFailed`. `timeout` es el wall-clock máximo — /// el lab puede tardar varios minutos, así que el default debería ser generoso. - pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result { + pub fn compile(&mut self, recipe: RecipeInline, timeout: Duration) -> Result { self.send(&Command::Compile { recipe })?; match self.recv_pending(timeout)? { - Event::BuildReady { artifact, .. } => Ok(artifact), + Event::BuildReady { artifact, verdict, .. } => Ok(CompileOutcome { artifact, verdict }), Event::BuildFailed { reason, .. } => Err(ClientError::Bus { code: "build_failed".into(), msg: reason, diff --git a/crates/hammer-agent/src/lib.rs b/crates/hammer-agent/src/lib.rs index 5d34ff9f..4f0355b3 100644 --- a/crates/hammer-agent/src/lib.rs +++ b/crates/hammer-agent/src/lib.rs @@ -22,7 +22,7 @@ pub mod orchestrator; pub mod translator; pub mod translator_claude; -pub use client::{AgentClient, ClientError, Welcome}; +pub use client::{AgentClient, ClientError, CompileOutcome, Welcome}; pub use orchestrator::{Orchestrator, Proposal, VerifyCheck}; pub use translator::{IntentCatalog, IntentTranslator, MockTranslator, SystemContext, TranslateError}; pub use translator_claude::{ClaudeConfig, ClaudeTranslator, HttpClient}; diff --git a/crates/hammer-agent/src/orchestrator.rs b/crates/hammer-agent/src/orchestrator.rs index b8e48c63..ef06647c 100644 --- a/crates/hammer-agent/src/orchestrator.rs +++ b/crates/hammer-agent/src/orchestrator.rs @@ -170,7 +170,7 @@ impl Orchestrator { target_bin, expected_hash, deps: _, - evidence: _, // H1c cableará la ejecución de la evidencia en el paso VERIFY + evidence, // [H1c] viaja al lab; el veredicto vuelve en BuildReady } => { let CompileMode::ViaBus { store, timeout, .. } = &self.compile else { skipped_source_patches += 1; @@ -195,11 +195,13 @@ impl Orchestrator { target: build.target.clone(), link: build.link.clone(), flags: build.flags.clone(), + evidence: evidence.clone(), }; let client = maybe_client.as_mut().expect("client conectado arriba"); - let artifact = client + let outcome = client .compile(recipe, *timeout) .map_err(Error::Client)?; + let artifact = outcome.artifact; if let Some(expected) = expected_hash { let want = expected.strip_prefix("b3:").unwrap_or(expected); let got = artifact.strip_prefix("b3:").unwrap_or(&artifact); @@ -210,6 +212,37 @@ impl Orchestrator { ))); } } + // [H1c] VERIFY con evidencia: el lab ya ejecutó la evidencia declarada tras + // sellar el artefacto y nos devolvió el veredicto. Es el gate "no proponer": + // si algún check falló, NO hidratamos (el artefacto queda sellado en el store, + // pero no toca el sistema) y abortamos con el detalle. + if let Some(verdict) = &outcome.verdict { + for c in &verdict.checks { + if !c.passed { + checks.push(VerifyCheck::fail(format!( + "evidencia [{}] {}: {}", + c.kind.as_str(), + c.cmd, + c.detail + ))); + } + } + if !verdict.all_passed { + let n_fail = verdict.checks.iter().filter(|c| !c.passed).count(); + return Err(Error::Orchestrate(format!( + "source_patch {target_bin}: {n_fail}/{} check(s) de evidencia \ + fallaron ⇒ no se propone (artefacto {artifact} sellado pero \ + NO hidratado)", + verdict.checks.len() + ))); + } + let lvl = verdict.max_level.map(|k| k.as_str()).unwrap_or("—"); + checks.push(VerifyCheck::pass(format!( + "evidencia: {} check(s) pasan (verificado hasta el estrato '{lvl}')", + verdict.checks.len() + ))); + } + // Hidratamos localmente desde el store (sin pasar por el bus, evita un // segundo Inject que duplicaría trabajo). let store = hammer_core::Store::open(store)?; @@ -430,6 +463,10 @@ impl VerifyCheck { pub fn warn(msg: impl Into) -> Self { Self { level: CheckLevel::Warn, msg: msg.into() } } + /// [H1c] Un check que FALLÓ. Hoy lo produce el gate de evidencia: la mutación no se propone. + pub fn fail(msg: impl Into) -> Self { + Self { level: CheckLevel::Fail, msg: msg.into() } + } } /// El resultado del bucle. El humano lo lee para decidir `hammer commit ` (si diff --git a/crates/hammer-agent/tests/client_against_stub_bus.rs b/crates/hammer-agent/tests/client_against_stub_bus.rs index 009530be..80f3881a 100644 --- a/crates/hammer-agent/tests/client_against_stub_bus.rs +++ b/crates/hammer-agent/tests/client_against_stub_bus.rs @@ -82,13 +82,35 @@ fn stub_serve(stream: UnixStream, rx_async: std::sync::mpsc::Receiver) { Err(_) => continue, }; let response = match cmd { - Command::Compile { recipe } => Event::BuildReady { - recipe: recipe.name.clone(), - artifact: format!( - "b3:stub-{}", - recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc") - ), - }, + Command::Compile { recipe } => { + // El stub refleja la evidencia declarada como un veredicto "todo pasa" para + // ejercitar el camino H1c del cliente (sin lab real que la ejecute). + let verdict = (!recipe.evidence.is_empty()).then(|| { + hammer_core::proto::EvidenceVerdict { + all_passed: true, + max_level: recipe.evidence.checks.iter().map(|c| c.kind).max(), + checks: recipe + .evidence + .checks + .iter() + .map(|c| hammer_core::proto::EvidenceCheckVerdict { + kind: c.kind, + cmd: c.cmd.clone(), + passed: true, + detail: "stub ok".into(), + }) + .collect(), + } + }); + Event::BuildReady { + recipe: recipe.name.clone(), + artifact: format!( + "b3:stub-{}", + recipe.commit.as_deref().or(recipe.sha256.as_deref()).unwrap_or("nosrc") + ), + verdict, + } + } Command::Query { what, .. } => Event::QueryResult { what, value: serde_json::json!({"stub": true}), @@ -145,11 +167,24 @@ fn client_handshake_compile_and_async_modified() { target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], + evidence: hammer_core::Evidence { + checks: vec![hammer_core::EvidenceCheck { + kind: hammer_core::EvidenceKind::CmdExit, + cmd: "grep --version".into(), + expected_exit: 0, + expected_output: None, + }], + }, }; - let artifact = client + let outcome = client .compile(recipe, Duration::from_secs(2)) .expect("BuildReady"); - assert_eq!(artifact, "b3:stub-abc123"); + assert_eq!(outcome.artifact, "b3:stub-abc123"); + // [H1c] el veredicto de la evidencia llega por BuildReady y el cliente lo expone. + let verdict = outcome.verdict.expect("verdict presente (declaramos evidencia)"); + assert!(verdict.all_passed); + assert_eq!(verdict.checks.len(), 1); + assert_eq!(verdict.max_level, Some(hammer_core::EvidenceKind::CmdExit)); // El stub también puede empujar eventos asíncronos. Inyectamos un Modified y vemos // que el cliente lo entrega por drain_async()/next_async(). diff --git a/crates/hammer-agent/tests/orchestrator_evidence_gate.rs b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs new file mode 100644 index 00000000..f01251e6 --- /dev/null +++ b/crates/hammer-agent/tests/orchestrator_evidence_gate.rs @@ -0,0 +1,158 @@ +//! [H1c] Gate de evidencia en el orquestador contra un bus stub. +//! +//! El lab (aquí, un stub) ejecuta la evidencia tras sellar el artefacto y devuelve el veredicto +//! en `BuildReady`. Si algún check falla, `Orchestrator::run` debe ABORTAR con "no se propone" y +//! NO hidratar nada al `prefix` — el gate "falla ⇒ no se propone" del SDD 15 §H1. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +use hammer_agent::orchestrator::{ApplyTarget, CompileMode, Orchestrator}; +use hammer_agent::MockTranslator; +use hammer_core::proto::{ + Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, PROTOCOL_VERSION, +}; +use hammer_core::swm::{Base, Mutation, Swm, SwmBuild}; +use hammer_core::{BaseRef, Evidence, EvidenceCheck, EvidenceKind}; +use std::collections::BTreeMap; + +/// Bus stub que responde a `Compile` con `BuildReady` cuyo veredicto tiene `all_passed=false`. +fn start_failing_bus(sock: PathBuf) { + let _ = std::fs::remove_file(&sock); + let listener = UnixListener::bind(&sock).expect("bind stub"); + thread::spawn(move || { + if let Some(Ok(stream)) = listener.incoming().next() { + thread::spawn(move || serve(stream)); + } + }); +} + +fn serve(stream: UnixStream) { + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + let send = |w: &mut UnixStream, ev: &Event| { + w.write_all(serde_json::to_string(ev).unwrap().as_bytes()).unwrap(); + w.write_all(b"\n").unwrap(); + w.flush().unwrap(); + }; + + let mut first = String::new(); + reader.read_line(&mut first).unwrap(); + let Ok(Command::Hello { .. }) = serde_json::from_str::(first.trim()) else { + return; + }; + send( + &mut writer, + &Event::Welcome { + ver: PROTOCOL_VERSION, + caps: vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init], + peer: Peer { uid: 1000, gid: 1000, pid: 1 }, + }, + ); + + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + if line.trim().is_empty() { + continue; + } + let Ok(cmd) = serde_json::from_str::(line.trim()) else { + continue; + }; + if let Command::Compile { recipe } = cmd { + send( + &mut writer, + &Event::BuildReady { + recipe: recipe.name.clone(), + artifact: "b3:stub-fail".into(), + verdict: Some(EvidenceVerdict { + all_passed: false, + max_level: None, + checks: vec![EvidenceCheckVerdict { + kind: EvidenceKind::CmdExit, + cmd: "grep --version".into(), + passed: false, + detail: "exit 7 ≠ esperado 0".into(), + }], + }), + }, + ); + } + } +} + +fn swm_with_evidence() -> Swm { + Swm { + swm_version: 1, + base: Base { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }, + mutations: vec![Mutation::SourcePatch { + repo: Some("git://x/grep.git".into()), + commit: Some("abc".into()), + tarball: None, + sha256: None, + strip_components: None, + patch: None, + patch_url: None, + build: SwmBuild { + compiler: "zig-cc".into(), + target: "x86_64-linux-musl".into(), + link: "static".into(), + flags: vec![], + phases: Default::default(), + zig_version: None, + }, + target_bin: "/usr/bin/grep".into(), + expected_hash: None, + deps: Default::default(), + evidence: Evidence { + checks: vec![EvidenceCheck { + kind: EvidenceKind::CmdExit, + cmd: "grep --version".into(), + expected_exit: 0, + expected_output: None, + }], + }, + }], + signature: None, + } +} + +#[test] +fn failing_evidence_aborts_and_does_not_hydrate() { + let d = tempfile::tempdir().unwrap(); + let sock = d.path().join("agent.sock"); + start_failing_bus(sock.clone()); + for _ in 0..50 { + if sock.exists() { + break; + } + thread::sleep(Duration::from_millis(10)); + } + + let prefix = d.path().join("prefix"); + let store = d.path().join("store"); + let base = BaseRef { distro_version: "2026-06-06".into(), pins: BTreeMap::new() }; + let t = MockTranslator::empty().with_intent("compile grep", swm_with_evidence()); + let o = Orchestrator::new( + t, + base, + ApplyTarget::Prefix { prefix: prefix.clone() }, + CompileMode::ViaBus { sock, store, timeout: Duration::from_secs(5) }, + ); + + let err = o.run("compile grep").unwrap_err().to_string(); + assert!(err.contains("no se propone"), "mensaje inesperado: {err}"); + assert!(err.contains("evidencia") || err.contains("check(s)"), "{err}"); + + // El gate corta ANTES de hidratar: el target_bin no debe existir bajo el prefix. + assert!( + !prefix.join("usr/bin/grep").exists(), + "el binario se hidrató pese al fallo de evidencia" + ); +} diff --git a/crates/hammer-core/src/proto.rs b/crates/hammer-core/src/proto.rs index d6733efd..7c574c7e 100644 --- a/crates/hammer-core/src/proto.rs +++ b/crates/hammer-core/src/proto.rs @@ -84,6 +84,11 @@ pub struct RecipeInline { pub link: String, #[serde(default)] pub flags: Vec, + /// [H1c] Bloque de evidencia de comportamiento (proof-carrying recipe). Viaja al lado del + /// build para que `hammerd` la EJECUTE tras sellar el artefacto y devuelva el veredicto en + /// `BuildReady`. Vacío ⇒ receta sin evidencia (compat con clientes previos a H1c). + #[serde(default, skip_serializing_if = "crate::recipe::Evidence::is_empty")] + pub evidence: crate::recipe::Evidence, } fn default_compiler() -> String { @@ -108,7 +113,15 @@ pub enum Event { peer: Peer, }, /// Build terminó OK. `recipe` es el `name` original; `artifact` es el hash sellado. - BuildReady { recipe: String, artifact: String }, + /// `verdict` [H1c]: `None` si la receta no declaró evidencia; `Some` con el resultado de + /// ejecutarla en el sandbox reproducible. `all_passed=false` ⇒ el artefacto quedó sellado + /// pero una prueba declarada falló — el orquestador lo usa como gate "no proponer". + BuildReady { + recipe: String, + artifact: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + verdict: Option, + }, /// Build falló. `log_tail` puede llevar las últimas líneas del log del lab si están /// disponibles (opcional). BuildFailed { @@ -149,6 +162,29 @@ pub struct Peer { pub pid: i32, } +/// [H1c] Forma serializable del `EvidenceReport` de `hammer_build` que viaja en `BuildReady`. +/// El lado del build (hammerd) ejecuta la evidencia y la adjunta; el orquestador la lee sin +/// depender de `hammer_build` (la separación agente-PROPONE / lab-CONSTRUYE se preserva). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceVerdict { + /// Gate: `false` ⇒ al menos un check declarado falló ⇒ el orquestador no propone la mutación. + pub all_passed: bool, + /// Estrato de confianza más alto que pasó (para reportar "verificado hasta …"). No promete + /// que ese nivel *baste*, sólo que lo declarado a ese nivel pasó. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_level: Option, + pub checks: Vec, +} + +/// [H1c] Veredicto de un check individual, ya evaluado (exit + hash del stdout) en el sandbox. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EvidenceCheckVerdict { + pub kind: crate::recipe::EvidenceKind, + pub cmd: String, + pub passed: bool, + pub detail: String, +} + /// Capacidades concedidas a una conexión. Por defecto `query`. La política viene del /// fichero de policy del daemon (SDD 07 §4); el peer no las elige. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -226,18 +262,23 @@ mod tests { target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec!["--enable-foo".into()], + evidence: Default::default(), }, }; let s = serde_json::to_string(&c).unwrap(); assert!(s.contains(r#""t":"compile""#)); assert!(s.contains(r#""name":"grep""#)); + // Sin evidencia declarada, el bloque no se serializa (compat con clientes pre-H1c). + assert!(!s.contains("evidence"), "{s}"); } #[test] fn build_ready_event() { + // Sin veredicto, el JSON es idéntico al de antes de H1c (skip_serializing_if). let e = Event::BuildReady { recipe: "grep".into(), artifact: "b3:deadbeef".into(), + verdict: None, }; let s = serde_json::to_string(&e).unwrap(); assert_eq!( @@ -246,6 +287,28 @@ mod tests { ); } + #[test] + fn build_ready_with_verdict_roundtrips() { + let e = Event::BuildReady { + recipe: "tree".into(), + artifact: "b3:cafe".into(), + verdict: Some(EvidenceVerdict { + all_passed: false, + max_level: Some(crate::recipe::EvidenceKind::CmdExit), + checks: vec![EvidenceCheckVerdict { + kind: crate::recipe::EvidenceKind::CmdExit, + cmd: "tree --version".into(), + passed: false, + detail: "exit 7 ≠ esperado 0".into(), + }], + }), + }; + let s = serde_json::to_string(&e).unwrap(); + assert!(s.contains(r#""all_passed":false"#), "{s}"); + let back: Event = serde_json::from_str(&s).unwrap(); + assert_eq!(back, e); + } + #[test] fn error_event_minimal() { let e = Event::Error { code: "no_cap".into(), msg: "no tienes 'compile'".into() }; diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs index 9b802971..8a3dde75 100644 --- a/crates/hammerd/src/bus.rs +++ b/crates/hammerd/src/bus.rs @@ -25,7 +25,10 @@ use std::thread; use nix::sys::socket::{getsockopt, sockopt::PeerCredentials}; use crate::events::EventBus; -use hammer_core::proto::{Cap, Command, Event, Peer, RecipeInline, PROTOCOL_VERSION}; +use hammer_core::proto::{ + Cap, Command, Event, EvidenceCheckVerdict, EvidenceVerdict, Peer, RecipeInline, + PROTOCOL_VERSION, +}; /// Política de capacidades: dado el `Peer` (ya autenticado por SO_PEERCRED), devuelve qué /// caps tiene esa conexión. Por defecto: misma UID que el daemon ⇒ todo menos `inject-real`; @@ -379,7 +382,9 @@ 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(), + // [H1c] La evidencia declarada por el cliente viaja a la mutación para ejecutarse tras + // sellar el artefacto (ver `evidence_verdict`). + evidence: recipe.evidence, }; let store = match hammer_core::Store::open(&store_root) { Ok(s) => s, @@ -395,9 +400,15 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { let cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); match hammer_build::build_source_patch(&mutation, &cfg, &store, None, None) { Ok(h) => { + // [H1c] El artefacto está sellado; ahora EJECUTAMOS la evidencia declarada y + // adjuntamos el veredicto. El gate ("no proponer" si falla) vive en el orquestador + // —el lab sólo construye y reporta—; por eso enviamos `BuildReady` con veredicto + // aunque algún check falle. + let verdict = evidence_verdict(&mutation, &cfg, &store); let _ = tx.send(Event::BuildReady { recipe: name, artifact: h.as_str().to_string(), + verdict, }); } Err(e) => { @@ -416,6 +427,67 @@ fn run_compile(recipe: RecipeInline, store_root: PathBuf, tx: Sender) { } } +/// [H1c] Ejecuta la evidencia declarada de una mutación `source_patch` y la reduce al veredicto +/// serializable del bus. Devuelve: +/// - `None` si la receta no declara evidencia (nada que correr). +/// - `Some(all_passed=true/false, …)` con el resultado por check. +/// - `Some(all_passed=false, …)` sintético si la evidencia ni siquiera pudo ejecutarse (falta una +/// build-dep, error de sandbox…): eso también debe bloquear la propuesta, no pasar en silencio. +fn evidence_verdict( + mutation: &hammer_core::swm::Mutation, + cfg: &hammer_build::BuildConfig, + store: &hammer_core::Store, +) -> Option { + let has_evidence = matches!( + mutation, + hammer_core::swm::Mutation::SourcePatch { evidence, .. } if !evidence.is_empty() + ); + if !has_evidence { + return None; + } + match run_evidence_inner(mutation, cfg, store) { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!(error = %e, "evidence: no se pudo ejecutar; veredicto = fallo"); + Some(EvidenceVerdict { + all_passed: false, + max_level: None, + checks: vec![EvidenceCheckVerdict { + kind: hammer_core::EvidenceKind::CmdExit, + cmd: "".into(), + passed: false, + detail: format!("la evidencia no pudo ejecutarse: {e}"), + }], + }) + } + } +} + +fn run_evidence_inner( + mutation: &hammer_core::swm::Mutation, + cfg: &hammer_build::BuildConfig, + store: &hammer_core::Store, +) -> hammer_core::Result { + let catalog_dir = hammer_build::swm_bridge::catalog_dir_for(cfg, None); + std::fs::create_dir_all(&catalog_dir)?; + let recipe = hammer_build::swm_bridge::recipe_from_source_patch(mutation, None, &catalog_dir)?; + let report = hammer_build::run_evidence(&recipe, cfg, store)?; + Ok(EvidenceVerdict { + all_passed: report.all_passed(), + max_level: report.max_level_passed(), + checks: report + .results + .iter() + .map(|r| EvidenceCheckVerdict { + kind: r.kind, + cmd: r.cmd.clone(), + passed: r.outcome.passed, + detail: r.outcome.detail.clone(), + }) + .collect(), + }) +} + fn query_file(path: &str) -> serde_json::Value { let p = Path::new(path); let meta = match std::fs::symlink_metadata(p) { @@ -510,6 +582,7 @@ caps = ["query", "compile", "inject", "inject-real", "init"] target: "t".into(), link: "static".into(), flags: vec![], + evidence: Default::default(), }, }), "compile" diff --git a/crates/hammerd/tests/bus_e2e.rs b/crates/hammerd/tests/bus_e2e.rs index 12799879..2209955a 100644 --- a/crates/hammerd/tests/bus_e2e.rs +++ b/crates/hammerd/tests/bus_e2e.rs @@ -147,6 +147,7 @@ fn missing_cap_returns_error_no_cap() { target: "x86_64-linux-musl".into(), link: "static".into(), flags: vec![], + evidence: Default::default(), }, }, ); diff --git a/docs/15-frontier-ai-native.md b/docs/15-frontier-ai-native.md index 48108f01..26090726 100644 --- a/docs/15-frontier-ai-native.md +++ b/docs/15-frontier-ai-native.md @@ -49,17 +49,23 @@ del lado del init: **el permiso deriva de una propiedad verificada del artefacto firmó.** H1 es esa idea, subida del boot al build. **Tickets:** -- **H1a** — Extender el `.swm` (SDD 06) con un bloque `evidence`: lista de `{kind, cmd, expected}` +- **H1a** ✅ — Extender el `.swm` (SDD 06) con un bloque `evidence`: lista de `{kind, cmd, expected}` donde `kind ∈ {proptest, contract, kani, cmd-exit}`. Serialización YAML estable + `verify_schema`. -- **H1b** — El checker: `hammer swm-verify --evidence` corre cada ítem **dentro del sandbox +- **H1b** ✅ — El checker: `hammer swm-verify --evidence` corre cada ítem **dentro del sandbox reproducible** y exige `expected`. Pequeño y auditable a propósito (no un framework: un runner de comandos con hash del output). Falla ⇒ no se propone. Reusa `Sandbox::run` + el `log_tail` ya existente (Fase 5). -- **H1c** — Cablear al `Orchestrator`: VERIFY consume `evidence`; el `Proposal` lleva el +- **H1c** ✅ — Cablear al `Orchestrator`: VERIFY consume `evidence`; el `Proposal` lleva el **veredicto de evidencia** además del diff. El humano ve "reproduce + estas N pruebas pasan", no sólo "reproduce". **Frontera:** el checker confía en que las pruebas *cubren* lo que importa — eso lo juzga el humano; H1 garantiza que *lo declarado pasa*, no que *lo declarado basta*. Se dice así. + **Implementación:** la evidencia corre del lado del BUILD (no del agente — `hammer-agent` no + depende de `hammer-build`, separación PROPONE/CONSTRUYE). `proto::RecipeInline` lleva `evidence` + y `Event::BuildReady` lleva `verdict: Option`; `hammerd::bus` la ejecuta tras + sellar el artefacto (`run_evidence` vía `swm_bridge::recipe_from_source_patch`) y adjunta el + veredicto; el `Orchestrator` lee el veredicto y, si `all_passed=false`, empuja `VerifyCheck::fail` + y **aborta antes de hidratar** (el artefacto queda sellado pero no toca el sistema). **Frontera honesta.** Property tests no son prueba; Kani/Creusot sí pero sólo cubren crates que se dejan. H1 **estratifica** la confianza (`cmd-exit` < `proptest` < `contract` < `kani`) y la