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 188Rust 55Shell 30Markdown 281211 Definiciones

qorpa create/recreate/run: la instancia nace sin ver nada

Paso 3 del ADR 0015. El overlay lo monta bwrap dentro de su propio namespace
(--overlay-src + --overlay), así que no hace falta root ni se monta 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, y lo que no se declara
no entra (D2/D7). `--dry-run` imprime el bwrap entero, una línea por concesión,
porque una jaula que no se puede leer no se puede auditar. `list` ahora enumera
también las instancias con lo que abre cada una — una instancia sin política y
una con la pantalla abierta se ven IGUAL desde fuera y no son lo mismo.

Los campos del manifiesto van en inglés (regla 4); el ADR los tenía en
castellano y quedan corregidos, igual que las rutas images/ e instances/.

D3 VALIDADO en la mano, no en el papel: la escritura va al upper, la imagen base
no se toca, y `recreate` tira la capa y la instancia sigue siendo la misma.

Y se midió la otra mitad del paso 1, que el ADR daba por «lo primero que va a
fallar». Falla, sí, pero el veredicto es MEJOR de lo que decía:

  uid_map: 0 1001 1 · setgroups: deny

- apt: el método http hace setgroups para bajar a _apt ⇒ para. Salteándolo con
  -o APT::Sandbox::User=root baja 34 MB, instala y corre los triggers de dpkg
  enteros; el único residuo es un AVISO de chown a root:adm.
- pacman: chownea el directorio de descarga a `alpm` ⇒ para en duro. Con
  DownloadUser comentado sincroniza, y tras pacman-key --init/--populate
  instala y el binario corre.

⇒ subuid no es un muro, es un IMPUESTO: un solo id alcanza para instalar
paquetes reales en los dos gestores, y lo que rompe es el chown/setgroups a
OTRO id, que cada gestor hace en un sitio distinto. Y quitarlo pide algo que el
ADR no decía: bwrap crea el userns con un solo id A PROPÓSITO y no llama a
newuidmap, así que además del setcap hay que crear el namespace aparte, mapear
el rango y pasárselo con --userns FD.

5 tests nuevos (nace sin concesiones, rechaza imagen vacía, el upper es caché,
sin grants la red queda fuera, y que el aviso de wayland no sea tibio). 40/40.

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

ba09fdb403c7e214d3d813e9ee1bc4130470a333 · por Sergio · (hace 1 mes) · padre fe155cf20 · ver el árbol en este commit

Delta semántico · 23

crates/hammer-cli/src/qorpa.rs

2 archivos · +446 −46

diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index 68d806e4..68ae8b96 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -31,6 +31,8 @@ pub const ROOT_ENV: &str = "HAMMER_QORPA_ROOT";
 const IMAGE_MANIFEST: &str = "image.toml";
 /// El árbol desempacado dentro del directorio de la imagen.
 const TREE: &str = "rootfs";
+/// El manifiesto de una INSTANCIA: la verdad de la que se recrea (D3).
+const INSTANCE_MANIFEST: &str = "instance.toml";
 
 #[derive(Subcommand)]
 pub enum QorpaCmd {
@@ -67,15 +69,43 @@ pub enum QorpaCmd {
         #[arg(long)]
         json: bool,
     },
-    /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Crea una instancia: overlay sobre la imagen + manifiesto.
+    /// [ADR 0015 §Orden 3] Crea una instancia: capa mutable sobre una imagen + su manifiesto.
+    ///
+    /// Nace SIN NINGUNA concesión (D7): ni red, ni sockets, ni dispositivos, ni directorios del
+    /// host. Se abre lo que se declare en `instance.toml`, y sólo eso.
     Create {
-        /// Identificador de la instancia (`juegos`).
+        /// Identificador de la instancia (`games`).
         id: String,
+        /// sha256 de la imagen base, ya traída con `pull`. Acepta prefijos.
+        #[arg(long)]
+        base: String,
+        /// Nombre informativo de la distro (`arch`, `ubuntu-24.04`).
+        #[arg(long)]
+        distro: Option<String>,
+        #[arg(long)]
+        root: Option<PathBuf>,
+    },
+    /// [ADR 0015 §Orden 3] Tira la capa mutable y la rehace desde el manifiesto.
+    ///
+    /// Es la prueba de D3: si esto duele, es que el `upper` se volvió el activo — y un blob
+    /// irreemplazable es justo lo que hammer existe para no tener.
+    Recreate {
+        id: String,
+        #[arg(long)]
+        root: Option<PathBuf>,
+    },
+    /// [ADR 0015 §Orden 3] Entra a la instancia con las concesiones declaradas y nada más.
+    Run {
+        id: String,
+        #[arg(long)]
+        root: Option<PathBuf>,
+        /// Imprime el `bwrap` que se ejecutaría y sale. La jaula tiene que ser inspeccionable.
+        #[arg(long)]
+        dry_run: bool,
+        /// Comando dentro (default: el shell de la imagen). Va después de `--`.
+        #[arg(last = true)]
+        cmd: Vec<String>,
     },
-    /// [ADR 0015 §Orden 3 — SIN IMPLEMENTAR] Recrea una instancia desde su manifiesto, tirando el `upper`.
-    Recreate { id: String },
-    /// [ADR 0015 §Orden 3/4 — SIN IMPLEMENTAR] Entra a una instancia con las concesiones declaradas.
-    Run { id: String },
     /// [ADR 0015 §Orden 5 — SIN IMPLEMENTAR] Genera los shims y `.desktop` de lo declarado en `[exporta]`.
     Export { id: String },
     /// [ADR 0015 §Orden 7 — SIN IMPLEMENTAR] Poda imágenes sin instancia y `upper` descartables.
@@ -88,14 +118,11 @@ pub fn run(cmd: QorpaCmd) -> Result<()> {
             pull(&resolve_root(root), &url, &sha256, label.as_deref(), verify_only, subdir.as_deref())
         }
         QorpaCmd::List { root, json } => list(&resolve_root(root), json),
-        QorpaCmd::Create { .. } | QorpaCmd::Recreate { .. } => bail!(
-            "sin implementar: las instancias son el paso 3 del §Orden de trabajo del ADR 0015 \
-             (overlay sobre la imagen + instancia.toml). Hoy sólo hay `pull` y `list`."
-        ),
-        QorpaCmd::Run { .. } => bail!(
-            "sin implementar: `run` necesita las instancias (paso 3) y las concesiones traducidas \
-             al PolicySpec de harkaq (paso 4) — abrir NADA por defecto es parte del contrato."
-        ),
+        QorpaCmd::Create { id, base, distro, root } => {
+            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::Export { .. } => bail!(
             "sin implementar: los shims son el paso 5 del ADR 0015. Se GENERAN, nunca se copia el \
              `Exec=` de un .desktop ajeno."
@@ -456,6 +483,261 @@ fn write_manifest(dir: &Path, m: &ImageManifest) -> Result<()> {
     Ok(())
 }
 
+// ── instancias ──────────────────────────────────────────────────────────────────────────────────
+
+/// El manifiesto de una instancia. **Es la verdad**: el `upper/` es caché y se puede tirar entero
+/// (D3). Los nombres de campo van en inglés como el resto de la superficie (`CLAUDE.md` regla 4);
+/// el ADR los escribió en castellano y se corrigieron aquí.
+#[derive(Serialize, Deserialize, Default)]
+struct Instance {
+    /// sha256 de la imagen base. Cambiarlo + `recreate` ES la actualización de versión (D8).
+    base: String,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    distro: Option<String>,
+    /// Lo que debe estar instalado adentro. Declarativo: hoy se registra, instalarlo es el
+    /// siguiente paso. Sin esto, `recreate` no podría reconstruir nada.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    packages: Vec<String>,
+    /// La política, autorada (D7). **Por defecto vacía**: una instancia nace sin ver nada.
+    #[serde(default)]
+    grants: Grants,
+}
+
+#[derive(Serialize, Deserialize, 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).
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    sockets: Vec<String>,
+    /// Nodos de dispositivo (`/dev/dri`). ABI de kernel, que es lo único que cruza el borde (D2).
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    devices: Vec<String>,
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    dirs: Vec<DirGrant>,
+    /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
+    #[serde(default)]
+    network: bool,
+}
+
+#[derive(Serialize, Deserialize)]
+struct DirGrant {
+    host: String,
+    inside: String,
+    /// `ro` o `rw`.
+    #[serde(default = "ro")]
+    mode: String,
+}
+fn ro() -> String { "ro".into() }
+
+fn instances_dir(root: &Path) -> PathBuf { root.join("instances") }
+fn instance_dir(root: &Path, id: &str) -> PathBuf { instances_dir(root).join(id) }
+
+/// Resuelve un prefijo de sha contra las imágenes presentes. Que exista y que TENGA CONTENIDO: una
+/// instancia sobre una imagen vacía es un directorio con nombre de rootfs (regla 3).
+fn resolve_image(root: &Path, prefix: &str) -> Result<(String, PathBuf)> {
+    let prefix = prefix.strip_prefix("sha256:").unwrap_or(prefix).to_ascii_lowercase();
+    let images = root.join("images");
+    let mut hits: Vec<String> = std::fs::read_dir(&images)
+        .with_context(|| format!("no hay imágenes en {}", images.display()))?
+        .flatten()
+        .map(|e| e.file_name().to_string_lossy().into_owned())
+        .filter(|n| !n.starts_with('.') && n.starts_with(&prefix))
+        .collect();
+    hits.sort();
+    match hits.len() {
+        1 => {
+            let sha = hits.remove(0);
+            let tree = images.join(&sha).join(TREE);
+            if !matches!(tree_state(&tree), TreeState::Populated) {
+                bail!("la imagen {sha} está VACÍA — no es una imagen, es un nombre");
+            }
+            Ok((sha, tree))
+        }
+        0 => bail!("ninguna imagen empieza por {prefix:?} — traela con `hammer qorpa pull`"),
+        n => bail!("{n} imágenes empiezan por {prefix:?}: dame más dígitos"),
+    }
+}
+
+fn create(root: &Path, id: &str, base: &str, distro: Option<&str>) -> Result<()> {
+    if id.is_empty() || id.contains('/') || id.starts_with('.') {
+        bail!("id de instancia inválido: {id:?}");
+    }
+    let dir = instance_dir(root, id);
+    if dir.exists() {
+        bail!("la instancia {id} ya existe en {} — `recreate` tira su capa mutable", dir.display());
+    }
+    let (sha, _tree) = resolve_image(root, base)?;
+
+    let inst = Instance {
+        base: format!("sha256:{sha}"),
+        distro: distro.map(Into::into),
+        ..Default::default()
+    };
+    std::fs::create_dir_all(dir.join("upper"))?;
+    std::fs::create_dir_all(dir.join("work"))?;
+    write_instance(&dir, &inst)?;
+    println!("instancia {id} creada en {}", dir.display());
+    println!("  base: sha256:{}", &sha[..12]);
+    println!("  concesiones: NINGUNA — se abren declarándolas en {}", INSTANCE_MANIFEST);
+    Ok(())
+}
+
+fn read_instance(root: &Path, id: &str) -> Result<(PathBuf, Instance)> {
+    let dir = instance_dir(root, id);
+    let f = dir.join(INSTANCE_MANIFEST);
+    let body = std::fs::read_to_string(&f)
+        .with_context(|| format!("no existe la instancia {id} ({})", f.display()))?;
+    let inst: Instance = toml::from_str(&body)
+        .with_context(|| format!("manifiesto ilegible: {}", f.display()))?;
+    Ok((dir, inst))
+}
+
+fn write_instance(dir: &Path, inst: &Instance) -> Result<()> {
+    let body = format!(
+        "# Manifiesto de la instancia — ESTO es la verdad, `upper/` es caché descartable (ADR 0015 D3).\n\
+         # `[grants]` es la política, y se AUTORA: es el único sitio del sistema donde no se deriva\n\
+         # de una clausura declarada (D7). Por defecto no hay ninguna.\n{}",
+        toml::to_string_pretty(inst).context("serializando la instancia")?
+    );
+    std::fs::write(dir.join(INSTANCE_MANIFEST), body)?;
+    Ok(())
+}
+
+fn recreate(root: &Path, id: &str) -> Result<()> {
+    let (dir, inst) = read_instance(root, id)?;
+    let (sha, _) = resolve_image(root, &inst.base)?;
+    for capa in ["upper", "work"] {
+        force_remove_dir_all(&dir.join(capa))
+            .with_context(|| format!("no pude tirar {capa}/ de {id}"))?;
+        std::fs::create_dir_all(dir.join(capa))?;
+    }
+    println!("instancia {id} recreada sobre sha256:{} — capa mutable tirada", &sha[..12]);
+    if !inst.packages.is_empty() {
+        println!(
+            "  declara {} paquete(s) ({}) — instalarlos todavía es a mano con `run`",
+            inst.packages.len(),
+            inst.packages.join(", ")
+        );
+    }
+    Ok(())
+}
+
+/// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
+/// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
+fn grants_to_args(g: &Grants) -> (Vec<String>, Vec<String>) {
+    let mut a: Vec<String> = Vec::new();
+    let mut warn: Vec<String> = Vec::new();
+    // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
+    let uid = std::fs::metadata("/proc/self")
+        .map(|m| std::os::unix::fs::MetadataExt::uid(&m))
+        .unwrap_or(1000);
+    let xdg = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| format!("/run/user/{uid}"));
+
+    for s in &g.sockets {
+        let (host, dentro) = match s.as_str() {
+            "wayland" => {
+                warn.push(
+                    "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \
+                     socket es un borde de privilegio y todavía no hay proxy filtrante \
+                     (ADR §NO-resuelve 1)".into(),
+                );
+                let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
+                (format!("{xdg}/{d}"), "/run/user/0/wayland-0".to_string())
+            }
+            "pipewire" => (format!("{xdg}/pipewire-0"), "/run/user/0/pipewire-0".into()),
+            "dbus" => (format!("{xdg}/bus"), "/run/user/0/bus".into()),
+            otro => {
+                warn.push(format!("socket desconocido {otro:?} — ignorado"));
+                continue;
+            }
+        };
+        a.push("--bind-try".into()); a.push(host); a.push(dentro);
+    }
+    for d in &g.devices {
+        a.push("--dev-bind-try".into()); a.push(d.clone()); a.push(d.clone());
+    }
+    for d in &g.dirs {
+        let host = if let Some(rest) = d.host.strip_prefix('~') {
+            format!("{}{}", std::env::var("HOME").unwrap_or_default(), rest)
+        } else { d.host.clone() };
+        a.push(if d.mode == "rw" { "--bind".into() } else { "--ro-bind".into() });
+        a.push(host); a.push(d.inside.clone());
+    }
+    if g.network {
+        // El segundo fallo clásico de la primera corrida, según el propio ADR.
+        a.push("--ro-bind-try".into());
+        a.push("/etc/resolv.conf".into());
+        a.push("/etc/resolv.conf".into());
+    } else {
+        a.push("--unshare-net".into());
+    }
+    (a, warn)
+}
+
+fn run_instance(root: &Path, id: &str, cmd: &[String], dry_run: 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);
+
+    // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
+    // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
+    // 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();
+
+    args.push("--overlay-src".into()); args.push(tree.display().to_string());
+    args.push("--overlay".into());
+    args.push(dir.join("upper").display().to_string());
+    args.push(dir.join("work").display().to_string());
+    args.push("/".into());
+
+    // /proc y /dev van DESPUÉS del overlay: son del kernel, no de la imagen.
+    args.extend(["--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp",
+                 "--dir", "/run/user/0", "--chdir", "/"].map(String::from));
+    args.extend([
+        "--setenv", "PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+        "--setenv", "HOME", "/root",
+        "--setenv", "TERM", &std::env::var("TERM").unwrap_or_else(|_| "xterm".into()),
+        "--setenv", "XDG_RUNTIME_DIR", "/run/user/0",
+    ].map(String::from));
+    args.extend(grant_args);
+
+    let cmd: Vec<String> = if cmd.is_empty() {
+        vec!["/bin/sh".into()] // toda imagen trae uno; bash puede no estar.
+    } else { cmd.to_vec() };
+    args.extend(cmd.iter().cloned());
+
+    for w in &warns {
+        eprintln!("⚠ {w}");
+    }
+    if dry_run {
+        // Una línea por concesión, no una por token: el punto de `--dry-run` es que se LEA.
+        let mut linea = String::from("bwrap");
+        for a in &args {
+            if a.starts_with("--") { println!("{linea} \\"); linea = format!("  {a}"); }
+            else { linea.push(' '); linea.push_str(a); }
+        }
+        println!("{linea}");
+        return Ok(());
+    }
+    eprintln!("qorpa {id} · sha256:{} · red={} · {} socket(s), {} device(s), {} dir(s)",
+        &sha[..12], inst.grants.network, inst.grants.sockets.len(),
+        inst.grants.devices.len(), inst.grants.dirs.len());
+
+    let st = Command::new("bwrap").args(&args).status()
+        .context("no pude ejecutar bwrap — ¿está en el PATH? (recipes/bwrap.toml)")?;
+    match st.code() {
+        Some(0) => Ok(()),
+        Some(c) => std::process::exit(c),
+        None => bail!("la instancia murió por señal"),
+    }
+}
+
 // ── list ────────────────────────────────────────────────────────────────────────────────────────
 
 fn list(root: &Path, json: bool) -> Result<()> {
@@ -475,6 +757,17 @@ fn list(root: &Path, json: bool) -> Result<()> {
     }
     rows.sort_by(|a, b| a.0.cmp(&b.0));
 
+    let insts: Vec<(String, Instance)> = std::fs::read_dir(instances_dir(root))
+        .into_iter()
+        .flatten()
+        .flatten()
+        .filter_map(|e| {
+            let id = e.file_name().to_string_lossy().into_owned();
+            let body = std::fs::read_to_string(e.path().join(INSTANCE_MANIFEST)).ok()?;
+            Some((id, toml::from_str::<Instance>(&body).ok()?))
+        })
+        .collect();
+
     if json {
         let out: Vec<_> = rows.iter().map(|(sha, m, st)| {
             serde_json::json!({
@@ -486,11 +779,17 @@ fn list(root: &Path, json: bool) -> Result<()> {
                 "empty": !matches!(st, TreeState::Populated),
             })
         }).collect();
-        println!("{}", serde_json::to_string_pretty(&out)?);
+        let insts_json: Vec<_> = insts.iter().map(|(id, i)| serde_json::json!({
+            "id": id, "base": i.base, "distro": i.distro,
+            "packages": i.packages, "network": i.grants.network,
+            "sockets": i.grants.sockets, "devices": i.grants.devices,
+        })).collect();
+        println!("{}", serde_json::to_string_pretty(
+            &serde_json::json!({"images": out, "instances": insts_json}))?);
         return Ok(());
     }
 
-    if rows.is_empty() {
+    if rows.is_empty() && insts.is_empty() {
         println!("no hay imágenes en {} — `hammer qorpa pull <url> --sha256 <sha>`", images.display());
         return Ok(());
     }
@@ -511,6 +810,21 @@ fn list(root: &Path, json: bool) -> Result<()> {
             }
         }
     }
+    if !insts.is_empty() {
+        println!("\ninstancias:");
+        for (id, i) in &insts {
+            let sha = i.base.strip_prefix("sha256:").unwrap_or(&i.base);
+            // Se enumeran las concesiones porque una instancia sin política declarada y una con la
+            // pantalla abierta se ven IGUAL desde fuera, y no son lo mismo (ADR §NO-resuelve 1).
+            let mut abre: Vec<String> = Vec::new();
+            if i.grants.network { abre.push("red".into()); }
+            abre.extend(i.grants.sockets.iter().cloned());
+            abre.extend(i.grants.devices.iter().cloned());
+            if !i.grants.dirs.is_empty() { abre.push(format!("{} dir(s)", i.grants.dirs.len())); }
+            println!("  {:<16} sobre {}  abre: {}", id, &sha[..sha.len().min(12)],
+                if abre.is_empty() { "nada".into() } else { abre.join(", ") });
+        }
+    }
     println!("\n{} imagen(es) en {}", rows.len(), images.display());
     if vacias > 0 {
         bail!("{vacias} imagen(es) VACÍA(s): un ausente falla ruidosamente, un vacío llega hasta el final diciendo que todo fue bien");
@@ -628,6 +942,75 @@ mod tests {
         assert!(!root.join("images").join(&sha).exists());
     }
 
+    /// Una imagen mínima que pasa por rootfs: `etc/` + `bin/`.
+    fn imagen_falsa(root: &Path, sha: &str) {
+        let t = root.join("images").join(sha).join(TREE);
+        std::fs::create_dir_all(t.join("etc")).unwrap();
+        std::fs::create_dir_all(t.join("bin")).unwrap();
+        std::fs::write(t.join("etc/os-release"), "ID=falsa\n").unwrap();
+    }
+
+    #[test]
+    fn create_nace_sin_ninguna_concesion() {
+        let d = tempfile::tempdir().unwrap();
+        let root = d.path();
+        let sha = "a".repeat(64);
+        imagen_falsa(root, &sha);
+        create(root, "prueba", "aaaa", Some("falsa")).unwrap();
+        let (_, inst) = read_instance(root, "prueba").unwrap();
+        assert_eq!(inst.base, format!("sha256:{sha}"));
+        // D7: la política se autora, y arranca VACÍA.
+        assert!(!inst.grants.network);
+        assert!(inst.grants.sockets.is_empty() && inst.grants.devices.is_empty());
+        // Y no se pisa a sí misma.
+        assert!(create(root, "prueba", "aaaa", None).is_err());
+    }
+
+    #[test]
+    fn create_rechaza_una_imagen_vacia() {
+        let d = tempfile::tempdir().unwrap();
+        let root = d.path();
+        let sha = "b".repeat(64);
+        std::fs::create_dir_all(root.join("images").join(&sha).join(TREE)).unwrap();
+        let err = create(root, "x", &sha, None).unwrap_err().to_string();
+        assert!(err.contains("VACÍA"), "mensaje inesperado: {err}");
+    }
+
+    #[test]
+    fn recreate_tira_la_capa_mutable_y_conserva_el_manifiesto() {
+        let d = tempfile::tempdir().unwrap();
+        let root = d.path();
+        let sha = "c".repeat(64);
+        imagen_falsa(root, &sha);
+        create(root, "i", &sha, None).unwrap();
+        let upper = instance_dir(root, "i").join("upper");
+        std::fs::write(upper.join("basura"), "x").unwrap();
+        recreate(root, "i").unwrap();
+        assert!(!upper.join("basura").exists(), "el upper no es caché si sobrevive");
+        assert!(instance_dir(root, "i").join(INSTANCE_MANIFEST).exists(), "el manifiesto ES la verdad");
+    }
+
+    #[test]
+    fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
+        let (a, w) = grants_to_args(&Grants::default());
+        assert!(a.contains(&"--unshare-net".to_string()));
+        assert!(w.is_empty());
+
+        let g = Grants { network: true, ..Default::default() };
+        let (a, _) = grants_to_args(&g);
+        assert!(!a.contains(&"--unshare-net".to_string()));
+        // El segundo fallo clásico de la primera corrida, según el ADR.
+        assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
+    }
+
+    #[test]
+    fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
+        let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
+        let (_, w) = grants_to_args(&g);
+        assert_eq!(w.len(), 1);
+        assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
+    }
+
     #[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 f48bbbc2..37e4d5e3 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -4,8 +4,8 @@
   §Orden de trabajo. Este documento decide la frontera; el código viene después.
 - **Fecha:** 2026-09-03
 - **Frontera (a crear):** `hammer qorpa {pull,create,run,export,list,prune}`,
-  `/var/lib/hammer/qorpa/imagenes/<sha256>/`, `/var/lib/hammer/qorpa/instancias/<id>/`,
-  `instancia.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`.
+  `/var/lib/hammer/qorpa/images/<sha256>/`, `/var/lib/hammer/qorpa/instances/<id>/`,
+  `instance.toml` (manifiesto), clase de nodo `ajeno` en `build-state.py`.
 - **Superficie:** verbos en inglés, mensajes en castellano — `CLAUDE.md` regla 4. Este ADR nació
   proponiendo `traer/crear/correr` y se corrigió el 2026-09-03; el nombre `qorpa` sí es quechua.
 - **Continúa:** [SDD 04](../04-overlay.md) (overlay), [SDD 16](../16-harkaq-jaula.md) (harkaq),
@@ -91,9 +91,9 @@ todo fue bien»).
 Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**:
 
 ```
-/var/lib/hammer/qorpa/imagenes/<sha256>/  imagen  — INMUTABLE, verificada por digest
-/var/lib/hammer/qorpa/instancias/<id>/
-    ├── instancia.toml                    manifiesto — LA VERDAD
+/var/lib/hammer/qorpa/images/<sha256>/    imagen  — INMUTABLE, verificada por digest
+/var/lib/hammer/qorpa/instances/<id>/
+    ├── instance.toml                     manifiesto — LA VERDAD
     ├── upper/                            capa mutable — CACHÉ
     └── work/                             overlayfs
 ```
@@ -129,20 +129,22 @@ host «para ahorrar espacio».
 Si el `upper` con 200 paquetes instalados a mano fuese el activo, tendríamos un blob irreemplazable:
 justo lo que hammer existe para no tener. La relación correcta es la misma que receta↔artefacto:
 
+Los campos van en inglés como el resto de la superficie (`CLAUDE.md` regla 4):
+
 ```toml
-# /var/lib/hammer/qorpa/instancias/juegos/instancia.toml
-base    = "sha256:…"                  # digest de la imagen, inmutable
-distro  = "fedora-43"                 # informativo
-paquetes = ["steam", "mesa-dri-drivers", "mesa-vulkan-drivers"]
-
-[concesiones]                          # POR DEFECTO NADA
-sockets    = ["wayland", "pipewire"]
-devices    = ["/dev/dri", "/dev/ntsync"]
-directorios = [{ host = "~/Juegos", dentro = "/home/usuario/Juegos", modo = "rw" }]
-red        = true                      # lo necesita dnf y lo necesita Steam
-
-[exporta]                              # capa de transparencia; ver D5
-binarios = ["steam"]
+# /var/lib/hammer/qorpa/instances/games/instance.toml
+base     = "sha256:…"                 # digest de la imagen, inmutable
+distro   = "arch"                     # informativo
+packages = ["steam", "mesa"]
+
+[grants]                              # POR DEFECTO NADA
+sockets = ["wayland", "pipewire"]
+devices = ["/dev/dri", "/dev/ntsync"]
+dirs    = [{ host = "~/Juegos", inside = "/home/user/Juegos", mode = "rw" }]
+network = true                        # lo necesita el gestor de paquetes y lo necesita Steam
+
+[export]                              # capa de transparencia; ver D5
+binaries = ["steam"]
 apps     = ["steam.desktop"]
 ```
 
@@ -151,7 +153,7 @@ Consecuencias que se caen solas:
   descartable.
 - **Actualizar la base** (Fedora 43 → 44) no es un rebase riesgoso: se cambia el digest y se recrea.
 - **El respaldo** es el manifiesto (KB), no el `upper` (GB). `respaldo-storagebox.sh` no toca
-  `/var/lib/hammer/qorpa/instancias/*/upper` y eso es correcto, no un olvido.
+  `/var/lib/hammer/qorpa/instances/*/upper` y eso es correcto, no un olvido.
 - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.**
 
 ### D4 — Granularidad: cuatro tipos, no uno
@@ -191,7 +193,7 @@ una línea de comando que no escribimos en el menú del usuario.
 
 **Capa 2 — se exporta lo declarado.** Si se exporta todo, el `ls` y el `systemctl` de Fedora compiten
 con los nuestros. Ésa es la falla de Bedrock: **arbitra en tiempo de `exec`**, con heurísticas. Acá
-la lista `[exporta]` del manifiesto resuelve la ambigüedad **al declarar**. Tiene precedente directo:
+la lista `[export]` del manifiesto resuelve la ambigüedad **al declarar**. Tiene precedente directo:
 la hidratación del escritorio ya va por lista explícita (`TARGETS="…"`), nunca por glob.
 
 **Capa 3 — el grafo lo sabe.** Los nodos exportados entran en `build-state.json` con **clase
@@ -232,7 +234,7 @@ la evidencia negativa que hace valioso al subproducto ([SDD 16](../16-harkaq-jau
 
 **Una imagen ajena no tiene clausura declarada.** Es el primer y único lugar del sistema donde la
 política hay que **autorarla**. No es un defecto: es una excepción que conviene nombrar y acotar en
-vez de descubrir. El bloque `[concesiones]` del manifiesto es esa política, **por defecto vacío**, y
+vez de descubrir. El bloque `[grants]` del manifiesto es esa política, **por defecto vacío**, y
 se compila al mismo `PolicySpec` que ya consume `harkaq-exec`.
 
 Un `dnf` que no alcanza `~/.ssh` es un argumento que ninguna distro ofrece.
@@ -246,7 +248,7 @@ Dos preguntas que se confunden y tienen respuestas distintas.
 
 | qué | ¿pineado? | cómo se actualiza |
 |---|---|---|
-| el rootfs base | **sí**, por sha256 | cambiar el digest en `instancia.toml` + `hammer qorpa recreate` |
+| el rootfs base | **sí**, por sha256 | cambiar el digest en `instance.toml` + `hammer qorpa recreate` |
 | lo que instalás adentro (`dnf install steam`, `pacman -Syu`) | **no, y no puede estarlo** | con el gestor de la imagen, cuando quieras |
 
 Subir la base de versión es barato **precisamente por D3**: como el manifiesto es la verdad y el
@@ -296,12 +298,23 @@ Se escriben acá para que no se descubran en producción.
    capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario.
    Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR.
 
-2. **UID mapping.** Sin userns privilegiados, `root` adentro es tu UID afuera — que es la propiedad
-   de seguridad que queremos, pero rompe operaciones que hacen `chown` a UIDs distintos (rpm crea
-   usuarios de servicio). La salida son rangos subuid, y **las piezas ya están en el corpus**:
-   `recipes/shadow.toml` instala `newuidmap`/`newgidmap` y crea `/etc/subuid` vacío. Falta
-   provisionarlo (capacidad de fichero o setuid + rango en `/etc/subuid`) y probarlo con un `dnf
-   install` real. Es la primera cosa que va a fallar.
+2. **UID mapping — MEDIDO 2026-09-03, y el veredicto es mejor de lo que decía este párrafo.**
+   Sin userns privilegiados, `root` adentro es tu UID afuera. La instancia ve `uid_map: 0 1001 1` y
+   `setgroups: deny`, y eso rompe todo lo que quiera *otro* id. Medido en las dos imágenes curadas:
+
+   | gestor | qué pasa | severidad |
+   |---|---|---|
+   | `apt` (Ubuntu 24.04) | el método http hace `setgroups` para bajar a `_apt` ⇒ `Operation not permitted` | **para**, pero se saltea con `-o APT::Sandbox::User=root`, y entonces baja 34 MB, instala y corre los triggers de dpkg enteros. Único residuo: `W: chown to root:adm … failed`, un **aviso** |
+   | `pacman` (Arch) | chownea el directorio de descarga al usuario `alpm` ⇒ `Invalid argument` | **para en duro**; con `DownloadUser` comentado sincroniza, y tras `pacman-key --init && --populate` instala y el binario corre |
+
+   ⇒ **subuid no es un muro, es un impuesto.** Un solo id alcanza para instalar paquetes reales en
+   los dos gestores; lo que rompe es el `chown`/`setgroups` a *otro* id, y cada gestor lo hace en un
+   sitio distinto. Lo que sigue faltando para quitar el impuesto: `newuidmap`/`newgidmap` con
+   `cap_setuid`/`cap_setgid` (`recipes/shadow.toml` los INSTALA pero no los provisiona — el mismo
+   hueco espera en la distro propia) **y** una vía para que bwrap use ese mapa: bwrap crea el userns
+   con un solo id a propósito y no llama a `newuidmap`, así que hay que crear el namespace aparte,
+   mapear el rango y pasárselo con `--userns FD`. `scripts/qorpa/qorpa-preflight.sh` ya mide las dos
+   mitades.
 
 3. **Segunda cadena de suministro, sin ninguna garantía de hammer.** Sin repro, sin cierre firmado,
    sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3
@@ -355,8 +368,12 @@ Se escriben acá para que no se descubran en producción.
 
 1. **Provisionar subuid** y probar `dnf install` en una instancia mínima. Es lo primero que falla
    (§NO-resuelve 2) y define si el resto es fácil o difícil.
-2. `hammer qorpa pull <url> --sha256` + verificación de digest. Reusa el patrón de `lab-image.sh`.
-3. Instancia = overlay sobre la imagen + `instancia.toml`; `create` / `recreate` / `run`.
+2. ✅ **HECHO 2026-09-03.** `hammer qorpa pull <url> --sha256` + verificación de digest antes de
+   desempacar. El rootfs se ancla por estructura (`etc/` + `usr|bin`), con `--subdir` de escape.
+3. ✅ **HECHO 2026-09-03.** Instancia = overlay sobre la imagen + `instance.toml`; `create` /
+   `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.
 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.