> 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: 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
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]);