gioser

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 815Rust 56Shell 37Markdown 291229 Definiciones

qorpa D9: harkaq enjaula la instancia, y midiendo salieron dos conflictos

Paso 4 del ADR 0015. harkaq-exec entra como último eslabón dentro de bwrap,
igual que en el sandbox del build. Cruza el borde un binario ESTÁTICO, no una
librería, así que D2 sigue en pie: lo único compartido es la ABI del kernel.

Honestidad primero, y está escrita en el código: en el eje del sistema de
ficheros harkaq casi no agrega nada, porque el namespace de montaje de bwrap ya
es una lista blanca. Escribir reglas `ro` que repiten eso sería un sello de
goma, así que la política de una instancia no sellada es UNA línea (`rw /`) y no
finge. Lo que sí aporta: seccomp (bwrap no instala filtro alguno — hoy una
instancia podía io_uring, bpf, ptrace, userfaultfd, keyctl, perf_event_open),
no_new_privs, el canal de evidencia, y `seal_image`, que congela /usr /bin /lib
/opt aunque adentro seas root.

Verificado contra el kernel, no contra el log: Landlock ABI 9, logging
post-exec ON, NoNewPrivs 1, Seccomp 2. Y sellando, `/usr/bin` y `/bin` denegados
mientras /etc y /var siguen escribibles.

DOS CONFLICTOS que sólo se ven midiendo, y ninguno estaba en el ADR:

1. Landlock y los contenedores anidados son INCOMPATIBLES hoy: con un dominio
   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. ⇒ pressure-vessel no arranca bajo Landlock. Por eso `nesting` pasa
   `--allow-nesting --no-landlock` y lo dice a gritos; seccomp y no_new_privs
   siguen puestos, que es lo que más pesa con un binario ajeno.
2. `root` adentro y anidar se pelean: con --uid 0, un userns anidado no puede
   escribir su uid_map. Sin remapear anida, pero el gestor de paquetes se queja.
   La instancia de juegos y la de paquetes quieren mapeos OPUESTOS, y ahora el
   manifiesto lo declara (`root`, encendido por defecto).

Las dos mitades se curan con lo mismo que el impuesto de apt: un rango real de
subuid con newuidmap + --userns FD. Ése es el ticket que más desbloquea.

En harkaq-exec, dos flags ADITIVOS y apagados por defecto (--allow-nesting,
--no-landlock): el camino del build no cambia ni un byte, que es requisito duro
con 700+ artefactos sellados. La lista de syscalls del anidamiento se separó de
la base y el _Static_assert del techo de salto BPF ahora suma las dos.

3 tests nuevos: que la política sin sellar no finja, que sellando el `rw /` no
sobreviva (uniría derechos por ancestro y anularía el sellado), y que `root` sea
lo único que nace encendido. 43/43.

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

5b824740790aae52ada28860bd74b14769805472 · por Sergio · (hace 1 mes) · padre ba09fdb40 · ver el árbol en este commit

Delta semántico · 15

crates/hammer-cli/src/qorpa.rs

scripts/harkaq/harkaq-exec.c

3 archivos · +319 −22

diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index 68ae8b96..883b49b7 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -102,6 +102,10 @@ pub enum QorpaCmd {
         /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable.
         #[arg(long)]
         dry_run: bool,
+        /// Corre SIN harkaq (sin Landlock, sin seccomp, sin no_new_privs). Para diagnosticar
+        /// cuando algo muere y hay que saber si fue la jaula. Avisa a gritos.
+        #[arg(long)]
+        no_jail: bool,
         /// Comando dentro (default: el shell de la imagen). Va después de `--`.
         #[arg(last = true)]
         cmd: Vec<String>,
@@ -122,7 +126,9 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
             create(&resolve_root(root), &id, &base, distro.as_deref())
         }
         QorpaCmd::Recreate { id, root } => recreate(&resolve_root(root), &id),
-        QorpaCmd::Run { id, root, dry_run, cmd } => run_instance(&resolve_root(root), &id, &cmd, dry_run),
+        QorpaCmd::Run { id, root, dry_run, no_jail, cmd } => {
+            run_instance(&resolve_root(root), &id, &cmd, dry_run, no_jail)
+        }
         QorpaCmd::Export { .. } => bail!(
             "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
              `Exec=` de un .desktop ajeno."
@@ -503,7 +509,8 @@ struct Instance {
     grants: Grants,
 }
 
-#[derive(Serialize, Deserialize, Default)]
+#[derive(Serialize, Deserialize)]
+#[serde(default)]
 struct Grants {
     /// Nombres cortos: `wayland`, `pipewire`, `dbus`. ⚠ `wayland` es un borde de PRIVILEGIO, no un
     /// caño: quien lo tiene puede capturar pantalla y sintetizar teclas (ADR §NO-resuelve 1).
@@ -517,6 +524,115 @@ struct Grants {
     /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
     #[serde(default)]
     network: bool,
+    /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
+    /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
+    /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
+    #[serde(default)]
+    nesting: bool,
+    /// Mapear tu uid a **root adentro**. Encendido por defecto: es lo que quieren `dnf`/`pacman`.
+    ///
+    /// ⚠ MEDIDO 2026-09-03, y es un conflicto real con `nesting`: con `--uid 0`, un userns anidado
+    /// NO puede escribir su `uid_map` («Operation not permitted»), así que **pressure-vessel de
+    /// Valve no arranca**. Sin remapear a root, el anidamiento funciona pero el gestor de paquetes
+    /// se queja. Las dos mitades se arreglan con lo mismo: mapear un RANGO real de subuid con
+    /// `newuidmap` y pasarle el namespace a bwrap con `--userns FD` — el mismo impuesto que paga
+    /// hoy `apt` con su `setgroups`.
+    #[serde(default = "si")]
+    root: bool,
+    /// Sella el árbol de la IMAGEN (`/usr`, `/bin`, `/lib`, `/opt`…) contra escritura, aunque
+    /// adentro seas root. Apagado por defecto porque un gestor de paquetes necesita escribir en
+    /// `/usr`; encendido para una instancia que sólo CORRE algo, un exploit no puede persistir en
+    /// el `upper`. Es lo único que Landlock aporta acá que el namespace de montaje no da ya.
+    #[serde(default)]
+    seal_image: bool,
+}
+
+/// Dónde vive `harkaq-exec`. Misma convención que el harkaq del build (`HARKAQ_BIN`).
+fn harkaq_exec() -> PathBuf {
+    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")
+        })
+        .join("harkaq-exec")
+}
+
+/// Los directorios que pertenecen a la IMAGEN y que `seal_image` congela.
+const IMAGEN_INMUTABLE: &[&str] = &["usr", "bin", "sbin", "lib", "lib64", "lib32", "opt", "boot"];
+/// Lo que bwrap monta encima del overlay y siempre es de la instancia, no de la imagen.
+const DEL_SANDBOX: &[&str] = &["/proc", "/dev", "/tmp", "/run"];
+
+/// Compila las concesiones a la política que consume `harkaq-exec`.
+///
+/// ── QUÉ APORTA HARKAQ ACÁ, Y QUÉ NO ─────────────────────────────────────────────────────────────
+/// Honestidad primero: en el eje del SISTEMA DE FICHEROS, harkaq casi no agrega nada a una
+/// instancia, porque el namespace de montaje de bwrap ya es una lista blanca — lo que no se bindea
+/// no existe adentro, y un `--ro-bind` es de sólo lectura por el kernel, más fuerte que una regla.
+/// Escribir reglas `ro` que no cambian nada sería un sello de goma, así que no se escriben.
+///
+/// Lo que harkaq SÍ aporta, y es grande para un binario ajeno y opaco:
+///   1. **seccomp.** bwrap no instala filtro alguno: hoy una instancia puede `io_uring`, `bpf`,
+///      `ptrace`, `userfaultfd`, `keyctl`, `perf_event_open`, cargar módulos. Esa superficie
+///      importa MÁS con un prebuilt de terceros que con nuestros propios builds.
+///   2. **`no_new_privs`**: ningún setuid de la imagen escala adentro.
+///   3. **el canal de evidencia**: con 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.
+///   4. **`seal_image`**: lo único del eje fs que el namespace no da — congelar el árbol de la
+///      imagen aunque adentro seas root.
+fn compile_policy(g: &Grants, image_tree: &Path, dst: &Path) -> Result<()> {
+    use std::fmt::Write as _;
+    let mut out = String::new();
+    out.push_str("# Política de una INSTANCIA qorpa (ADR 0015 D7).\n");
+    out.push_str("# A diferencia de la del build, ésta NO se deriva de una clausura: se AUTORA,\n");
+    out.push_str("# que es el único sitio del sistema donde eso pasa. Se compila del manifiesto.\n");
+
+    if !g.seal_image {
+        // El árbol entero es de la instancia y sus escrituras van al `upper`, que es caché.
+        out.push_str("rw /\n");
+    } else {
+        // Sin regla sobre `/` no se puede ni listar la raíz; listar no es leer ni escribir.
+        out.push_str("list /\n# la imagen, congelada:\n");
+        for d in IMAGEN_INMUTABLE {
+            if image_tree.join(d).exists() {
+                let _ = writeln!(out, "ro /{d}");
+            }
+        }
+        out.push_str("# lo mutable de la instancia:\n");
+        let mut mutables: Vec<String> = std::fs::read_dir(image_tree)
+            .with_context(|| format!("leyendo el árbol de la imagen {}", image_tree.display()))?
+            .flatten()
+            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
+            .map(|e| e.file_name().to_string_lossy().into_owned())
+            .filter(|n| !IMAGEN_INMUTABLE.contains(&n.as_str()))
+            .map(|n| format!("/{n}"))
+            .collect();
+        for d in DEL_SANDBOX {
+            if !mutables.iter().any(|m| m == d) {
+                mutables.push((*d).to_string());
+            }
+        }
+        mutables.sort();
+        mutables.dedup();
+        for m in &mutables {
+            let _ = writeln!(out, "rw {m}");
+        }
+    }
+    std::fs::write(dst, out)
+        .with_context(|| format!("escribiendo la política {}", dst.display()))?;
+    Ok(())
+}
+
+
+impl Default for Grants {
+    fn default() -> Self {
+        // Toda concesión arranca CERRADA menos `root`, que no abre nada del host: sólo decide
+        // quién sos adentro de tu propio rootfs.
+        Grants {
+            sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
+            network: false, nesting: false, root: true, seal_image: false,
+        }
+    }
 }
 
 #[derive(Serialize, Deserialize)]
@@ -528,6 +644,7 @@ struct DirGrant {
     mode: String,
 }
 fn ro() -> String { "ro".into() }
+fn si() -> bool { true }
 
 fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
 fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
@@ -674,7 +791,7 @@ fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
     (a, warn)
 }
 
-fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<()> {
+fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool, no_jail: bool) -> Result<()> {
     let (dir, inst) = read_instance(root, id)?;
     let (sha, tree) = resolve_image(root, &inst.base)?;
     let (grant_args, warns) = grants_to_args(&inst.grants);
@@ -684,11 +801,20 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<
     // que no se declara, no entra (D2, "por defecto NADA").
     let mut args: Vec<String> = vec![
         "--unshare-user", "--unshare-pid", "--unshare-ipc", "--unshare-uts", "--unshare-cgroup-try",
-        "--uid", "0", "--gid", "0",
         "--hostname", "qorpa",
         "--clearenv",
         "--die-with-parent",
     ].into_iter().map(String::from).collect();
+    if inst.grants.root {
+        args.extend(["--uid", "0", "--gid", "0"].map(String::from));
+        if inst.grants.nesting {
+            eprintln!(
+                "⚠ `nesting` y `root` juntos NO funcionan hoy: con el uid remapeado a 0, un userns \
+                 anidado no puede escribir su uid_map ⇒ pressure-vessel no arranca. Poné \
+                 `root = false` para juegos, o esperá al mapeo por rango (subuid + --userns FD)."
+            );
+        }
+    }
 
     args.push("--overlay-src".into()); args.push(tree.display().to_string());
     args.push("--overlay".into());
@@ -707,9 +833,51 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<
     ].map(String::from));
     args.extend(grant_args);
 
-    let cmd: Vec<String> = if cmd.is_empty() {
+    let mut cmd: Vec<String> = if cmd.is_empty() {
         vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
     } else { cmd.to_vec() };
+
+    // 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
+    // es la ABI del kernel.
+    if !no_jail {
+        let exec = harkaq_exec();
+        if !exec.exists() {
+            bail!(
+                "no encuentro harkaq-exec en {} — la instancia NO va a correr enjaulada y eso no se \
+                 hace en silencio.\n  construilo:  gcc -O1 -Wall -static -o {} scripts/harkaq/harkaq-exec.c\n                   o corré con --no-jail si sabés lo que estás haciendo.",
+                exec.display(), exec.display()
+            );
+        }
+        let policy = dir.join("policy");
+        compile_policy(&inst.grants, &tree, &policy)?;
+
+        args.push("--ro-bind".into()); args.push(exec.display().to_string());
+        args.push("/run/qorpa/harkaq-exec".into());
+        args.push("--ro-bind".into()); args.push(policy.display().to_string());
+        args.push("/run/qorpa/policy".into());
+
+        let mut envoltura: Vec<String> = vec![
+            "/run/qorpa/harkaq-exec".into(), "--policy".into(), "/run/qorpa/policy".into(),
+        ];
+        if inst.grants.nesting {
+            // MEDIDO: seccomp no alcanza. Un dominio Landlock activo hace fallar `mount` con
+            // EACCES aunque el filtro lo permita — el kernel no admite montajes nuevos bajo un
+            // dominio, porque escaparían de sus reglas por-ruta. ⇒ anidar cuesta la política de
+            // ficheros. Seccomp y no_new_privs siguen puestos, que es lo que más pesa con un
+            // binario ajeno, y el confinamiento de fs queda en el namespace de montaje de bwrap,
+            // que ya es una lista blanca.
+            envoltura.push("--allow-nesting".into());
+            envoltura.push("--no-landlock".into());
+        }
+        envoltura.push("--".into());
+        envoltura.extend(cmd);
+        cmd = envoltura;
+    } else {
+        eprintln!("⚠ --no-jail: SIN Landlock, SIN seccomp, SIN no_new_privs. Sólo los namespaces \
+                   de bwrap. No es el modo normal.");
+    }
     args.extend(cmd.iter().cloned());
 
     for w in &warns {
@@ -725,9 +893,12 @@ fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: bool) -> Result<
         println!("{linea}");
         return Ok(());
     }
-    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s)",
+    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s) · \
+               jaula={} · imagen={}",
         &sha[..12], inst.grants.network, inst.grants.sockets.len(),
-        inst.grants.devices.len(), inst.grants.dirs.len());
+        inst.grants.devices.len(), inst.grants.dirs.len(),
+        if no_jail { "NO" } else if inst.grants.nesting { "sí (anidamiento permitido)" } else { "sí" },
+        if inst.grants.seal_image { "sellada" } else { "escribible" });
 
     let st = Command::new("bwrap").args(&args).status()
         .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
@@ -1011,6 +1182,49 @@ mod tests {
         assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
     }
 
+    #[test]
+    fn sin_sellar_la_politica_no_finge_confinar_el_fs() {
+        let d = tempfile::tempdir().unwrap();
+        imagen_falsa(d.path(), "d".repeat(64).as_str());
+        let tree = d.path().join("images").join("d".repeat(64)).join(TREE);
+        let dst = d.path().join("policy");
+        compile_policy(&Grants::default(), &tree, &dst).unwrap();
+        let pol = std::fs::read_to_string(&dst).unwrap();
+        // El namespace de montaje de bwrap YA es lista blanca. Escribir reglas `ro` que no cambian
+        // nada sería un sello de goma, así que la política se queda en una línea honesta.
+        assert!(pol.lines().any(|l| l == "rw /"), "{pol}");
+        assert!(!pol.contains("\nro "), "no debería haber reglas decorativas:\n{pol}");
+    }
+
+    #[test]
+    fn sellar_congela_la_imagen_y_deja_mutable_lo_de_la_instancia() {
+        let d = tempfile::tempdir().unwrap();
+        let sha = "e".repeat(64);
+        imagen_falsa(d.path(), &sha);
+        let tree = d.path().join("images").join(&sha).join(TREE);
+        std::fs::create_dir_all(tree.join("var")).unwrap();
+        std::fs::create_dir_all(tree.join("usr")).unwrap();
+        let dst = d.path().join("policy");
+        compile_policy(&Grants { seal_image: true, ..Default::default() }, &tree, &dst).unwrap();
+        let pol = std::fs::read_to_string(&dst).unwrap();
+        assert!(pol.lines().any(|l| l == "ro /usr"), "{pol}");
+        assert!(pol.lines().any(|l| l == "rw /etc"), "{pol}");
+        assert!(pol.lines().any(|l| l == "rw /var"), "{pol}");
+        assert!(pol.lines().any(|l| l == "list /"), "{pol}");
+        // Si `rw /` sobreviviera, taparía todas las `ro`: Landlock une derechos por ancestro.
+        assert!(!pol.lines().any(|l| l == "rw /"), "el `rw /` anula el sellado:\n{pol}");
+        // Sólo se nombra lo que EXISTE: harkaq-exec falla duro ante un path inexistente.
+        assert!(!pol.contains("/boot"), "{pol}");
+    }
+
+    #[test]
+    fn root_es_lo_unico_que_nace_encendido() {
+        let g = Grants::default();
+        assert!(g.root, "sin esto un gestor de paquetes no arranca");
+        assert!(!g.network && !g.nesting && !g.seal_image);
+        assert!(g.sockets.is_empty() && g.devices.is_empty() && g.dirs.is_empty());
+    }
+
     #[test]
     fn sha_mal_formado_falla_antes_de_tocar_la_red() {
         assert!(normalize_sha256("abc").is_err());
diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md
index 37e4d5e3..cb4aba57 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -285,6 +285,42 @@ máquinas, que es lo que ya hace ADR 0013 con las fuentes) hay que mirar licenci
 redistribuir un rootfs de Ubuntu o Fedora sin modificar suele estar permitido, pero la política de
 marca es cosa aparte y no es una pregunta técnica.
 
+### D9 — Qué aporta harkaq a una instancia, y los dos conflictos que sólo se ven midiendo
+
+**Honestidad primero: en el eje del sistema de ficheros, harkaq casi no agrega nada.** El namespace
+de montaje de bwrap ya es una lista blanca —lo que no se bindea no existe adentro, y un `--ro-bind`
+es de sólo lectura por el kernel— así que escribir reglas Landlock que repiten eso sería un sello de
+goma. Por eso la política de una instancia **no sellada** es una sola línea (`rw /`) y no finge.
+
+Lo que harkaq sí aporta, y pesa mucho más con un binario ajeno y opaco que con nuestros propios
+builds:
+
+| aporte | por qué importa acá |
+|---|---|
+| **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`) |
+| **`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` |
+
+**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
+arranca bajo Landlock** (D6). Por eso `nesting = true` cuesta la política de ficheros —`harkaq-exec
+--allow-nesting --no-landlock`—, y **seccomp y `no_new_privs` siguen puestos**, que es lo que más
+pesa. Nunca por defecto y siempre dicho en la línea de arranque: harkaq no afirma una jaula que no
+puso.
+
+**Conflicto 2 — `root` adentro y anidar se pelean.** Medido: con el uid remapeado a 0 (`--uid 0`), un
+userns anidado **no puede escribir su `uid_map`**. Sin remapear, anida bien pero el gestor de
+paquetes se queja. ⇒ la instancia de juegos y la de gestor de paquetes quieren mapeos **opuestos**,
+y el manifiesto lo declara (`root`, encendido por defecto). Las dos mitades se curan con lo mismo que
+el impuesto de §NO-resuelve 2: **un rango real de subuid** mapeado con `newuidmap` y pasado a bwrap
+con `--userns FD`. Ése es hoy el ticket que más desbloquea del ADR entero.
+
+**Y un detalle que ordena las expectativas:** bwrap **tira todas las capabilities**, así que dentro
+de una instancia no hay `CAP_SYS_ADMIN` — el anidamiento sólo puede ser por userns sin privilegios,
+que es exactamente como lo hace pressure-vessel.
+
 ---
 
 ## Lo que este ADR admite que NO resuelve
@@ -374,7 +410,10 @@ Se escriben acá para que no se descubran en producción.
    `recreate` / `run`. El overlay lo monta bwrap (`--overlay-src` + `--overlay`) dentro de su
    propio namespace: sin root y sin montar nada en el host. `run` entra con `--clearenv` y con
    `--unshare-net` salvo que se declare `network` — el entorno del host también es una concesión.
-4. Concesiones → `PolicySpec` de harkaq. Empezar por **nada** y abrir sólo lo declarado.
+4. ✅ **HECHO 2026-09-03.** Concesiones → política de harkaq. `harkaq-exec` entra como último
+   eslabón dentro de bwrap (cruza un binario **estático**, no una librería ⇒ D2 sigue en pie), y
+   aporta lo que bwrap no da: **seccomp** —hoy una instancia podría `io_uring`, `bpf`, `ptrace`,
+   `userfaultfd`, `keyctl`, `perf_event_open`—, `no_new_privs`, y el canal de evidencia. Ver D9.
 5. Shims + `.desktop` generados (`export`), y clase `ajeno` en `build-state.py`.
 6. **Steam de punta a punta**, con verificación explícita del bwrap anidado.
 7. Poda (`hammer qorpa prune`) y renglón en SDD 20 sobre licencias.
diff --git a/scripts/harkaq/harkaq-exec.c b/scripts/harkaq/harkaq-exec.c
index 0dc33cc7..01b2577f 100644
--- a/scripts/harkaq/harkaq-exec.c
+++ b/scripts/harkaq/harkaq-exec.c
@@ -2,7 +2,7 @@
 // como último eslabón antes del `sh -c` del build: bwrap ya puso los namespaces, el overlay y
 // la ausencia de red; esto pone el grano fino y —lo que importa— abre el canal de evidencia.
 //
-//   harkaq-exec --policy <fichero> [--abi-min 3] -- <cmd> [args...]
+//   harkaq-exec --policy <fichero> [--abi-min 3] [--allow-nesting] [--no-landlock] -- <cmd> [...]
 //
 // Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se
 // DERIVA, no se escribe):
@@ -121,8 +121,22 @@ static const int SYSCALLS_DENEGADAS[] = {
 #endif
     // perf: superficie histórica de escalada.
     __NR_perf_event_open,
-    // Montajes: bwrap ya armó el árbol; re-montar sólo sirve para escapar de él. `pivot_root`
-    // NO se deniega: bwrap lo usa ANTES de llegar acá (esto corre después del pivot).
+};
+
+// La FAMILIA DEL ANIDAMIENTO, separada del resto a propósito.
+//
+// Para un BUILD estas syscalls no tienen uso legítimo: bwrap ya armó el árbol y re-montar sólo
+// sirve para escapar de él. Pero harkaq también enjaula instancias ajenas (qorpa, ADR 0015), y ahí
+// hay un caso legítimo y central: **Steam ya es un contenedor**. Su pressure-vessel monta y crea
+// namespaces DENTRO del nuestro, que es la configuración exacta que Valve prueba (ADR 0015 D6).
+// Denegarle `mount` no lo hace más seguro: lo hace no arrancar.
+//
+// Por eso se deniegan POR DEFECTO —el build no cambia ni un byte— y sólo se levantan con
+// `--allow-nesting`, que en qorpa cuelga de una concesión DECLARADA en el manifiesto. Abrir sólo
+// lo declarado, otra vez.
+//
+// `pivot_root` no está en ninguna de las dos listas: bwrap lo usa ANTES de llegar acá.
+static const int SYSCALLS_ANIDAMIENTO[] = {
     __NR_mount, __NR_umount2,
 #ifdef __NR_open_tree
     __NR_open_tree, __NR_move_mount, __NR_fsopen, __NR_fsconfig, __NR_fsmount,
@@ -139,15 +153,24 @@ static const int SYSCALLS_DENEGADAS[] = {
 // (SDD 25 T17 midió además que la longitud de esta lista NO cuesta nada en ejecución — el bitmap
 //  de acción constante del kernel la hace plana —, así que no hay ningún motivo de rendimiento
 //  para recortarla ni para reordenarla.)
-_Static_assert(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]) <= 250,
+_Static_assert((sizeof(SYSCALLS_DENEGADAS) + sizeof(SYSCALLS_ANIDAMIENTO)) /
+                       sizeof(SYSCALLS_DENEGADAS[0]) <= 250,
                "la denylist pasa el techo de salto de la BPF clasica (__u8): partir el filtro");
 
 // Arma e instala el filtro. Devuelve 0 si quedó puesto, -1 si el kernel no lo aceptó.
-static int poner_seccomp(void) {
-    const int n = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]));
+static int poner_seccomp(int permitir_anidamiento) {
+    const int n_base = (int)(sizeof(SYSCALLS_DENEGADAS) / sizeof(SYSCALLS_DENEGADAS[0]));
+    const int n_anid = permitir_anidamiento
+                           ? 0
+                           : (int)(sizeof(SYSCALLS_ANIDAMIENTO) / sizeof(SYSCALLS_ANIDAMIENTO[0]));
+    const int n = n_base + n_anid;
+    int *nums = calloc(n ? n : 1, sizeof(*nums));
+    if (!nums) return -1;
+    for (int i = 0; i < n_base; i++) nums[i] = SYSCALLS_DENEGADAS[i];
+    for (int i = 0; i < n_anid; i++) nums[n_base + i] = SYSCALLS_ANIDAMIENTO[i];
     // 2 (carga arch + salto) + 1 (carga nr) + n (comparaciones) + 3 (los tres RET).
     struct sock_filter *f = calloc(n + 6, sizeof(*f));
-    if (!f) return -1;
+    if (!f) { free(nums); return -1; }
     int k = 0;
     const int I_ALLOW = 3 + n, I_ERRNO = 4 + n, I_KILL = 5 + n;
 
@@ -168,7 +191,7 @@ static int poner_seccomp(void) {
                                           offsetof(struct seccomp_data, nr));
     for (int i = 0; i < n; i++, k++)
         f[k] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K,
-                                            (unsigned)SYSCALLS_DENEGADAS[i], I_ERRNO - k - 1, 0);
+                                            (unsigned)nums[i], I_ERRNO - k - 1, 0);
     f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW);
     f[k++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K,
                                           SECCOMP_RET_ERRNO | (EPERM & SECCOMP_RET_DATA));
@@ -179,15 +202,26 @@ static int poner_seccomp(void) {
     // Requiere no_new_privs, que ya está puesto antes de llamar acá.
     int r = syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);
     free(f);
+    free(nums);
     return r;
 }
 
 int main(int argc, char **argv) {
     const char *policy = NULL;
-    int abi_min = 3, i;
+    int abi_min = 3, permitir_anidamiento = 0, sin_landlock = 0, i;
     for (i = 1; i < argc; i++) {
         if (!strcmp(argv[i], "--policy") && i + 1 < argc) policy = argv[++i];
         else if (!strcmp(argv[i], "--abi-min") && i + 1 < argc) abi_min = atoi(argv[++i]);
+        else if (!strcmp(argv[i], "--allow-nesting")) permitir_anidamiento = 1;
+        // MEDIDO 2026-09-03: un dominio Landlock activo hace fallar `mount` con EACCES aunque
+        // seccomp lo permita — el kernel no deja crear montajes nuevos bajo un dominio, porque un
+        // montaje nuevo escaparía de sus reglas por-ruta. Es decir: **Landlock y un runtime de
+        // contenedores anidado son incompatibles hoy**, y eso incluye a pressure-vessel de Valve
+        // (ADR 0015 D6). Para ese caso se cambia política-de-fs por poder anidar; seccomp y
+        // no_new_privs SIGUEN puestos, que es lo que más importa con un binario ajeno. Nunca por
+        // defecto, y siempre dicho en la línea de arranque: la regla de harkaq es no afirmar una
+        // jaula que no se puso.
+        else if (!strcmp(argv[i], "--no-landlock")) sin_landlock = 1;
         else if (!strcmp(argv[i], "--")) { i++; break; }
     }
     if (!policy || i >= argc) {
@@ -206,6 +240,9 @@ int main(int argc, char **argv) {
         return 4;
     }
 
+    int n_file = 0, n_dir = 0;
+    if (sin_landlock) goto tras_landlock;
+
     __u64 handled = abi >= 5 ? FS_ABI5 : abi >= 3 ? FS_ABI3 : abi >= 2 ? FS_ABI2 : FS_ABI1;
     struct landlock_ruleset_attr attr = {.handled_access_fs = handled};
     int rs = ll_create(&attr, sizeof(attr), 0);
@@ -214,7 +251,6 @@ int main(int argc, char **argv) {
     FILE *f = fopen(policy, "r");
     if (!f) { perror("[harkaq] abrir política"); return 4; }
     char line[4200];
-    int n_file = 0, n_dir = 0;
     while (fgets(line, sizeof(line), f)) {
         char *nl = strchr(line, '\n');
         if (nl) *nl = 0;
@@ -273,17 +309,24 @@ int main(int argc, char **argv) {
     if (ll_restrict(rs, flags) < 0) { perror("[harkaq] restrict_self"); return 4; }
     close(rs);
 
+tras_landlock:;
+
     // seccomp DESPUÉS de landlock y de no_new_privs, y justo antes del exec: el filtro se hereda
     // por fork/exec igual que el dominio de Landlock (D5), así que cubre todo el árbol del build.
     // Si el kernel lo rechaza NO se sigue: D7 es fallo cerrado — correr un build creyéndolo
     // enjaulado cuando le falta media jaula es peor que no correrlo.
-    if (poner_seccomp() < 0) {
+    if (poner_seccomp(permitir_anidamiento) < 0) {
         fprintf(stderr, "[harkaq] RECHAZO: no pude instalar el filtro seccomp (%m).\n"
                         "         ¿CONFIG_SECCOMP_FILTER=y? D4: seccomp no es opcional.\n");
         return 4;
     }
 
-    if (abi < 7)
+    if (sin_landlock)
+        fprintf(stderr,
+                "[harkaq] SIN LANDLOCK por pedido explícito: seccomp y no_new_privs SÍ están, pero "
+                "NO hay política de ficheros ni evidencia. El confinamiento de fs queda en manos "
+                "del namespace de montaje de quien invoca.\n");
+    else if (abi < 7)
         fprintf(stderr,
                 "[harkaq] AVISO: ABI %d < 7 ⇒ el kernel no audita denegaciones. El build corre "
                 "ENJAULADO pero SIN EVIDENCIA; el veredicto no podrá afirmar hermeticidad.\n",
@@ -291,8 +334,9 @@ int main(int argc, char **argv) {
     else
         fprintf(stderr,
                 "[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, "
-                "logging post-exec ON\n",
-                abi, n_file, n_dir);
+                "logging post-exec ON%s\n",
+                abi, n_file, n_dir,
+                permitir_anidamiento ? ", ANIDAMIENTO PERMITIDO (mount/setns)" : "");
 
     execvp(cmd[0], cmd);
     fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]);