> 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
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"