645 líneas. Los ADR entran porque en este repo SON documentos vivos, no registros inmutables: el 0013 tiene 5 commits, el 0009 dos. Eso se comprobó antes de decidir, no se asumió por convención general. EXCLUIDOS por ser REGISTRO o generado: docs/evidencia/ (6), el HANDOFF de la noche de KDE (1) y docs/state/ (24, se regenera solo). Reescribir un comando dentro de una evidencia la falsifica. Y el ADR 0016 se excluye de todo barrido, con un aviso adentro para el próximo que barra: habla SOBRE el renombre, así que necesita seguir diciendo 'hammer'. El barrido se lo llevó puesto y lo dejó titulado 'Renombre del sistema: takana → takana'; revertido. Congelados, verificados uno por uno con controles: /opt/hammer, /var/lib/hammer, /usr/bin/hammer, /mnt/vvv/hammer, la URL de gitea, hammer-farm.service, hammer-live-install.sh, BRIEFING-hammer.md, hammerd, hammer-recover y HAMMER_LIVE.
44 KiB
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/<sha256>/,/var/lib/hammer/qorpa/instances/<id>/,instance.toml(manifiesto), clase de nodoajenoenbuild-state.py. - Superficie: verbos en inglés, mensajes en castellano —
CLAUDE.mdregla 4. Este ADR nació proponiendotraer/crear/correry se corrigió el 2026-09-03; el nombreqorpasí es quechua. - Continúa: SDD 04 (overlay), SDD 16 (harkaq), ADR 0004 (takana no usa nix), SDD 20 (catálogo publicable).
- Absorbe:
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:
- 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.
- 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.
- 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 + crates/hammer-overlay |
overlayfs, takana try |
| política de grano fino en el kernel | SDD 16 | 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/<sha256>/ imagen — INMUTABLE, verificada por digest
/var/lib/hammer/qorpa/instances/<id>/
├── 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):
# /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 <id>reconstruye la instancia desde el manifiesto. Elupperes 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.shno toca/var/lib/hammer/qorpa/instances/*/uppery eso es correcto, no un olvido. - La poda tiene una regla trivial: un
uppersiempre 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 <id> 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 —
catal 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.jsonse 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ó consealed_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 §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: 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 <url> --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, 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 <hash>, 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 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 <id> --evidence |
seal_image |
lo único del eje fs que el namespace no da: congelar /usr, /bin, /lib, /opt aunque adentro seas root, para que un exploit no persista en el upper |
El canal de evidencia, cableado (2026-09-03). takana qorpa run <id> --evidence levanta el
lector (harkaq-audit) en el HOST —el audit no está namespeceado y los registros cruzan— antes de
que arranque la instancia, y al terminar imprime uno de tres estados. Los tres se verificaron a
mano y quedan como guardián en scripts/qorpa/evidence-probe.sh:
| estado | qué significa | comprobado con |
|---|---|---|
| HERMÉTICO | 0 denegaciones y el canario las respalda | una instancia que no toca nada prohibido |
| IMPURO | qué intentó y no pudo — el diagnóstico, no el fallo | touch /usr/INTRUSO; mkdir /opt/INTRUSO → fs.make_reg · /usr, fs.make_dir · /opt |
| SIN EVIDENCIA | no se pudo saber. No es «limpio» | quitándole las capabilities al lector |
El canario es lo que hace que «cero denegaciones» valga algo. Es un fichero en un sitio que la
política no alcanza; al leerlo, el kernel emite una denegación que revela el domain= de ESE
dominio Landlock —un número que desde fuera no se puede adivinar—. Sin él, el lector no sabe cuáles
de todas las denegaciones del sistema son de esta instancia, y denials=[] sería el instrumento
callado en vez de una instancia limpia.
Dos condiciones estructurales, y --evidence FALLA en vez de dar un veredicto vacío si falta
alguna: con nesting no hay dominio Landlock (conflicto 1, acá abajo) ⇒ o anidás o auditás; y
sin seal_image la política es rw /, o sea que no hay nada denegable ⇒ un veredicto limpio
sería cierto por construcción y no por mérito. Ese acoplamiento no es un defecto de la
implementación: la evidencia sólo existe donde algo puede ser negado.
Y una que salió midiendo el propio instrumento: sin CAP_AUDIT_READ el kernel responde que
no (NLMSG_ERROR/EPERM) y el lector ignoraba esa respuesta esperando una que no llegaría — 8 s
por consulta, 16 s en su compuerta. Como qorpa run lo despierta al terminar, moría por señal
dentro de la compuerta, sin emitir nada, y el veredicto quedaba vacío: un «no» tardío se parecía
demasiado a un cuelgue. Ahora el NLMSG_ERROR se atiende y el lector dice su motivo en 2 s.
Conflicto 1 — Landlock y los contenedores anidados son incompatibles hoy. Medido: con un dominio
Landlock activo, mount falla con EACCES aunque seccomp lo permita; el kernel no admite montajes
nuevos bajo un dominio, porque escaparían de sus reglas por-ruta. Es decir: pressure-vessel no
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 — , RESUELTO el mismo día. Con el userns de un
solo id que crea bwrap, root adentro y anidar se pelean--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.
-
El socket de Wayland —
el agujeroCERRADO 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 —screencopycaptura la pantalla,virtual_keyboardsintetiza teclas,data_controllee el portapapeles sin que nadie copie ylayer_shelldibuja encima de todo. Sigue disponible conwayland_raw, apagado por defecto y avisando a gritos. -
UID mapping —
el muroRESUELTO 2026-09-03. Este punto ya no describe un hueco; queda por lo que enseñó. La instancia veíauid_map: 0 1001 1ysetgroups: deny, y eso rompíaapt(no podía bajar a_apt),pacman(no podía chownear su descarga aalpm) y el userns anidado de pressure-vessel. Tres síntomas, una causa.La cura entera —
takana qorpa runla aplica sola cuando puede— tiene tres partes, y ninguna sobra:pieza por qué setcap cap_setuid+ep newuidmap(ycap_setgidennewgidmap)el binario está en el corpus ( recipes/shadow.toml) pero sin provisionar; sin la capability no puede escribir el mapacrear el userns nosotros, mapear el rango de /etc/subuidy pasárselo a bwrap con--userns FDbwrap crea el suyo con un solo id a propósito y nunca llama a newuidmap: el trabajo es de quien lo invocadevolver 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 estoaptsigue sin poderseteuid(42)— un síntoma que parecía de subuid y no lo eraCon las tres:
uid_mapde 65537 ids,setgroups: allow,aptinstala sin elAPT::Sandbox::User=root,pacmansincroniza conDownloadUser = alpmintacto, y el userns anidado monta conroot = 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_ADMINqueda fuera y cuelga denesting.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 ⇒recreatey 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. -
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
ajenoen el grafo) + un renglón explícito en SDD 20: las imágenes ajenas no entran en el catálogo publicable ni en el reporte de licencias, porque no podemos enumerarlas. -
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.
-
Disco. Un rootfs de Fedora son 1-2 G y no lo alcanzan
store-gcni.dmerge. Necesita poda propia. Este repo ya tuvo tres emergencias de disco; que la poda nazca con el subsistema, no después. -
Red adentro.
dnfla 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. -
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
xwaylanddeja 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 nodowanteddeescritorio-kdese disuelve sin escribir la receta y sin tocar la postura Wayland-only.targets.tomldebe 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/pacmandetrás de un comando único (elpmmde Bedrock): es menos mágico y no miente.
Orden de trabajo propuesto
-
Provisionar subuid y probar
dnf installen una instancia mínima. Es lo primero que falla (§NO-resuelve 2) y define si el resto es fácil o difícil. -
✅ HECHO 2026-09-03.
takana qorpa pull <url> --sha256+ verificación de digest antes de desempacar. El rootfs se ancla por estructura (etc/+usr|bin), con--subdirde escape. -
✅ 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.runentra con--clearenvy con--unshare-netsalvo que se declarenetwork— el entorno del host también es una concesión.Corregido el mismo día, y el bug era una verdad a destiempo: dos
runseguidos sobre la misma instancia y el segundo moría conCan't make overlay mount … Device or resource busy; el tercero andaba. El kernel se niega a propósito a montar dos overlays vivos que compartanupperdir/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. Ahorarunreintenta acotado y, si el overlay sigue tomado, dice la causa («¿hay otrarunviva?») en vez de dejar pasar el mensaje crudo de bwrap. -
✅ HECHO 2026-09-03. Concesiones → política de harkaq.
harkaq-execentra 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íaio_uring,bpf,ptrace,userfaultfd,keyctl,perf_event_open—,no_new_privs, y el canal de evidencia. Ver D9. -
✅ HECHO 2026-09-03. Shims +
.desktopgenerados (export), y claseajenoenbuild-state.py. El.desktopse genera con lista BLANCA de claves —Exec,TryExec,Path,DBusActivatablequedan 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--removeborre exactamente lo generado y no por patrón sobre el~/.local/binde alguien. -
⚠️ PARCIAL 2026-09-03 — hasta donde esta máquina permite, y destapó tres muros. Steam 1.0.0.87 instalado de verdad desde el
multilibde 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/drisí, 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.xzes lo que Steam despliega, y colocado a mano no hace falta comprar nada. Corrido dentro de una instancia qorpa sobre Ubuntu base, connestingy la jaula puesta:evidencia fuera de pressure-vessel dentro /etc/os-releaseUbuntu 24.04.3 LTSSteam Runtime 3 (sniper)namespace de montaje mnt:[4026532468]mnt:[4026532526]/usr/lib/x86_64-linux-gnuel de Ubuntu 675 libs de sniper, libSDL2-2.0.so.0incluidaO 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 = falseel mismo comando muere enbwrap: 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-gnuporque Ubuntu base no trae multilib —para juegos la base es Arch, que es justamente por lo que Arch está en la terna—;ldd --versionsigue 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ó esos-release, noldd; 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.
-
✅ HECHO 2026-09-03.
takana qorpa prune+ el renglón en SDD 20. 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--yeses un simulacro, y tras borrar comprueba que el directorio se fue: la cicatriz destore-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. -
✅ 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.