tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1574Shell 204Rust 111Markdown 993763 Definiciones

harkaq: HITO DE FASE 1 — wiring en Rust; veredicto por fase sobre una receta real

crates/hammer-build/src/harkaq.rs + Sandbox::bwrap_args_harkaq: harkaq sale de
`hammer build`, no de un script. Deriva la política de la clausura declarada
(D1), lanza el lector, mete harkaq-exec como último eslabón antes del `sh -c`, y
recoge el Verdict por FASE (configure/compile/install son bwraps distintos ⇒
dominios distintos ⇒ un veredicto cada uno).

EL HITO, sobre recipes/zlib.toml (receta real del catálogo):
  configure →  Hermetico   esperadas (14): /usr/bin/gcc   deuda: ninguna ✓
  compile   →  Impuro      DEUDA (1): /usr/bin/make       → 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`.

INERTE sin HARKAQ=1: mismos args de bwrap, mismo entorno, ningún proceso extra.
Requisito duro, no cortesía — 700+ artefactos sellados no pueden cambiar de hash
por encender un diagnóstico. Los 57 tests previos del crate siguen verdes sin
tocar, que es la prueba.

harkaq-policy es PURO y se testea sin kernel (4 tests nuevos, como pide §4):
traduce store→sandbox, excluye la metadata `.hammer/` del artefacto, y deja
listables los ancestros de cada fichero de la clausura.

La integración se delató sola en su PRIMERA corrida real: faltaba /cache como
superficie de contrato (ZIG_GLOBAL_CACHE_DIR=/cache/zig — zig CREA dirs 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 sabe qué montó) y no harkaq: /cache sólo
existe si hay cache_dir, y una política que nombre un path inexistente aborta el
build a propósito.

libc en hammer-build: 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. Hace falta SIGTERM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

47982d7c6a6c8fe86700b225c840184827f33256 · por sergio · (hace 3 meses) · padre ed614e689 · ver el árbol en este commit

Delta semántico · 18

crates/hammer-build/src/harkaq.rs

crates/hammer-build/src/lib.rs

crates/hammer-build/src/sandbox.rs

5 archivos · +421 −2

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<PathBuf>,
+}
+
+/// 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<Self> {
+        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<String> = Vec::new();
+        let mut dirs: std::collections::BTreeSet<String> = 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<Corrida> {
+        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 <dep>` funde `<dep>/usr/...` en `/usr/...`.
+fn recorrer(
+    raiz: &Path,
+    dir: &Path,
+    ro: &mut Vec<String>,
+    dirs: &mut std::collections::BTreeSet<String>,
+) -> 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: `<tmp>/<hash>-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 <dep>` funde `<dep>/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<String> {
+        let mut v: Vec<String> = [
+            "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 <cmd>` final por
+    /// `harkaq-exec --policy ... -- sh -c "<probe del canario>; <cmd>"`.
+    ///
+    /// 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<String> {
+        // 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 <cmd>` 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, <cmd>
+
+        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<String> {
         let mut args: Vec<String> = 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: