diff --git a/crates/hammer-build/Cargo.toml b/crates/hammer-build/Cargo.toml index d64e2c76..34e66be0 100644 --- a/crates/hammer-build/Cargo.toml +++ b/crates/hammer-build/Cargo.toml @@ -15,6 +15,10 @@ tracing.workspace = true sha2.workspace = true hex.workspace = true serde_json.workspace = true +# harkaq (SDD 16) necesita SIGTERM para despertar al lector: Child::kill() manda SIGKILL y el +# lector moriría mudo, sin emitir el Verdict — justo lo que no queremos de un componente cuyo +# producto ES el veredicto. +libc = "0.2" [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-build/src/harkaq.rs b/crates/hammer-build/src/harkaq.rs new file mode 100644 index 00000000..0f344853 --- /dev/null +++ b/crates/hammer-build/src/harkaq.rs @@ -0,0 +1,304 @@ +//! harkaq — la jaula del build (SDD 16). Integración con el sandbox de bwrap. +//! +//! Cablea las tres piezas de `scripts/harkaq/` al `Sandbox` real: deriva la política de la +//! clausura declarada (D1), lanza el lector de evidencia, mete `harkaq-exec` como último eslabón +//! antes del `sh -c` del build, y recoge el `Verdict`. +//! +//! **Inerte por defecto.** Sin `HARKAQ=1` no se emite un solo argumento distinto de bwrap, no se +//! toca el entorno y no se lanza ningún proceso: el sandbox es byte-por-byte el de siempre. Es un +//! requisito duro, no una cortesía — hay 700+ artefactos sellados cuyo hash no puede moverse por +//! encender un diagnóstico (ver el patrón de `cargo-recipe-ar-wrapper`: nada que re-hashee). +//! +//! Variables: +//! HARKAQ=1 enciende +//! HARKAQ_BIN=DIR dir con harkaq-exec y harkaq-audit (default ~/.cache/harkaq) +//! HARKAQ_BASE=F fichero con el runtime base, generado por `harkaq-base-closure.py` +//! (§4.3: el runtime base es POR ROOTFS y se deriva; acá se consume ya hecho) + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; + +/// Config de harkaq para un build. `from_env` devuelve `None` salvo que `HARKAQ=1`. +#[derive(Debug, Clone)] +pub struct Harkaq { + pub bin: PathBuf, + pub base: Option, +} + +/// Lo que viaja de vuelta tras una fase enjaulada: el fichero del veredicto crudo y el canario +/// con el que se lo interpreta. +#[derive(Debug)] +pub struct Corrida { + pub policy: PathBuf, + pub canary: String, + pub canary_src: PathBuf, + verdict: PathBuf, + reader: Child, +} + +impl Harkaq { + pub fn from_env() -> Option { + if std::env::var("HARKAQ").ok().as_deref() != Some("1") { + return None; + } + let bin = std::env::var("HARKAQ_BIN") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/root".into())) + .join(".cache/harkaq") + }); + Some(Harkaq { + bin, + base: std::env::var("HARKAQ_BASE").ok().map(PathBuf::from), + }) + } + + pub fn exec_bin(&self) -> PathBuf { + self.bin.join("harkaq-exec") + } + + /// Deriva la política de la clausura declarada (D1: no se escribe, se deriva). + /// + /// Grano POR FICHERO, no por directorio: el sandbox funde las deps y el rootfs Alpine en el + /// mismo `/usr` (`--overlay-src` × N), así que `/usr/include/zlib.h` (dep declarada) y + /// `/usr/include/stdio.h` (Alpine) son el mismo directorio y una regla sobre `/usr` concedería + /// las dos — la evidencia dejaría de valer (SDD §4.1). En el host sí sabemos qué aporta cada + /// dep, así que la clausura se enumera fichero a fichero. + fn derivar_politica( + &self, + deps: &[PathBuf], + contratos: &[String], + dst: &Path, + ) -> hammer_core::Result<()> { + let mut ro: Vec = Vec::new(); + let mut dirs: std::collections::BTreeSet = Default::default(); + + for dep in deps { + let real = std::fs::canonicalize(dep).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("canonicalize {}: {e}", dep.display())) + })?; + recorrer(&real, &real, &mut ro, &mut dirs)?; + } + + let mut f = std::fs::File::create(dst).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("crear política {}: {e}", dst.display())) + })?; + writeln!(f, "# harkaq: clausura derivada de {} dep(s)", deps.len()).ok(); + + // Superficies de CONTRATO del sandbox, no de la receta: las monta bwrap, no salen de + // Alpine, así que no son deuda (SDD §4.2). Las decide el `Sandbox` —que es quien sabe + // cuáles montó— y no harkaq: `/cache` sólo existe si hay `cache_dir`, y `harkaq-exec` + // falla duro ante un path inexistente (a propósito: una entrada que no existe es un bug + // de la política, y tragárselo la haría más chica que la clausura ⇒ falsos positivos). + writeln!(f, "# superficies de contrato del sandbox").ok(); + for l in contratos { + writeln!(f, "{l}").ok(); + } + + if let Some(base) = &self.base { + let txt = std::fs::read_to_string(base).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("leer HARKAQ_BASE {}: {e}", base.display())) + })?; + writeln!(f, "# runtime base ({})", base.display()).ok(); + f.write_all(txt.as_bytes()).ok(); + } + + // `list` de los ancestros de cada fichero de la clausura: pkgconf y los tests de + // configure NECESITAN escanear (p.ej. /usr/lib/pkgconfig), pero listar no es leer. + // Landlock separa READ_DIR de READ_FILE ⇒ se concede el listado y se sigue denegando la + // lectura de lo no declarado (§4.1). + writeln!(f, "# dirs de la clausura (listar, no leer)").ok(); + for d in &dirs { + writeln!(f, "list {d}").ok(); + } + writeln!(f, "# clausura ({} ficheros)", ro.len()).ok(); + ro.sort(); + ro.dedup(); + for r in &ro { + writeln!(f, "ro {r}").ok(); + } + Ok(()) + } + + /// Arranca el lector y deja todo listo para la fase. El lector se lanza ANTES del build y no + /// se le da salida hasta que confirma que escucha: si el build arrancara antes, sus primeras + /// denegaciones —el canario entre ellas— se emitirían sin nadie al otro lado y el veredicto + /// sería `SinEvidencia` por una carrera, no por un problema real. + pub fn preparar( + &self, + deps: &[PathBuf], + contratos: &[String], + id: &str, + ) -> hammer_core::Result { + let tmp = std::env::temp_dir().join(format!("harkaq-{id}-{}", std::process::id())); + std::fs::create_dir_all(&tmp).ok(); + let policy = tmp.join("policy"); + self.derivar_politica(deps, contratos, &policy)?; + + // El canario va en la RAÍZ, no en /tmp: la política concede `rw /tmp` (es el HOME del + // build), así que un canario ahí caería DENTRO de la clausura, se leería sin problema y + // no denunciaría nada ⇒ SinEvidencia eterno. Tiene que vivir donde la política no + // alcanza, por construcción. La raíz del sandbox es escribible (`--tmp-overlay /`). + let canary = format!("/harkaq-canary-{id}-{}", std::process::id()); + let canary_src = tmp.join("canary"); + std::fs::write(&canary_src, b"harkaq\n").ok(); + + let verdict = tmp.join("verdict.json"); + let vf = std::fs::File::create(&verdict).map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!("crear verdict: {e}")) + })?; + let reader = Command::new(self.bin.join("harkaq-audit")) + .arg("--canary") + .arg(&canary) + .arg("--timeout") + .arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into())) + .stdout(Stdio::from(vf)) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| { + hammer_core::Error::Other(anyhow::anyhow!( + "spawn harkaq-audit ({}): {e} — ¿compilado y con `setcap cap_audit_read`?", + self.bin.display() + )) + })?; + // El lector necesita estar en el multicast antes de que el build empiece. `--ready-fd` es + // la vía limpia; acá basta una pausa corta porque el bwrap que sigue tarda mucho más en + // levantar los namespaces que el lector en bindear. + std::thread::sleep(std::time::Duration::from_millis(300)); + + Ok(Corrida { + policy, + canary, + canary_src, + verdict, + reader, + }) + } + + /// Cierra la corrida: despierta al lector y devuelve el veredicto crudo (JSON). + pub fn recoger(&self, mut c: Corrida) -> String { + // Gracia para que llegue el `deallocated` del dominio (el kernel lo emite con retraso, + // vía RCU/workqueue) antes de despertar al lector. + std::thread::sleep(std::time::Duration::from_millis(1500)); + unsafe { + libc::kill(c.reader.id() as i32, libc::SIGTERM); + } + let _ = c.reader.wait(); + std::fs::read_to_string(&c.verdict).unwrap_or_else(|e| { + format!("{{\"estado\":\"SinEvidencia\",\"motivo\":\"no pude leer el verdict: {e}\"}}") + }) + } +} + +/// Recorre el árbol de una dep y traduce cada fichero a su ruta DENTRO del sandbox. La +/// traducción es sólo quitar el prefijo del store: el resto del path ya es el del sandbox, +/// porque `--overlay-src ` funde `/usr/...` en `/usr/...`. +fn recorrer( + raiz: &Path, + dir: &Path, + ro: &mut Vec, + dirs: &mut std::collections::BTreeSet, +) -> hammer_core::Result<()> { + let rd = match std::fs::read_dir(dir) { + Ok(r) => r, + Err(_) => return Ok(()), + }; + for e in rd.flatten() { + let p = e.path(); + // `.hammer/` es metadata del artefacto (recipe.toml), no parte de lo que el build + // consume: si el build lo leyera, esa denegación es legítima y la queremos ver. + if p.file_name().is_some_and(|n| n == ".hammer") { + continue; + } + let ft = match e.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if ft.is_dir() { + recorrer(raiz, &p, ro, dirs)?; + } else if let Ok(rel) = p.strip_prefix(raiz) { + let dentro = format!("/{}", rel.display()); + if let Some(d) = Path::new(&dentro).parent() { + let mut d = d.to_path_buf(); + while d.as_os_str() != "/" && !d.as_os_str().is_empty() { + dirs.insert(d.display().to_string()); + d = match d.parent() { + Some(p) => p.to_path_buf(), + None => break, + }; + } + } + ro.push(dentro); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Arma un artefacto de store de mentira: `/-zlib/usr/include/zlib.h`, etc. + fn dep_falsa(nombre: &str) -> PathBuf { + let raiz = std::env::temp_dir().join(format!("harkaq-t-{}-{nombre}", std::process::id())); + let _ = std::fs::remove_dir_all(&raiz); + for d in ["usr/include", "usr/lib/pkgconfig", ".hammer"] { + std::fs::create_dir_all(raiz.join(d)).unwrap(); + } + std::fs::write(raiz.join("usr/include/zlib.h"), b"h").unwrap(); + std::fs::write(raiz.join("usr/lib/libz.a"), b"a").unwrap(); + std::fs::write(raiz.join("usr/lib/pkgconfig/zlib.pc"), b"p").unwrap(); + std::fs::write(raiz.join(".hammer/recipe.toml"), b"r").unwrap(); + raiz + } + + #[test] + fn la_clausura_traduce_del_store_al_sandbox() { + // El corazón de D1. `--overlay-src ` funde `/usr/...` en `/usr/...`, así que + // traducir es quitar el prefijo del store. Puro: se testea sin kernel, en cualquier + // máquina, que es justamente lo que el SDD §4 exige de harkaq-policy. + let dep = dep_falsa("clausura"); + let (mut ro, mut dirs) = (Vec::new(), Default::default()); + recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); + ro.sort(); + assert_eq!( + ro, + vec!["/usr/include/zlib.h", "/usr/lib/libz.a", "/usr/lib/pkgconfig/zlib.pc"] + ); + let _ = std::fs::remove_dir_all(&dep); + } + + #[test] + fn la_metadata_del_artefacto_no_es_clausura() { + // `.hammer/recipe.toml` es metadata, no algo que el build consuma. Si el build lo + // leyera, esa denegación es legítima y la queremos VER, no conceder. + let dep = dep_falsa("metadata"); + let (mut ro, mut dirs) = (Vec::new(), Default::default()); + recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); + assert!(!ro.iter().any(|r| r.contains(".hammer")), "ro={ro:?}"); + let _ = std::fs::remove_dir_all(&dep); + } + + #[test] + fn cada_fichero_deja_listables_sus_ancestros() { + // Sin `list` en los ancestros, un `ls /usr/lib/pkgconfig` de pkgconf sería un falso + // positivo. Listar no es leer: Landlock separa READ_DIR de READ_FILE (§4.1). + let dep = dep_falsa("dirs"); + let (mut ro, mut dirs) = (Vec::new(), Default::default()); + recorrer(&dep, &dep, &mut ro, &mut dirs).unwrap(); + for d in ["/usr", "/usr/lib", "/usr/lib/pkgconfig", "/usr/include"] { + assert!(dirs.contains(d), "falta `list {d}` en {dirs:?}"); + } + let _ = std::fs::remove_dir_all(&dep); + } + + #[test] + fn apagado_por_defecto() { + // Requisito duro: sin HARKAQ=1 no se enciende nada. 700+ artefactos sellados dependen de + // que el sandbox no cambie por añadir un diagnóstico. + if std::env::var("HARKAQ").is_err() { + assert!(Harkaq::from_env().is_none()); + } + } +} diff --git a/crates/hammer-build/src/lib.rs b/crates/hammer-build/src/lib.rs index f40b4d0f..6e615ca8 100644 --- a/crates/hammer-build/src/lib.rs +++ b/crates/hammer-build/src/lib.rs @@ -9,6 +9,7 @@ use hammer_core::{ArtifactHash, Compiler, LinkMode, Phases, Recipe, Store}; pub mod config; pub mod download; pub mod fetch; +pub mod harkaq; pub mod hydrate; pub mod sandbox; pub mod swm_bridge; diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs index f10cebdf..5cc6fb64 100644 --- a/crates/hammer-build/src/sandbox.rs +++ b/crates/hammer-build/src/sandbox.rs @@ -89,7 +89,19 @@ impl Sandbox { /// líneas; si el exit code es != 0, devuelve un [`BuildFailure`] con esa cola en /// `log_tail`, envuelto en `hammer_core::Error::Other`. pub fn run(&self, cmd: &str) -> hammer_core::Result<()> { - let args = self.bwrap_args(cmd); + // harkaq (SDD 16): sin HARKAQ=1 esto es `None` y todo lo de abajo es el camino de + // siempre — mismos args de bwrap, mismo entorno, ningún proceso extra. Inerte por + // requisito: hay 700+ artefactos sellados cuyo hash no puede moverse por encender un + // diagnóstico. + let hk = crate::harkaq::Harkaq::from_env(); + let corrida = match &hk { + Some(h) => Some(h.preparar(&self.deps, &self.contratos_harkaq(), "fase")?), + None => None, + }; + let args = match (&hk, &corrida) { + (Some(h), Some(c)) => self.bwrap_args_harkaq(cmd, h, c), + _ => self.bwrap_args(cmd), + }; tracing::debug!(?args, "bwrap"); let mut child = Command::new("bwrap") .args(&args) @@ -113,6 +125,16 @@ impl Sandbox { let _ = h_out.join(); let _ = h_err.join(); + // El veredicto se recoge SIEMPRE, haya ido bien o mal la fase: un build que falla es + // justamente cuando más interesa saber qué tocó fuera de su clausura. + if let (Some(h), Some(c)) = (&hk, corrida) { + let policy = c.policy.display().to_string(); + let verdict = h.recoger(c); + tracing::info!(%verdict, %policy, "harkaq: verdict"); + eprintln!("[harkaq] {}", verdict.trim()); + eprintln!("[harkaq] política: {policy}"); + } + if !status.success() { let tail_str = { let g = tail.lock().expect("tail mutex"); @@ -221,6 +243,75 @@ impl Sandbox { Some(merged) } + /// Las superficies que define el SANDBOX (no la receta): las monta bwrap, no salen del rootfs + /// Alpine, así que no son deuda de de-Alpinización (SDD 16 §4.2). Vive acá y no en `harkaq` + /// porque es el sandbox quien sabe qué montó: `/cache` sólo existe si hay `cache_dir`, y una + /// política que nombre un path inexistente aborta el build (a propósito). + /// + /// `list /` y `list /opt`: todo directorio concedido necesita listado en sus ancestros o el + /// escaneo del padre es un falso positivo — lo encontró el propio banco de pruebas (§4.4). + fn contratos_harkaq(&self) -> Vec { + let mut v: Vec = [ + "rw /src", "rw /out", "rw /tmp", "rw /dev/null", "ro /dev/urandom", "ro /opt/zig", + "list /", "list /opt", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + if self.cache_dir.is_some() { + // `ZIG_GLOBAL_CACHE_DIR=/cache/zig` ⇒ zig CREA directorios ahí. Sin esto la primera + // fase muere con `fs.make_dir /cache/zig/tmp` — lo cazó la primera corrida real. + v.push("rw /cache".into()); + } + v + } + + /// Como [`bwrap_args`](Self::bwrap_args), pero mete a harkaq como último eslabón: bindea + /// `harkaq-exec`, la política y el canario, y sustituye el `sh -c ` final por + /// `harkaq-exec --policy ... -- sh -c "; "`. + /// + /// Todo lo anterior (overlays, ns, binds, entorno) queda intacto: harkaq NO reemplaza al + /// sandbox de bwrap, se compone encima (§0, no-tesis). + fn bwrap_args_harkaq( + &self, + cmd: &str, + h: &crate::harkaq::Harkaq, + c: &crate::harkaq::Corrida, + ) -> Vec { + // El probe del canario va DENTRO del `sh -c`, o sea DESPUÉS del execve: es el único sitio + // donde valida lo que tiene que validar (que el logging post-exec funciona). Usa SÓLO + // builtins — con `cat` dependería de /bin/cat y moriría si no está en la clausura, sin + // probar nada (SDD §4.2). + let con_canario = format!("read _ < {} || true; {cmd}", c.canary); + let mut args = self.bwrap_args(&con_canario); + + // El `sh -c ` que bwrap_args deja al final se reemplaza por la cadena de harkaq. + // Los binds van ANTES del comando; por eso se corta la cola y se rearma. + let n = args.len(); + args.truncate(n - 3); // quita: /bin/sh, -c, + + for (src, dst) in [ + (c.canary_src.display().to_string(), c.canary.clone()), + (h.exec_bin().display().to_string(), "/harkaq-exec".to_string()), + (c.policy.display().to_string(), "/harkaq.policy".to_string()), + ] { + args.push("--ro-bind".into()); + args.push(src); + args.push(dst); + } + // En la raíz y no en /tmp: `--tmp-overlay /` la hace escribible, así que bwrap puede + // crear ahí los puntos de montaje, y la política no alcanza la raíz ⇒ el canario deniega + // por construcción. En /tmp caería dentro de `rw /tmp` y no denunciaría nada. + args.push("/harkaq-exec".into()); + args.push("--policy".into()); + args.push("/harkaq.policy".into()); + args.push("--".into()); + args.push("/bin/sh".into()); + args.push("-c".into()); + args.push(con_canario); + args + } + fn bwrap_args(&self, cmd: &str) -> Vec { let mut args: Vec = vec![ "--overlay-src".into(), diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index 00e0cedb..bce7e86c 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -671,7 +671,26 @@ Después: tomar **una** receta real de hammer. Derivar su política de la clausu Landlock+seccomp dentro del bwrap actual. Emitir el `Verdict` con `denials = []` **y el canario visto**. -**Estado (2026-07-15): la cadena corre de punta a punta.** `harkaq-audit` (lector, host, +**✅ HITO DE FASE 1 CUMPLIDO (2026-07-15).** `hammer build` sobre una receta **real del catálogo** +(`recipes/zlib.toml`), con la política derivada de la clausura declarada y el `Verdict` emitido +**por fase**: + +``` +configure → Hermetico esperadas (14): /usr/bin/gcc deuda: ninguna ✓ +compile → Impuro DEUDA (1): /usr/bin/make → build rc=126 (la jaula lo frenó) +``` + +El diagnóstico completo de zlib cabe en una frase: **lo único que toma de Alpine sin declararlo +es `make`**. Wiring en `crates/hammer-build/src/harkaq.rs`, detrás de `HARKAQ=1` e **inerte** sin +él (requisito duro: 700+ artefactos sellados no pueden cambiar de hash por encender un +diagnóstico). `harkaq-policy` es puro y se testea sin kernel (4 tests), como pide §4. + +La integración se delató sola en su primera corrida: faltaba `/cache` (superficie de contrato — +`ZIG_GLOBAL_CACHE_DIR=/cache/zig`, zig **crea** directorios ahí) y la fase moría con +`fs.make_dir /cache/zig/tmp`. De ahí que las superficies de contrato las decida el `Sandbox`, que +es quien sabe qué montó, y no harkaq: `/cache` sólo existe si hay `cache_dir`. + +**Estado previo — la cadena corre de punta a punta.** `harkaq-audit` (lector, host, `CAP_AUDIT_READ`) + `harkaq-exec` (política, estático, dentro de bwrap) + `harkaq-run.sh` (orquesta y pone el canario). Los dos actos, con comandos sintéticos: