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…

qorpa D9: el canal de evidencia, cableado — la única forma de auditar el montón B

De un binario ajeno no hay fuente que leer. Lo único observable es lo que el
kernel le NIEGA y anota, y hasta acá ese canal existía en el build pero ninguna
instancia lo abría. `hammer qorpa run <id> --evidence` levanta el lector
(`harkaq-audit`) en el HOST —el audit no está namespaceado— antes de que arranque
la instancia, y al terminar imprime uno de tres estados. Los tres, medidos:

  HERMÉTICO     0 denegaciones Y el canario las respalda
  IMPURO        `touch /usr/INTRUSO; mkdir /opt/INTRUSO` →
                  fs.make_reg · /usr     fs.make_dir · /opt
  SIN EVIDENCIA quitándole las capabilities al lector. NO es «limpio»

**El canario es lo que hace que «cero denegaciones» valga algo:** un fichero
donde la política no alcanza; al leerlo, el kernel emite una denegación que
revela el `domain=` de ESTE dominio Landlock, un número que desde fuera no se
adivina. Sin él, `denials=[]` sería el instrumento callado.

**Dos condiciones estructurales, y se FALLA en vez de dar un veredicto vacío:**
con `nesting` no hay Landlock (D9 conflicto 1) ⇒ o anidás o auditás; y sin
`seal_image` la política es `rw /` ⇒ no hay NADA denegable y el veredicto sería
limpio por construcción, no por mérito. No es un defecto de la implementación:
**la evidencia sólo existe donde algo puede ser negado.**

**Un bug del propio instrumento, que sólo salió usándolo:** sin CAP_AUDIT_READ
el kernel RESPONDE que no (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa
respuesta esperando una que no iba a llegar — 8 s por consulta, 16 s en su
compuerta. Como `qorpa run` lo despierta al terminar, moría por señal dentro de
la compuerta **sin emitir nada**: un «no» tardío se parecía demasiado a un
cuelgue. Ahora atiende el NLMSG_ERROR y dice su motivo en 2 s. Y si aun así el
veredicto sale vacío, se reporta con el código de salida del lector, que es el
único dato que queda.

Guardián: `scripts/qorpa/evidence-probe.sh`, con las tres aserciones. La 2 es la
que sostiene a la 1 — sin algo que TIENE que salir sucio, «HERMÉTICO» lo cumple
igual un canal muerto. Comprueba también las capabilities del lector, que **se
pierden en cada recompilación** y son la forma más probable de que el canal
muera en silencio.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZjGRqvjWij9dew7NVFu4Q

4a68dfe76149bfca9dfb0a3320407b3123070f53 · por Sergio · (hace 1 mes) · padre e04fdf459 · ver el árbol en este commit

Delta semántico · 9

crates/hammer-cli/src/qorpa.rs

scripts/harkaq/harkaq-audit.c

5 archivos · +299 −7

diff --git a/Cargo.toml b/Cargo.toml
index 40205725..9e43f201 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,7 +52,7 @@ getrandom = "0.2"
 # `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS —
 # Wayland pasa descriptores (wl_shm, dmabuf) por el mismo socket, y sin relayarlos no dibuja
 # nada. Las features de cargo son ADITIVAS: activarlas no cambia lo que compilan los demás.
-nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio"] }
+nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio", "signal"] }
 tempfile = "3"
 clap = { version = "4", features = ["derive"] }
 tracing = "0.1"
diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index b4fd6d71..cde91169 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -113,6 +113,15 @@ pub enum QorpaCmd {
         /// Para comparar: casi todo lo que "no anda adentro" es este mapeo.
         #[arg(long)]
         single_id: bool,
+        /// Abre el canal de evidencia: apunta qué intentó tocar la instancia y no pudo.
+        ///
+        /// **Es la única forma de auditar el montón B.** De un binario ajeno no hay fuente que
+        /// leer: lo único que queda es lo que el kernel deniega y anota. Exige una política que
+        /// pueda denegar algo (`seal_image`) y Landlock vivo (o sea, sin `nesting`); si falta
+        /// alguna, FALLA diciendo cuál — un `denials=[]` sin evidencia posible sería el
+        /// instrumento mintiendo.
+        #[arg(long)]
+        evidence: bool,
         /// Comando dentro (default: el shell de la imagen). Va después de `--`.
         #[arg(last = true)]
         cmd: Vec<String>,
@@ -180,9 +189,10 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
         QorpaCmd::Recreate { id, no_provision, root } => {
             recreate(&resolve_root(root), &id, no_provision)
         }
-        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, cmd } => {
-            run_instance(&resolve_root(root), &id, &cmd, Ajustes { dry_run, no_jail, single_id, ..Default::default() })
-        }
+        QorpaCmd::Run { id, root, dry_run, no_jail, single_id, evidence, cmd } => run_instance(
+            &resolve_root(root), &id, &cmd,
+            Ajustes { dry_run, no_jail, single_id, evidencia: evidence, ..Default::default() },
+        ),
         QorpaCmd::Provision { id, force, dry_run, root } => {
             provision(&resolve_root(root), &id, force, dry_run)
         }
@@ -1260,12 +1270,14 @@ struct Ajustes {
     /// Modo `provision`: la política declarada se ENSANCHA para poder instalar, y sólo mientras
     /// dura la instalación. Ver `provision()` — lo que se ensancha y por qué se dice en voz alta.
     provisionando: bool,
+    /// Levanta el lector de audit y recoge el veredicto. Ver `preparar_evidencia`.
+    evidencia: bool,
 }
 
 fn run_instance(
     root: &Path, id: &str, cmd: &[String], aj: Ajustes,
 ) -> Result<()> {
-    let Ajustes { dry_run, no_jail, single_id, provisionando } = aj;
+    let Ajustes { dry_run, no_jail, single_id, provisionando, evidencia: _ } = aj;
     let (dir, mut inst) = read_instance(root, id)?;
     let (sha, tree) = resolve_image(root, &inst.base)?;
 
@@ -1431,6 +1443,34 @@ fn run_instance(
         }
     }
 
+    // ── EL CANAL DE EVIDENCIA ──────────────────────────────────────────────────────────────────
+    // De un binario ajeno no hay fuente que leer. Lo único que queda para saber qué intentó hacer
+    // es lo que el kernel le NEGÓ y anotó — por eso el ADR lo llama «la única forma de auditar el
+    // montón B». El lector (`harkaq-audit`) vive en el HOST, fuera de bwrap, porque el audit no
+    // está namespaceado; se levanta ANTES para no perderse las primeras denegaciones.
+    let mut ev: Option<Evidencia> = None;
+    if aj.evidencia && !dry_run {
+        let e = preparar_evidencia(&dir, id, no_jail, &inst.grants)?;
+        args.push("--ro-bind".into());
+        args.push(e.canario_src.display().to_string());
+        args.push(e.canario.clone());
+        // El canario se lee ADENTRO y su denegación es lo que revela el `domain=` del dominio
+        // Landlock, que es un número que nadie puede adivinar desde fuera. Sin esto el lector no
+        // sabe cuáles de todas las denegaciones del sistema son de ESTA instancia.
+        let mut w = vec![
+            "/bin/sh".to_string(), "-c".into(),
+            // El `{ …; } 2>/dev/null` NO es lo mismo que `read _ < "$1" 2>/dev/null`: las
+            // redirecciones se aplican de izquierda a derecha, así que en la segunda forma el
+            // fallo de la entrada se reporta ANTES de silenciar stderr y el usuario ve un
+            // «Permission denied» que parece un error suyo y es el canario funcionando.
+            "{ read _ < \"$1\"; } 2>/dev/null || true; shift; exec \"$@\"".into(),
+            "qorpa".into(), e.canario.clone(),
+        ];
+        w.extend(cmd);
+        cmd = w;
+        ev = Some(e);
+    }
+
     // harkaq va como ÚLTIMO eslabón antes del comando, igual que en el sandbox del build: bwrap ya
     // puso los namespaces y el overlay, y esto pone el grano fino. Cruza el borde un binario
     // ESTÁTICO — no una librería —, así que sigue valiendo D2: lo único que comparte con el host
@@ -1531,6 +1571,9 @@ fn run_instance(
         std::thread::sleep(std::time::Duration::from_millis(200 * u64::from(intento)));
     }
     let st = st.expect("el bucle sale con estado o con bail");
+    if let Some(e) = ev {
+        recoger_evidencia(&dir, e)?;
+    }
     match st.code() {
         Some(0) => Ok(()),
         Some(c) => std::process::exit(c),
@@ -1538,6 +1581,141 @@ fn run_instance(
     }
 }
 
+// ── el canal de evidencia (D9 punto 3) ──────────────────────────────────────────────────────────
+//
+// LO QUE HACE ÚNICO A ESTE CANAL: con nuestros propios builds, si algo toca lo que no debe, está
+// la receta y la fuente para mirar. Con un binario ajeno no hay nada que leer. Lo que el kernel
+// deniega y anota es LA ÚNICA superficie observable — el ADR lo dice así en D9.
+//
+// Tres piezas, y ninguna sobra:
+//   · el LECTOR vive en el host, fuera de bwrap: el audit no está namespaceado y los registros
+//     cruzan igual. Se levanta ANTES de la instancia o se pierde el principio.
+//   · el CANARIO es un fichero en un sitio que la política NO alcanza. Al leerlo, el kernel emite
+//     una denegación con el `domain=` de este dominio Landlock — un número que desde fuera no se
+//     puede adivinar. Sin canario, el lector no sabe cuáles de todas las denegaciones del sistema
+//     son de esta instancia.
+//   · el VEREDICTO se gana: `denials=[]` sólo vale si el canario se vio Y el contador del kernel
+//     cuadra. Si no, el estado es `SinEvidencia`, que es distinto de «limpio».
+
+const EVIDENCE_FILE: &str = "evidence.json";
+
+struct Evidencia {
+    canario: String,
+    canario_src: PathBuf,
+    veredicto: PathBuf,
+    lector: std::process::Child,
+}
+
+fn preparar_evidencia(dir: &Path, id: &str, no_jail: bool, g: &Grants) -> Result<Evidencia> {
+    // ── LAS TRES CONDICIONES, Y POR QUÉ SE FALLA EN VEZ DE SEGUIR ──────────────────────────────
+    // Si alguna falta, el lector devolvería `denials=[]` y eso se leería como «la instancia no
+    // intentó nada raro», cuando la verdad sería «no había forma de enterarse». Un instrumento que
+    // no puede medir tiene que decirlo, no marcar cero.
+    if no_jail {
+        bail!("`--evidence` con `--no-jail` no tiene sentido: sin jaula no hay dominio Landlock y \
+               no hay nada que el kernel pueda anotar");
+    }
+    if g.nesting {
+        bail!(
+            "`nesting` apaga Landlock (D9, conflicto 1: el kernel no admite montajes nuevos bajo \n  \
+             un dominio) ⇒ o la instancia anida o se audita, no las dos. Para auditar Steam hay \n  \
+             que mirar la instancia SIN pressure-vessel."
+        );
+    }
+    if !g.seal_image {
+        bail!(
+            "sin `seal_image` la política de una instancia es UNA línea, `rw /` ⇒ no hay NADA que \n  \
+             el kernel pueda denegar, y el veredicto diría `denials=[]` por construcción y no por \n  \
+             mérito. Encendé `seal_image = true` en el manifiesto y volvé a correr."
+        );
+    }
+
+    let bin = harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit");
+    if !bin.exists() {
+        bail!(
+            "no encuentro harkaq-audit en {}\n  \
+             construilo:  gcc -O1 -Wall -o {} scripts/harkaq/harkaq-audit.c\n  \
+             y dale las capabilities:  sudo setcap cap_audit_read,cap_audit_control+ep {}",
+            bin.display(), bin.display(), bin.display()
+        );
+    }
+
+    // El canario va en la RAÍZ. Con `seal_image` la política concede `list /` y nada más sobre la
+    // raíz misma, así que un fichero ahí se puede LISTAR pero no LEER: la denegación es exacta.
+    // En cualquier directorio con `rw` —/etc, /var, /tmp— se leería sin ruido y el canario sería
+    // mudo, que es la misma trampa que ya documenta el canario del build.
+    let canario = format!("/harkaq-canary-{id}-{}", std::process::id());
+    let canario_src = dir.join("canary");
+    std::fs::write(&canario_src, b"harkaq\n")
+        .with_context(|| format!("escribiendo el canario {}", canario_src.display()))?;
+
+    let veredicto = dir.join(EVIDENCE_FILE);
+    let vf = std::fs::File::create(&veredicto)
+        .with_context(|| format!("creando {}", veredicto.display()))?;
+    let lector = Command::new(&bin)
+        .arg("--canary").arg(&canario)
+        .arg("--timeout").arg(std::env::var("HARKAQ_TIMEOUT").unwrap_or_else(|_| "1800".into()))
+        .stdout(std::process::Stdio::from(vf))
+        .stderr(std::process::Stdio::inherit())
+        .spawn()
+        .with_context(|| format!("no pude lanzar {}", bin.display()))?;
+    // El lector tiene que estar en el multicast antes de que arranque la instancia: si no, sus
+    // primeras denegaciones —el canario entre ellas— se emiten sin nadie del otro lado y el
+    // veredicto sale `SinEvidencia` por una carrera y no por un problema real.
+    std::thread::sleep(std::time::Duration::from_millis(300));
+    eprintln!("  evidencia: lector puesto, canario {canario}");
+    Ok(Evidencia { canario, canario_src, veredicto, lector })
+}
+
+fn recoger_evidencia(dir: &Path, mut e: Evidencia) -> Result<()> {
+    // Gracia para que llegue el `deallocated` del dominio: el kernel lo emite con retraso (RCU),
+    // y es lo que le permite al lector comparar su conteo contra el del kernel.
+    std::thread::sleep(std::time::Duration::from_millis(1500));
+    let _ = nix::sys::signal::kill(
+        nix::unistd::Pid::from_raw(e.lector.id() as i32),
+        nix::sys::signal::Signal::SIGTERM,
+    );
+    let salida = e.lector.wait().ok().and_then(|s| s.code());
+    let _ = std::fs::remove_file(&e.canario_src);
+
+    let txt = std::fs::read_to_string(&e.veredicto).unwrap_or_default();
+    // Un veredicto VACÍO no es un veredicto: es el lector que no llegó a emitir. Se dice con su
+    // código de salida, que es el único dato que queda — un `SinEvidencia` mudo manda a buscar el
+    // problema en el sitio equivocado.
+    if txt.trim().is_empty() {
+        println!(
+            "evidencia: SIN EVIDENCIA — el lector no emitió veredicto (salió con {}). NO quiere \
+             decir «limpio».\n  Comprobá:  getcap {}",
+            salida.map(|c| c.to_string()).unwrap_or_else(|| "señal".into()),
+            harkaq_exec().parent().unwrap_or(Path::new(".")).join("harkaq-audit").display(),
+        );
+        return Ok(());
+    }
+    let v: serde_json::Value = serde_json::from_str(txt.trim()).unwrap_or(serde_json::Value::Null);
+    let estado = v.get("estado").and_then(|x| x.as_str()).unwrap_or("SinEvidencia");
+    let denials = v.get("denials").and_then(|x| x.as_array()).map(|a| a.len()).unwrap_or(0);
+    match estado {
+        "Hermetico" => println!("evidencia: HERMÉTICO — 0 denegaciones, y el canario las respalda"),
+        "Impuro" => {
+            println!("evidencia: IMPURO — {denials} denegación(es); esto es el diagnóstico, no el fallo");
+            for d in v["denials"].as_array().into_iter().flatten().take(12) {
+                println!("  {} · {}",
+                    d.get("blockers").and_then(|x| x.as_str()).unwrap_or("?"),
+                    d.get("path").and_then(|x| x.as_str()).unwrap_or("?"));
+            }
+            if denials > 12 { println!("  … y {} más en {}", denials - 12, e.veredicto.display()); }
+        }
+        _ => println!(
+            "evidencia: SIN EVIDENCIA — {}. NO quiere decir «limpio»: quiere decir que no se pudo \
+             saber.",
+            v.get("motivo").and_then(|x| x.as_str()).unwrap_or("el lector no dejó motivo")
+        ),
+    }
+    println!("  veredicto completo: {}", e.veredicto.display());
+    let _ = dir;
+    Ok(())
+}
+
 /// Lanza bwrap y devuelve su estado junto con **si el fallo fue el overlay tomado**.
 ///
 /// El stderr se PASA A TRAVÉS mientras se mira, byte a byte y sin esperar renglones: hay que leerlo
diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md
index f1afc387..1ea782b3 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -349,9 +349,38 @@ builds:
 |---|---|
 | **seccomp** | bwrap **no instala filtro alguno**: hoy una instancia puede `io_uring`, `bpf`, `ptrace`, `process_vm_readv`, `userfaultfd`, `keyctl`, `perf_event_open`, `init_module`, `kexec`. Ésa es superficie de kernel contra un prebuilt de terceros |
 | **`no_new_privs`** | ningún setuid de la imagen ajena escala adentro |
-| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B (leer ese canal es trabajo aparte: pide el lector con `CAP_AUDIT_READ`) |
+| **canal de evidencia** | con Landlock ABI ≥ 7 el kernel audita las denegaciones ⇒ se puede saber **qué intentó tocar** un binario cerrado. Es la única forma de auditar el montón B. ✅ **cableado 2026-09-03**: `hammer qorpa run <id> --evidence` |
 | **`seal_image`** | lo único del eje fs que el namespace no da: congelar `/usr`, `/bin`, `/lib`, `/opt` **aunque adentro seas root**, para que un exploit no persista en el `upper` |
 
+**El canal de evidencia, cableado (2026-09-03).** `hammer qorpa run <id> --evidence` levanta el
+lector (`harkaq-audit`) en el HOST —el audit no está namespeceado y los registros cruzan— antes de
+que arranque la instancia, y al terminar imprime uno de **tres** estados. Los tres se verificaron a
+mano y quedan como guardián en `scripts/qorpa/evidence-probe.sh`:
+
+| estado | qué significa | comprobado con |
+|---|---|---|
+| **HERMÉTICO** | 0 denegaciones **y el canario las respalda** | una instancia que no toca nada prohibido |
+| **IMPURO** | qué intentó y no pudo — *el diagnóstico, no el fallo* | `touch /usr/INTRUSO; mkdir /opt/INTRUSO` → `fs.make_reg · /usr`, `fs.make_dir · /opt` |
+| **SIN EVIDENCIA** | no se pudo saber. **No es «limpio»** | quitándole las capabilities al lector |
+
+**El canario es lo que hace que «cero denegaciones» valga algo.** Es un fichero en un sitio que la
+política no alcanza; al leerlo, el kernel emite una denegación que revela el `domain=` de ESE
+dominio Landlock —un número que desde fuera no se puede adivinar—. Sin él, el lector no sabe cuáles
+de todas las denegaciones del sistema son de esta instancia, y `denials=[]` sería el instrumento
+callado en vez de una instancia limpia.
+
+**Dos condiciones estructurales, y `--evidence` FALLA en vez de dar un veredicto vacío si falta
+alguna:** con `nesting` no hay dominio Landlock (conflicto 1, acá abajo) ⇒ **o anidás o auditás**; y
+**sin `seal_image` la política es `rw /`, o sea que no hay nada denegable** ⇒ un veredicto limpio
+sería cierto por construcción y no por mérito. Ese acoplamiento no es un defecto de la
+implementación: *la evidencia sólo existe donde algo puede ser negado.*
+
+**Y una que salió midiendo el propio instrumento:** sin `CAP_AUDIT_READ` el kernel **responde que
+no** (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa respuesta esperando una que no llegaría — 8 s
+por consulta, 16 s en su compuerta. Como `qorpa run` lo despierta al terminar, moría **por señal
+dentro de la compuerta, sin emitir nada**, y el veredicto quedaba vacío: un «no» tardío se parecía
+demasiado a un cuelgue. Ahora el `NLMSG_ERROR` se atiende y el lector dice su motivo en 2 s.
+
 **Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy.** Medido: con un dominio
 Landlock activo, `mount` falla con **EACCES aunque seccomp lo permita**; el kernel no admite montajes
 nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no
diff --git a/scripts/harkaq/harkaq-audit.c b/scripts/harkaq/harkaq-audit.c
index a45b1c17..afdcdfc8 100644
--- a/scripts/harkaq/harkaq-audit.c
+++ b/scripts/harkaq/harkaq-audit.c
@@ -109,12 +109,23 @@ static int audit_status(int *enabled) {
     for (int i = 0; i < 8; i++) {
         ssize_t n = recv(fd, buf, sizeof(buf), 0);
         if (n <= 0) break;
-        for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n); h = NLMSG_NEXT(h, n))
+        for (struct nlmsghdr *h = (struct nlmsghdr *)buf; NLMSG_OK(h, n); h = NLMSG_NEXT(h, n)) {
+            // El kernel RESPONDE que no, y hasta ahora esa respuesta se ignoraba: sin
+            // CAP_AUDIT_READ contesta un NLMSG_ERROR con -EPERM, y el bucle seguía esperando un
+            // AUDIT_GET que no iba a llegar — 8 s por llamada, 16 s en el gate. Con el lector
+            // arrancado por `qorpa run`, eso significaba que el SIGTERM del final lo mataba
+            // DENTRO del gate, antes de instalar su manejador ⇒ moría por señal sin emitir nada y
+            // el veredicto quedaba VACÍO. Un "no" tardío se parece demasiado a un cuelgue.
+            if (h->nlmsg_type == NLMSG_ERROR) {
+                close(fd);
+                return -1;
+            }
             if (h->nlmsg_type == AUDIT_GET) {
                 *enabled = ((struct audit_status *)NLMSG_DATA(h))->enabled;
                 close(fd);
                 return 0;
             }
+        }
     }
     close(fd);
     return -1;
diff --git a/scripts/qorpa/evidence-probe.sh b/scripts/qorpa/evidence-probe.sh
new file mode 100755
index 00000000..14b41662
--- /dev/null
+++ b/scripts/qorpa/evidence-probe.sh
@@ -0,0 +1,74 @@
+#!/bin/sh
+# Prueba el canal de evidencia de una instancia qorpa: que distinga HERMÉTICO de IMPURO, y que se
+# NIEGUE a opinar cuando no hay nada que medir. ADR 0015 D9 punto 3.
+#
+# ── POR QUÉ ESTE GUARDIÁN ───────────────────────────────────────────────────────────────────────
+# De un binario ajeno no hay fuente que leer: lo único observable es lo que el kernel le NIEGA y
+# anota. Si ese canal se rompe —caps que se pierden al recompilar, un kernel sin audit, una
+# política que ya no deniega nada—, el síntoma es **un veredicto limpio**. O sea que el modo de
+# fallo de este instrumento es *parecer que todo está bien*, y por eso hace falta medirlo con algo
+# que TIENE que salir sucio.
+#
+# ── LAS TRES ASERCIONES ─────────────────────────────────────────────────────────────────────────
+#   1. una instancia que no toca nada prohibido → HERMÉTICO
+#   2. la MISMA instancia tocando /usr y /opt   → IMPURO, y con esas rutas
+#   3. una instancia sin `seal_image`           → se NIEGA a correr (no hay nada denegable, así que
+#                                                 un `denials=[]` sería el instrumento mintiendo)
+# La 2 es la que sostiene a la 1: sin ella, «HERMÉTICO» lo cumple igual un canal muerto.
+set -eu
+
+QORPA_ROOT="${HAMMER_QORPA_ROOT:-/var/lib/hammer/qorpa}"
+HAMMER="${HAMMER_BIN:-./target/release/hammer}"
+ID="${1:-arch-probe}"
+export HAMMER_QORPA_ROOT="$QORPA_ROOT"
+
+INST="$QORPA_ROOT/instances/$ID"
+[ -f "$INST/instance.toml" ] || { echo "no existe la instancia $ID en $QORPA_ROOT" >&2; exit 2; }
+
+AUDIT="${HARKAQ_BIN:-$HOME/.cache/harkaq}/harkaq-audit"
+if [ ! -x "$AUDIT" ]; then
+  echo "falta $AUDIT — gcc -O1 -Wall -o $AUDIT scripts/harkaq/harkaq-audit.c" >&2; exit 2
+fi
+# El `setcap` se pierde en CADA recompilación del lector, y ésa es la forma más probable de que el
+# canal muera sin que nadie se entere: por eso se comprueba acá y no se descubre en el veredicto.
+case "$(getcap "$AUDIT" 2>/dev/null)" in
+  *cap_audit_read*) : ;;
+  *) echo "el lector no tiene capabilities (se pierden al recompilar):" >&2
+     echo "  sudo setcap cap_audit_read,cap_audit_control+ep $AUDIT" >&2; exit 2 ;;
+esac
+
+LOG=$(mktemp -d); trap 'rm -rf "$LOG"' EXIT
+# A fichero y no a `$( )`: la sustitución espera al PIPE, no al proceso, y un descendiente vivo la
+# cuelga para siempre (misma cicatriz que `pressure-vessel-probe.sh`).
+corre() { timeout -k 5 180 "$HAMMER" qorpa run "$ID" --evidence -- /bin/sh -c "$1" > "$2" 2>&1 </dev/null || true; }
+
+sed -i 's/^seal_image = false/seal_image = true/' "$INST/instance.toml"
+
+echo "== 1. sin tocar nada prohibido"
+corre 'true' "$LOG/limpio"
+grep -q "HERMÉTICO" "$LOG/limpio" || { tail -5 "$LOG/limpio"; echo "FALLA: no dio hermético" >&2; exit 1; }
+echo "   HERMÉTICO ✓"
+
+echo "== 2. tocando la imagen sellada"
+corre 'touch /usr/INTRUSO 2>/dev/null; mkdir /opt/INTRUSO 2>/dev/null; true' "$LOG/sucio"
+grep -q "IMPURO" "$LOG/sucio" || { tail -8 "$LOG/sucio"; echo "FALLA: el canal no vio nada — un canal muerto se ve así" >&2; exit 1; }
+for ruta in "/usr" "/opt"; do
+  grep -q " $ruta\$" "$LOG/sucio" || { tail -8 "$LOG/sucio"; echo "FALLA: falta la denegación de $ruta" >&2; exit 1; }
+done
+grep -E "fs\.(make_reg|make_dir)" "$LOG/sucio" | sed 's/^/   /'
+echo "   IMPURO con las dos rutas ✓"
+
+echo "== 3. sin seal_image: tiene que NEGARSE, no dar limpio"
+sed -i 's/^seal_image = true/seal_image = false/' "$INST/instance.toml"
+corre 'true' "$LOG/sinsello"
+sed -i 's/^seal_image = false/seal_image = true/' "$INST/instance.toml"
+# Se busca la LÍNEA de veredicto (`evidencia: …`), no la palabra «denials»: el propio mensaje de
+# rechazo la contiene al explicar por qué se niega, y la primera versión de este guardián falló
+# contra su propia explicación.
+if grep -q "^evidencia:" "$LOG/sinsello"; then
+  tail -5 "$LOG/sinsello"; echo "FALLA: opinó sobre una política que no puede denegar nada" >&2; exit 1
+fi
+grep -q "seal_image" "$LOG/sinsello" || { tail -5 "$LOG/sinsello"; echo "FALLA: no explicó por qué se niega" >&2; exit 1; }
+echo "   se niega y dice por qué ✓"
+
+echo "OK: el canal de evidencia distingue hermético de impuro, y no opina cuando no puede medir"