# ADR 0015 — qorpa: el mundo glibc entra enjaulado, y no entra al store - **Estado:** PROPUESTO — nombre ADOPTADO (`qorpa`, 2026-09-03); implementación EN CURSO por el §Orden de trabajo. Este documento decide la frontera; el código viene después. - **Fecha:** 2026-09-03 - **Frontera (a crear):** `takana qorpa {pull,create,run,export,list,prune}`, `/var/lib/hammer/qorpa/images//`, `/var/lib/hammer/qorpa/instances//`, `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), [ADR 0004](0004-no-custom-nix.md) (takana no usa nix), [SDD 20](../20-catalogo-publicable-y-completa.md) (catálogo publicable). - **Absorbe:** [`plan-jaula-juegos.md`](../plan-jaula-juegos.md) §Capa 3. Su **F1** pasa a ser un caso particular de este ADR (imagen de tipo 2); su **F0** (flatpak al catálogo) queda innecesaria; su **F2** (glibc + multilib 32-bit desde fuente, «una campaña entera») queda **cancelada**. --- ## Contexto ### El problema, medido El corpus tiene hoy 788 recetas selladas y cuatro escritorios que cierran. De apps de usuario final tiene esto: ``` kate konsole dolphin okular gwenview ark spectacle kcalc filelight kfind · foot · vim ``` Ni navegador, ni suite ofimática, ni reproductor, ni editor de imagen. La pregunta natural es qué lo impide, y la respuesta se parte en tres montones **con causas distintas**: | montón | qué hay adentro | qué lo bloquea | |---|---|---| | **A** | Firefox, Chromium, LibreOffice, mpv, GIMP, Inkscape, Blender, OBS | **nada estructural**: son Wayland-nativos y compilables. Falta escribirlas. | | **B** | Steam/Proton, IDEs de JetBrains, Zoom/Slack/Discord/Spotify, binarios comerciales | **la libc**. Son prebuilts glibc. Mueren en el loader, antes de tocar el servidor gráfico. | | **C** | herramental X11: `xdotool`, `xbindkeys`, `x11vnc`, Barrier | **Wayland**, y Xwayland tampoco los salva (sólo ve clientes X). | La medición que ordena el debate: **la intersección de «sólo X11» con «compilable desde fuente en musl» es casi vacía**. Casi todo lo que se pierde por Wayland ya estaba perdido por musl. El montón que duele es **B**, y ninguna cantidad de trabajo en el corpus lo mueve: son binarios que nadie va a recompilar. ### Por qué no alcanza con lo que ya decidimos Tres salidas parecen razonables y las tres son peores: 1. **Construir glibc + multilib 32-bit en el corpus.** Es la F2 del plan de juegos, descrita ahí mismo como «una campaña entera»: toolchain dual, loader, locales. Y aun así no corrés Steam: el cliente espera un userland glibc completo, no una libc suelta. 2. **Flatpak al catálogo** (F0 del mismo plan). Funciona, pero mete ostree + flatpak + su modelo de runtimes y portales, y la cadena de custodia pasa a ser de flathub, no nuestra. Era la validación barata mientras no hubiera jaula propia. 3. **Aflojar Wayland-only.** No resuelve B —el problema es la libc— y sí revierte una decisión tomada con fundamento. ### Lo que ya está construido Este ADR compone piezas existentes. No inventa infraestructura: | pieza | dónde | estado | |---|---|---| | aislamiento de namespaces | `recipes/bwrap.toml` | sellada; ya es dep del lab de build | | capa mutable sobre base inmutable | [SDD 04](../04-overlay.md) + `crates/hammer-overlay` | overlayfs, `takana try` | | política de grano fino en el kernel | [SDD 16](../16-harkaq-jaula.md) | Landlock + seccomp; fase 1 ✅, barrido con 0 irreducibles | | rootfs ajeno pineado por sha256 | `scripts/lab-image.sh` | en producción desde 2026-08-10 | | `newuidmap` / `newgidmap` / `/etc/subuid` | `recipes/shadow.toml` | **instalados**; falta provisionarlos | | kernel con lo necesario | `recipes/linux-metal.toml:111-114` | `OVERLAY_FS` (+redirect_dir/index/xino/metacopy), `USER_NS`, `NAMESPACES`, `SECURITY_LANDLOCK`, `SECCOMP` | Nada de esto hay que re-sellar. El kernel propio ya arranca con todo encendido. --- ## Decisión **Se adopta un subsistema de imágenes ajenas: rootfs de otras distribuciones, traídos enteros y pineados por digest, que corren enjaulados sobre el mismo kernel y comparten con el escritorio únicamente protocolos y nodos de dispositivo. Sus artefactos NO entran al store.** ### D1 — La invariante del store > **Una imagen ajena no es un artefacto y no vive en el store.** No es prolijidad. El store promete que **cada entrada se reconstruye desde fuente bit a bit**. Un rootfs de Fedora no. Meterlo ahí haría que el store mienta, y sería la misma clase de error que el artefacto vacío que ya nos costó caro: *algo que se lee como garantía y no lo es* (`CLAUDE.md` regla 3 — «un ausente falla ruidosamente; un vacío llega hasta el final diciendo que todo fue bien»). Espacio de nombres paralelo, direccionado por digest, **fuera de `hash_inputs` de todo**: ``` /var/lib/hammer/qorpa/images// imagen — INMUTABLE, verificada por digest /var/lib/hammer/qorpa/instances// ├── instance.toml manifiesto — LA VERDAD ├── upper/ capa mutable — CACHÉ └── work/ overlayfs ``` El precedente exacto ya existe y funciona: `scripts/lab-image.sh` trae el rootfs del lab pineado por sha256 porque `apk add` contra Alpine edge es irrepetible por diseño. Misma figura, otro consumidor. ### D2 — La regla del borde > **Se cruza el borde con protocolos y nodos de dispositivo, nunca con librerías compartidas.** | qué | cómo cruza | por qué | |---|---|---| | pantalla | socket `wayland-0` | protocolo, no ABI. La imagen trae su `libwayland`. | | audio | socket de PipeWire | ídem | | IPC | socket de D-Bus (opcional, por sesión) | ídem | | GPU | `/dev/dri/*` | ABI de kernel (DRM), estable | | juegos | `/dev/ntsync` | ABI de kernel | | input | `/dev/input/*` (sólo si se concede) | ABI de kernel | | **Mesa / libc / cualquier `.so`** | **no cruza** | nuestro Mesa es musl; un proceso glibc no lo carga. La imagen trae el suyo, que habla DRM con nuestro kernel sin conocerlo. | El corolario práctico: **la jaula no sabe ni le importa qué libc hay adentro.** Glibc, musl ajeno, uclibc, bionic. Lo único que cruza es la ABI del kernel, que es estable por contrato. Por eso este mecanismo resuelve el montón B completo de una sola vez. El error a evitar —y es el que convierte esto en un pantano— es intentar compartir librerías del host «para ahorrar espacio». ### D3 — El manifiesto es la verdad; el `upper` es caché > **Una instancia se declara, no se acumula.** Si el `upper` con 200 paquetes instalados a mano fuese el activo, tendríamos un blob irreemplazable: justo lo que takana 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/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"] ``` Consecuencias que se caen solas: - **`takana qorpa recreate `** reconstruye la instancia desde el manifiesto. El `upper` es 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/instances/*/upper` y eso es correcto, no un olvido. - La poda tiene una regla trivial: **un `upper` siempre se puede borrar.** **✅ COMPLETADO 2026-09-03 — hasta acá `packages` era una lista que nadie leía.** Durante unas horas el ADR afirmaba las cuatro consecuencias de arriba mientras `recreate` confesaba en su propia salida que «instalarlos todavía es a mano»: o sea que el `upper` **sí** era el activo y el manifiesto un adorno. Ahora `takana qorpa provision ` instala lo declarado, y **`recreate` lo llama solo** (`--no-provision` para no hacerlo). | decisión | por qué | |---|---| | el gestor se **detecta** (`apt`, `pacman`, `dnf`, `apk`), no se configura | cada imagen trae el suyo. Y **no se multiplexa**: el ADR rechaza un comando único al estilo `pmm` de Bedrock; acá se elige cuál correr, no se inventa una interfaz que los tape | | `provision` **ensancha la política y lo dice en la cara** | instalar pide las tres cosas que una instancia bien declarada no tiene —red, root y la imagen sin sellar—. Se ensancha sólo durante esa operación, el manifiesto **no se toca**, y el siguiente `run` vuelve a lo escrito. Ensancharlo en silencio sería exactamente lo que D7 prohíbe | | el registro (`provisioned.toml`) vive **fuera** del `upper` | si viviera dentro se iría con la capa y nadie sabría qué había. Por eso `recreate` lo borra a mano: un registro que afirma paquetes sobre una capa recién vaciada es la forma más pura del error de la regla 3 | | los nombres de paquete se **validan y se comillan** | salen del manifiesto, que lo escribe una persona; `strace; rm -rf /` no llega al guión | **Las dos manías que sólo aparecen provisionando de verdad** (medidas, no leídas): el bootstrap de Arch trae la **mirrorlist entera comentada** —`pacman` muere con «no servers configured»— y el **llavero sin inicializar**, con lo que toda firma es inválida; el guión pone el mirror geo oficial **avisando cuál**, y corre `pacman-key --init && --populate` sólo si falta. `apt`, en cambio, no necesita ni un workaround: es el dividendo del rango de subuid. **Probado de punta a punta en los dos gestores**: `apt` sobre Ubuntu base y `pacman` sobre el bootstrap de Arch instalan, el binario corre después con la red apagada otra vez, y un `recreate` tira la capa y la deja igual — que es la prueba de esta sección entera. ### D4 — Granularidad: cuatro tipos, no uno | tipo | qué es | mutable | ¿sellable? | para qué | |---|---|---|---|---| | **1** | rootfs completo con gestor (Fedora+`dnf`, Arch+`pacman`) | sí | no | «literalmente lo que sea» | | **2** | runtime curado congelado (Steam Linux Runtime *sniper*, runtime freedesktop) | **no** | **sí**, receta `foreign` pineada por sha256 | cadena de custodia nuestra | | **3** | bundle por app (AppImage) | no | sí | una app suelta | | **4** | glibc en un prefijo, sin jaula | — | — | **rechazado**: frágil y ensucia justo lo que se protege | El tipo 2 es la F1 del plan de juegos y **sigue siendo el preferido cuando alcanza**: es inmutable, se sella y su digest entra en el índice firmado. El tipo 1 existe porque `dnf install` es precisamente lo que el tipo 2 no permite. **⚠ Corrección de vocabulario (2026-09-04).** Este ADR decía «entra al store por `file_drop`», y `file_drop` en takana es **otra cosa**: una operación de `takana apply` que coloca un fichero en el sistema instalado verificando su hash (`takana-core::apply`). No tenía nada que ver con sellar. Lo que sella de verdad es lo de siempre —**una receta**—, con una marca nueva: `foreign = true`. Se deja escrito porque un término inventado que suena a mecanismo existente es peor que un hueco: manda a buscar el código donde no está. ### D5 — Transparencia: shims generados, no un sistema de ficheros El objetivo es el poder de Bedrock Linux —un espacio de nombres unificado, apps de cualquier «stratum» disponibles en todos lados— **sin sus formas**: un FUSE global en el camino de cada `exec`, integración a nivel de PID 1, y arbitraje por heurística de qué binario gana. Acá no hace falta nada de eso, porque **el FHS de esta distro ya es una proyección, no la verdad**: `takana hydrate` proyecta artefactos del store a un árbol. Extender la proyección a instancias es idiomático. Tres capas: **Capa 1 — shims.** Al crear o actualizar una instancia se **generan** lanzadores finos en el espacio del host: un script que hace `exec` hacia adentro, más el `.desktop` con su icono. Firefox de Fedora aparece en el menú de Plasma al lado de kate; `steam` está en el `PATH`. Gana en tres cosas concretas contra el FUSE: - **cero costo en runtime** — no hay proceso en el camino crítico de cada `exec`; - **inspeccionable** — `cat` al shim y ves exactamente qué hace; - **revocable** — se borran los shims y la instancia desaparece del sistema sin desmontar nada. ⚠ **Se generan, no se copian.** El `Exec=` de un `.desktop` ajeno es texto ajeno; copiarlo pondría 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 `[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 `ajeno`**, y el reporte pasa a decir: ``` escritorio-kde 187/188 listo falta 1 (raíces 14, + 1 ajenas) ``` Así sale hoy, de verdad: el `xwayland` que era `wanted` (deuda: receta por escribir) pasó a `ajeno` y **la deuda se disolvió sin escribir la receta**. Dos decisiones que sostienen la cifra: - **Los ajenos se restan del denominador.** Si entraran en la fracción, el número que todo el mundo lee como «cuánto construimos» crecería solo cada vez que alguien enjaula una app ajena. - **La declaración vive en `docs/state/qorpa-ajenos.toml`, en el repo — no se lee de `/var/lib/hammer`.** `build-state.json` se commitea y lo regenera el cron en dos máquinas: si la clase saliera de las instancias instaladas, cada una diría algo distinto y se pisarían en cada cosecha. Es el mismo error que ya se cometió con `sealed_remoto`. **Qué provee una imagen ajena es diseño, compartido; qué instancias tiene tu disco, no.** Bedrock no puede decirte qué tenés. Nosotros sí, y esto cierra el riesgo real de todo el ADR: que en seis meses alguien cuente esas apps como parte del corpus. **En otra clase no se pueden contar mal.** ### D6 — El caso Steam, que es el que ordena el diseño Steam **ya es un contenedor**: el cliente corre en el sistema y los juegos corren dentro de *pressure-vessel*, el contenedor bwrap de Valve con el runtime *sniper*. La forma correcta no es meter Steam en nuestra jaula sino anidarlas: ``` arje-zero → kwin (wayland) └─ instancia glibc (tipo 1: drivers, libs de 32 bits, dnf) └─ cliente Steam └─ pressure-vessel (tipo 2, de Valve) ← anidado └─ el juego + Proton ``` Corre mejor así porque **es la configuración exacta que Valve prueba**. Nosotros ponemos el nivel de afuera y la política; adentro no tocamos nada. ⚠ **Verificar, no asumir:** pressure-vessel necesita crear user namespaces *desde dentro* de uno ya creado. El kernel los tiene encendidos, pero el anidamiento hay que probarlo con un juego real. Es la clase de cosa que sella verde y falla en la mano del usuario. ### D7 — La política se escribe, y es la única vez harkaq deriva la política en vez de escribirla: `política = clausura(deps declaradas)`, y de ahí sale la evidencia negativa que hace valioso al subproducto ([SDD 16](../16-harkaq-jaula.md) §3). **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 `[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. ### D8 — Qué se pinea (el suelo, no la instancia) y cuántas imágenes se curan Dos preguntas que se confunden y tienen respuestas distintas. **a) Pinear no congela: fija el punto de partida.** El digest ES la identidad, igual que `alpine-minirootfs`, `zig` y el rootfs del lab. Lo que queda dentro y fuera del pin: | qué | ¿pineado? | cómo se actualiza | |---|---|---| | el rootfs base | **sí**, por sha256 | cambiar el digest en `instance.toml` + `takana 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 `upper/` es caché, se cambia `base = "sha256:…"` y se recrea. Sin D3 sería un rebase sobre un blob irreemplazable; con D3 son dos líneas y un rato de red. El riesgo real del pin —que upstream borre el tarball— **ya está resuelto por [ADR 0013](0013-mirror-de-fuentes.md)**: la URL no entra en la identidad, sólo el sha256, así que espejar una imagen es gratis y una imagen pineada no se puede perder. **b) Pinear ≠ lista cerrada.** `takana qorpa pull --sha256` acepta cualquier rootfs. Lo corto no es lo que *se puede* traer, sino lo que **nosotros probamos, espejamos y publicamos**, porque cada imagen curada es una segunda cadena de suministro que hay que sostener (§NO-resuelve 3). Se curan **tres**, y cada una entra por un trabajo distinto — no por sabor: | imagen | tipo | qué trabajo hace | pin | |---|---|---|---| | **Arch bootstrap** | 1 | juegos: `multilib` de 32 bits de primera clase, y SteamOS *es* Arch ⇒ extiende el argumento de D6 («la configuración exacta que Valve prueba») | `archlinux-bootstrap-2026.09.01-x86_64.tar.zst`, en `archive.archlinux.org` (archivado para siempre) | | **Ubuntu base LTS** | 1 | binarios comerciales: Zoom, Slack, Discord, Spotify, Chrome, JetBrains se compilan y prueban contra esto | `ubuntu-base-24.04.3-base-amd64.tar.gz`, `cdimage.ubuntu.com` + `old-releases` | | **Steam Runtime «sniper»** | 2 | la única **sellable**: al no mutar, su árbol entra al store y su digest va al índice firmado — cadena de custodia nuestra | ✅ **SELLADA 2026-09-04** por `recipes/steam-runtime-sniper.toml`; snapshot `3.0.20260805.254768`, el que despliega el cliente y no «el último» | **Los digests enteros están en [`docs/state/qorpa-imagenes.toml`](../state/qorpa-imagenes.toml), no acá.** Estaban acá, abreviados —`895661bd…`— y cuando la poda se llevó las imágenes del disco no alcanzaron para volver a traerlas: hubo que ir a buscarlos otra vez a upstream. **Un pin que no se puede pegar en un comando no es un pin, es una cita.** El fichero además dice de qué lista de sumas salió cada uno y **si esa lista viene firmada**, que es la única forma honesta de hablar de una cadena de suministro ajena: Ubuntu firma su `SHA256SUMS`, Arch no firma la lista pero sí el tarball, y **Valve no firma nada** — su garantía entera es TLS más el pin. **Cómo se sella, y la marca que evita que la cifra mienta (2026-09-04).** `recipes/steam-runtime-sniper.toml` es una receta normal salvo en una cosa: no construye nada, sella bytes ajenos ya compilados. Por eso lleva **`foreign = true`**, un campo nuevo que **no entra en `hash_inputs`** —describe procedencia, no identidad, así que no movió un solo `ArtifactHash`— y cuyo efecto es **contable**: `build-state.py` la clasifica `ajeno`, la resta del denominador de las imágenes y la deja fuera del recuento de recetas. Sin esa marca, sellar un prebuilt habría subido la cifra que todo el mundo lee como «cuánto construimos» — el riesgo que este ADR se puso por escrito antes de que existiera la primera instancia. El grafo lo dice con nombre y apellido: `de las ajenas, 1 selladas al store (prebuilt pineado, sin procedencia de fuente)`. **Y una diferencia con el otro tipo de ajeno:** el declarado en `qorpa-ajenos.toml` (`xwayland`) no se hashea, porque no hay receta y un hash afirmaría que lo reproducimos. El sellado **sí conserva su hash**: está en el store, y que un artefacto exista mientras el grafo lo niega sería otra forma de mentir. Lo que comparten es el estado —`ajeno`, fuera del corpus—, que es lo que protege la cifra. **Se consume con `takana qorpa import --from-store `**, y ahí está el detalle que hace que todo esto valga: la imagen se registra bajo el **sha256 del archivo de upstream** que el artefacto declara, **no** bajo su `ArtifactHash`. Si se registrara por `ArtifactHash`, la imagen importada del store y la traída con `pull` serían dos imágenes distintas con los mismos bytes y las instancias de dos máquinas dejarían de coincidir — justo lo que el pin existe para evitar. El árbol se **enlaza** en vez de copiarse: una imagen nunca se escribe (lo que escribe la instancia va a su `upper`), así que compartir inodos con un artefacto sellado y de sólo lectura es correcto por construcción y hace que la imagen cueste ~0 bytes. La contracara ya conocida: mientras el artefacto siga en el store, borrar la imagen no libera disco — `--copy` lo evita. **Del sniper se pinean dos cosas, y la segunda es la que destraba el paso 6.** La imagen (`Platform-…-runtime.tar.gz`, un rootfs) y el **depot** `SteamLinuxRuntime_sniper.tar.xz`, que trae pressure-vessel más la imagen y es lo que Steam despliega en `steamapps/common/`. Ese runtime **sólo se baja al instalar un juego**, y eso pedía credenciales: pineado se coloca a mano, y pressure-vessel se puede ejercitar **sin cuenta, sin juego y sin pantalla**. El depot **no es un rootfs**: se trae con `--verify-only`, y traerlo a secas falla el anclaje en vez de adivinar (comprobado: «no encuentro un rootfs en el archivo»). **Fedora queda BYO, no curada.** No por calidad —publica `Fedora-Container-Base-Generic-43-1.6` con CHECKSUM firmado y se pinea igual de bien— sino porque hace el **mismo trabajo que Arch** en el slot «fresco», y una tercera cadena mutable es justo la normalización que §NO-resuelve 3 quiere evitar. El ejemplo de D3 sigue diciendo `fedora-43` a propósito: el mecanismo no distingue, y eso es la demostración de que la terna es una decisión de curaduría, no del diseño. **c) Compartibles, más adelante.** Una imagen pineada es cuerpo inmutable direccionado por contenido, así que **[ADR 0014](0014-distribucion-multiorigen.md) se le aplica sin cambiar nada**: puede servirse desde cualquier origen porque el digest la verifica, y ausencia ⇒ seguir mientras que contenido distinto ⇒ ABORTAR. No se hace el día uno, pero no hay que diseñar nada para que se pueda: lo único que falta es la decisión. ⚠ Antes de publicarlas a terceros (no a nuestras propias máquinas, que es lo que ya hace ADR 0013 con las fuentes) hay que mirar licencia y **marca**: 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. ✅ **cableado 2026-09-03**: `takana qorpa run --evidence` | | **`seal_image`** | lo único del eje fs que el namespace no da: congelar `/usr`, `/bin`, `/lib`, `/opt` **aunque adentro seas root**, para que un exploit no persista en el `upper` | **El canal de evidencia, cableado (2026-09-03).** `takana qorpa run --evidence` levanta el lector (`harkaq-audit`) en el HOST —el audit no está namespeceado y los registros cruzan— antes de que arranque la instancia, y al terminar imprime uno de **tres** estados. Los tres se verificaron a mano y quedan como guardián en `scripts/qorpa/evidence-probe.sh`: | estado | qué significa | comprobado con | |---|---|---| | **HERMÉTICO** | 0 denegaciones **y el canario las respalda** | una instancia que no toca nada prohibido | | **IMPURO** | qué intentó y no pudo — *el diagnóstico, no el fallo* | `touch /usr/INTRUSO; mkdir /opt/INTRUSO` → `fs.make_reg · /usr`, `fs.make_dir · /opt` | | **SIN EVIDENCIA** | no se pudo saber. **No es «limpio»** | quitándole las capabilities al lector | **El canario es lo que hace que «cero denegaciones» valga algo.** Es un fichero en un sitio que la política no alcanza; al leerlo, el kernel emite una denegación que revela el `domain=` de ESE dominio Landlock —un número que desde fuera no se puede adivinar—. Sin él, el lector no sabe cuáles de todas las denegaciones del sistema son de esta instancia, y `denials=[]` sería el instrumento callado en vez de una instancia limpia. **Dos condiciones estructurales, y `--evidence` FALLA en vez de dar un veredicto vacío si falta alguna:** con `nesting` no hay dominio Landlock (conflicto 1, acá abajo) ⇒ **o anidás o auditás**; y **sin `seal_image` la política es `rw /`, o sea que no hay nada denegable** ⇒ un veredicto limpio sería cierto por construcción y no por mérito. Ese acoplamiento no es un defecto de la implementación: *la evidencia sólo existe donde algo puede ser negado.* **Y una que salió midiendo el propio instrumento:** sin `CAP_AUDIT_READ` el kernel **responde que no** (`NLMSG_ERROR`/EPERM) y el lector ignoraba esa respuesta esperando una que no llegaría — 8 s por consulta, 16 s en su compuerta. Como `qorpa run` lo despierta al terminar, moría **por señal dentro de la compuerta, sin emitir nada**, y el veredicto quedaba vacío: un «no» tardío se parecía demasiado a un cuelgue. Ahora el `NLMSG_ERROR` se atiende y el lector dice su motivo en 2 s. **Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy.** Medido: con un dominio Landlock activo, `mount` falla con **EACCES aunque seccomp lo permita**; el kernel no admite montajes nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: **pressure-vessel no 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~~, RESUELTO el mismo día.** Con el userns de un solo id que crea bwrap, `--uid 0` impedía que un userns anidado escribiera su `uid_map`: la instancia de juegos y la de gestor de paquetes querían mapeos **opuestos**. Con el mapeo por rango de §NO-resuelve 2 el conflicto desaparece —anida **con `root = true`**, verificado— y `root` deja de ser una elección incómoda. El campo se queda en el manifiesto porque sigue describiendo quién sos adentro, no porque haya que elegir entre dos males. **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. ### D10 — Los tres muros que sólo aparecieron con Steam en la mano Ninguno estaba en el ADR, y los tres son de la clase «sella verde y falla en la mano del usuario» que §D6 nombraba sin poder anticipar. **1. La jaula mataba TODOS los binarios de 32 bits.** El filtro seccomp de harkaq comprueba `arch == AUDIT_ARCH_X86_64` y **mata** lo que no lo sea. Para un build es la defensa clásica y correcta —los números de syscall son por arquitectura—; para el montón B es fatal, porque el cliente de Steam es un ELF i386. El síntoma medido fue `ldd: exited with unknown exit code (159)` — **159 = 128+31 = SIGSYS** — que no se parece en nada a «tu jaula mata los 32 bits». La salida no es aflojar el check (eso reabre el agujero) sino **darle a i386 su propia tabla y aplicarle la misma política**. Los 25 números van literales y se verificaron **uno por uno contra `/usr/include/asm/unistd_32.h`**: 24 estaban bien y **uno mal** — `kexec_file_load` no existe en i386, y su número de x86_64 (320) es ahí `utimensat`, o sea que habríamos denegado algo que usa cualquier cosa que toque una marca de tiempo. Es la diferencia entre una tabla escrita de memoria y una verificada. **2. Steam se niega a correr como root**, y cambiar el mapa para evitarlo **corrompe la instancia.** `bin_steam.sh: Error: Cannot run as root user`. La vía tentadora es mapear el uid 1000 en vez del 0 —el mapa decide quién sos—, pero **un fichero creado bajo un mapa aparece con OTRO uid bajo el otro**: el `useradd` de la preparación deja un `/home` que después su propio dueño no puede escribir (`mkdir: /home/jugador/.steam: Permission denied`). ⇒ el mapa es parte de la IDENTIDAD de la instancia y cambiarlo pide `recreate`. La vía correcta es la de cualquier runtime de contenedores: **un solo mapa, y se BAJA de privilegio adentro** (`run_as` en el manifiesto, `setpriv`, con el `CAP_SETUID` que ya tenemos en el namespace). **3. El `XDG_RUNTIME_DIR` es del usuario que CORRE, no del uid del mapa.** Con `run_as`, apuntarlo al de root deja a las herramientas del Steam Runtime sin poder crear su temporal (`srt-logger: g_mkdtemp: Permission denied`). Se lee como un aviso menor hasta que algo deja de andar sin explicar por qué. **Lo que sí quedó probado, y es el corazón del ADR:** un userland glibc ajeno con su cadena de 32 bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton. ### D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls El compositor anuncia **todos** sus globals a quien se conecte. El proxy se mete en el medio, relaya todo salvo dos mensajes y con eso alcanza: | mensaje | qué se hace | por qué | |---|---|---| | `wl_registry.global` (del compositor) | si el interface no está en la lista, **no se reenvía** | el cliente nunca se entera de que existe | | `wl_registry.bind` (del cliente) | si el `name` es uno de los ocultos, `wl_display.error` y se corta | el `name` es **un número que se puede adivinar**: ocultar sin cortar el bind dejaría el filtro en decorativo | **Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón:** el conjunto *peligroso* de Wayland **crece** —cada compositor inventa protocolos privilegiados— mientras que el conjunto *necesario para dibujar una ventana* es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el compositor, **y sin un solo error visible**. La lista incluye a propósito lo que los juegos piden y suele olvidarse: `pointer_constraints`, `relative_pointer`, `tearing_control`, `idle_inhibit`. **Lo que hace esto viable en un fichero:** ningún mensaje que se descarta lleva descriptores, así que los fds (que Wayland pasa por `SCM_RIGHTS` para `wl_shm` y dmabuf, y sin los cuales no se dibuja nada) se relayan en orden de llegada **sin tener que asociarlos a su mensaje**. **Probado sin compositor**, con uno FALSO que anuncia siete globals —tres permitidos y cuatro peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear por número el `name` que nunca se anunció corta la conexión. Es la clase de prueba que no necesita GPU y aun así responde la pregunta entera. Un quinto test cubre el `size` menor que la cabecera, que haría avanzar 0 bytes y colgar el proxy en un bucle. --- ## Lo que este ADR admite que NO resuelve Se escriben acá para que no se descubran en producción. 1. **El socket de Wayland — ~~el agujero~~ CERRADO 2026-09-03.** Se escribió el proxy que faltaba (D11): la instancia ya no ve el registro entero del compositor sino una **lista blanca**. Lo que queda del párrafo original es la razón por la que se hizo: pasar el socket crudo **no es abrir un caño, es un borde de privilegio** — `screencopy` captura la pantalla, `virtual_keyboard` sintetiza teclas, `data_control` lee el portapapeles sin que nadie copie y `layer_shell` dibuja encima de todo. Sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos. 2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt` (no podía bajar a `_apt`), `pacman` (no podía chownear su descarga a `alpm`) y el userns anidado de pressure-vessel. **Tres síntomas, una causa.** La cura entera —`takana qorpa run` la aplica sola cuando puede— tiene **tres partes, y ninguna sobra**: | pieza | por qué | |---|---| | `setcap cap_setuid+ep newuidmap` (y `cap_setgid` en `newgidmap`) | el binario está en el corpus (`recipes/shadow.toml`) pero **sin provisionar**; sin la capability no puede escribir el mapa | | crear el userns **nosotros**, mapear el rango de `/etc/subuid` y pasárselo a bwrap con `--userns FD` | bwrap crea el suyo con **un solo id a propósito** y nunca llama a `newuidmap`: el trabajo es de quien lo invoca | | devolver las **capabilities** dentro del namespace (`--cap-add`) | **bwrap las tira todas**, y en Linux ser root es tener `CAP_SETUID`, no tener uid 0. Sin esto `apt` sigue sin poder `seteuid(42)` — un síntoma que *parecía* de subuid y no lo era | Con las tres: `uid_map` de **65537 ids**, `setgroups: allow`, `apt` instala **sin** el `APT::Sandbox::User=root`, `pacman` sincroniza con `DownloadUser = alpm` **intacto**, y el userns anidado monta **con `root = true`** ⇒ el conflicto 2 de D9 se disuelve. Las capabilities concedidas son seguras por construcción: dentro de un user namespace sólo alcanzan a lo que ese namespace posee, o sea nuestros propios subuid. `CAP_SYS_ADMIN` queda fuera y cuelga de `nesting`. **Y una consecuencia que sólo aparece después:** el `upper/` pasa a contener ficheros de los subuid (apt deja los suyos con uid 165577) que **nuestro uid normal no puede borrar** ⇒ `recreate` y la poda tienen que entrar a un userns mapeado para limpiar (`unshare -U --map-auto -r rm -rf`). Quitar el impuesto mueve el problema, no lo evapora. 3. **Segunda cadena de suministro, sin ninguna garantía de takana.** Sin repro, sin cierre firmado, sin escaneo de licencias. El riesgo no es técnico: es que se normalice. Mitigación = D5 capa 3 (clase `ajeno` en el grafo) + un renglón explícito en [SDD 20](../20-catalogo-publicable-y-completa.md): **las imágenes ajenas no entran en el catálogo publicable ni en el reporte de licencias, porque no podemos enumerarlas.** 4. **El alcance del claim.** Desde el día que exista una instancia, la frase «takana reproduce bit a bit» hay que acotarla por escrito: *el sistema base reproduce; las instancias ajenas no, y se declaran como tales*. Sin eso, la cultura de números honestos se erosiona sola — que es exactamente la falla que ya nos pasó con el store-gc y con la deuda fantasma. 5. **Disco.** Un rootfs de Fedora son 1-2 G y no lo alcanzan `store-gc` ni `.dmerge`. Necesita poda propia. Este repo ya tuvo tres emergencias de disco; que la poda nazca con el subsistema, no después. 6. **Red adentro.** `dnf` la necesita. Es la concesión más peligrosa del manifiesto y por eso es la única que se escribe sola en una línea aparte. También hace falta `/etc/resolv.conf`: es el segundo fallo clásico de la primera corrida. 7. **X entre instancias.** Un Xwayland dentro de una instancia levanta *su* servidor X. Portapapeles con el escritorio funciona (pasa por el compositor); entre dos instancias, no. Aceptado. --- ## Lo que se cae, y lo que se resuelve gratis - **`xwayland` deja de ser deuda del corpus.** Si las apps X sólo corren dentro de instancias, el Xwayland va **adentro de la imagen** —Fedora y Arch ya lo traen— y se cuelga de nuestro kwin por el socket. El nodo `wanted` de `escritorio-kde` se disuelve sin escribir la receta y sin tocar la postura Wayland-only. `targets.toml` debe sacarlo de las raíces con una nota que apunte acá. - **GIMP e Inkscape dejan de reabrir GTK3.** Son GTK3 y GTK3 está aparcado a propósito (ver la memoria del frente GNOME). Con instancias, corren sin traer el árbol GTK3 al corpus. - **F2 del plan de juegos, cancelada.** Las libs de 32 bits las trae la imagen. - **F0 del plan de juegos, innecesaria.** No hace falta meter flatpak+ostree al catálogo para tener juegos. ## Lo que este ADR NO cambia - **El montón A se sigue construyendo nativo.** La jaula es para binarios ajenos, no una excusa para no empaquetar. Orden acordado: **mpv → OBS → Firefox**. - **Wayland-only sigue en pie**, y este ADR es lo que lo hace barato. - **arje-zero sigue siendo PID 1.** Nada de integración a nivel de init, que es una de las formas de Bedrock que se rechazan explícitamente. - **Cada instancia se gestiona con su propio gestor**, entrando explícitamente. Nada de multiplexar `dnf`/`pacman` detrás de un comando único (el `pmm` de Bedrock): es menos mágico y no miente. --- ## Orden de trabajo propuesto 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. ✅ **HECHO 2026-09-03.** `takana qorpa pull --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. **Corregido el mismo día, y el bug era una verdad a destiempo:** dos `run` seguidos sobre la misma instancia y el segundo moría con `Can't make overlay mount … Device or resource busy`; el tercero andaba. El kernel se **niega a propósito** a montar dos overlays vivos que compartan `upperdir`/`workdir` —eso corrompe la capa—, así que el EBUSY es un guardián correcto: lo que estaba mal era el momento, porque la corrida anterior ya devolvió el prompt y su namespace todavía no terminó de reaparse. Ahora `run` reintenta acotado y, si el overlay sigue tomado, dice **la causa** («¿hay otra `run` viva?») en vez de dejar pasar el mensaje crudo de bwrap. 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. ✅ **HECHO 2026-09-03.** Shims + `.desktop` generados (`export`), y clase `ajeno` en `build-state.py`. El `.desktop` se genera con lista BLANCA de claves —`Exec`, `TryExec`, `Path`, `DBusActivatable` quedan fuera **por definición**, no por enumeración— y el icono se copia al host, porque un icono que el host no resuelve se ve como un cuadrito gris. Cada fichero escrito queda en un registro (`exported.json`) para que `--remove` borre **exactamente** lo generado y no por patrón sobre el `~/.local/bin` de alguien. 6. ⚠️ **PARCIAL 2026-09-03** — hasta donde esta máquina permite, y destapó tres muros. Steam 1.0.0.87 **instalado de verdad** desde el `multilib` de Arch (las libs de 32 bits que la F2 daba por «una campaña entera»), su cliente i386 **bajado y desempacado** por el propio bootstrap de Valve, y el **bwrap anidado verificado explícitamente** (`BWRAP-ANIDADO-OK`, 24 montajes propios) — que era lo que este paso pedía. Ver D10. **Lo que NO se pudo, y por qué:** la máquina no tiene ninguna sesión gráfica (`/dev/dri` sí, socket Wayland no), así que Steam no dibuja; y el runtime *sniper* sólo se baja al instalar un juego, lo que exige credenciales. **pressure-vessel con un juego real sigue sin ejercitarse.** **✅ AMPLIADO 2026-09-03: pressure-vessel SÍ se ejercitó — sin cuenta, sin juego y sin pantalla.** Lo que lo destrabó fue pinear el depot (D8): `SteamLinuxRuntime_sniper.tar.xz` es lo que Steam despliega, y colocado a mano no hace falta comprar nada. Corrido dentro de una instancia qorpa *sobre Ubuntu base*, con `nesting` y la jaula puesta: | evidencia | fuera de pressure-vessel | dentro | |---|---|---| | `/etc/os-release` | `Ubuntu 24.04.3 LTS` | **`Steam Runtime 3 (sniper)`** | | namespace de montaje | `mnt:[4026532468]` | **`mnt:[4026532526]`** | | `/usr/lib/x86_64-linux-gnu` | el de Ubuntu | **675 libs de sniper, `libSDL2-2.0.so.0` incluida** | O sea: **contenedor de Valve anidado dentro de nuestra jaula, con el runtime real adentro.** Es la pregunta que ordenaba D6 y ya no está abierta. **Y la concesión resultó ser de verdad, no un adorno:** con `nesting = false` el mismo comando muere en `bwrap: Creating new namespace failed: Operation not permitted`. Una concesión que no se puede apagar no es una concesión. Tres detalles que sólo salen corriéndolo: pressure-vessel avisa `Cannot determine ld.so for i386-linux-gnu` porque Ubuntu base no trae multilib —para juegos la base es Arch, que es justamente por lo que Arch está en la terna—; `ldd --version` sigue diciendo la glibc de la instancia y **está bien**, porque pressure-vessel importa la libc del host cuando es más nueva que la del runtime, así que la prueba de que el runtime entró es `os-release`, no `ldd`; y no hay ICD de Vulkan porque no se concedió `/dev/dri` — la jaula no regala dispositivos. **Lo que sigue faltando, y ahora es sólo esto:** un juego real dibujando, que pide sesión gráfica y credenciales. Ninguna de las dos es una pregunta de diseño. 7. ✅ **HECHO 2026-09-03.** `takana qorpa prune` + el renglón en [SDD 20](../20-catalogo-publicable-y-completa.md#imágenes-ajenas-qorpa-fuera-del-catálogo-y-por-escrito). Poda restos de pulls a medias, imágenes que ninguna instancia usa (se re-traen por digest: es la propiedad que da el pin) y, con `--upper`, las capas mutables. **Sin `--yes` es un simulacro**, y tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`, porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar. 8. ✅ **HECHO 2026-09-03.** Proxy filtrante de Wayland. Ver D11. ## Nombre — ADOPTADO 2026-09-03 El subsistema se llama **`qorpa`** — *huésped alojado*: alguien que entra a la casa, se le dan habitaciones concretas y no las demás. Elegido por el autor del repo; entra en la familia (harkaq, yupana, arje, wawafs, kikin, churay, mirada, minga). Consecuencias de nombre, ya aplicadas arriba: la frontera es `takana qorpa {…}`, el espacio de nombres es `/var/lib/hammer/qorpa/{imagenes,instancias}/` —uno solo, para que la poda de §NO-resuelve 5 tenga un único árbol que barrer— y el adjetivo del grafo sigue siendo `ajeno` (clase de nodo), porque describe la **procedencia** del nodo, no el subsistema que lo aloja.