> 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…
atuq: un guardián para el rootfs del runner — y encontró SIETE sonames más cayendo al lab
La lista de raíces de `atuq-nested.sh` se mantiene A MANO y la jaula monta
`.dev-fs/alpine` como capa de abajo. Esas dos cosas juntas hacen que una raíz que
falte NO se note: el navegador arranca igual, resolviendo contra el lab. Ayer eso
costó `gcc-libs`. La regla del repo es que cada punto ciego se convierte en un
guardián, así que acá está el guardián en vez del parche.
`scripts/test-atuq-rootfs.py` mira el objeto que la jaula monta de verdad —el
directorio HIDRATADO— y no el grafo de recetas, que es lo que ya cubre
`vigia-sonames.py`. Son preguntas distintas: la lista del runner no sale del
grafo, así que el grafo no puede auditarla.
Lo primero que hizo fue encontrar SIETE sonames más que se estaban resolviendo
contra el lab, y no son cosmética:
libexpat.so.1, libzstd.so.1 <- los pide mesa (iris_dri, libEGL, libgbm)
libdbus-1.so.3 <- lo pide pipewire; lo trae `dbus-shared`, no `dbus`
libbz2.so.1 <- freetype
libudev.so.1 <- libspa-alsa
libsndfile.so.1, libncursesw.so.6
Los siete tienen proveedor en el corpus. Agregados a las raíces: el rootfs pasa
de 7 huecos a CERO, y la única excepción que queda es `libc.so`, que va en una
lista explícita porque ningún artefacto lo provee — las imágenes lo copian del
devfs. Si algún día hay receta que lo provea, esa lista se achica y el guardián
se vuelve más estricto solo.
CONTROL NEGATIVO incluido, que sin él esto no probaría nada:
`--negative-control` esconde libstdc++.so.6 y exige que el guardián lo cace.
Corrido: lo caza, y nombra a quién lo pide (atuq, atuq-bin).
Y `scripts/test-atuq-ruteo.py` vuelve a pasar entero contra el rootfs completo,
o sea que el ruteo por contenedor está probado ahora sobre un rootfs que no le
pide nada al lab salvo el intérprete.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UgNtJEFetMYbXax5dVUjZZ
diff --git a/scripts/atuq-nested.sh b/scripts/atuq-nested.sh
index 89e8dc5a..c93f76c1 100755
--- a/scripts/atuq-nested.sh
+++ b/scripts/atuq-nested.sh
@@ -39,6 +39,7 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep
libffi-shared zlib-shared harfbuzz-shared fribidi freetype-shared fontconfig-shared pixman libpng-shared
libjpeg-turbo-shared wayland libxkbcommon mesa libdrm dbus pipewire pulseaudio alsa-lib
libxml2-shared dejavu-fonts
+ bzip2-shared expat-shared ncurses-shared libsndfile libudev-zero zstd dbus-shared
xkeyboard-config grim"
# ⚠ `gcc-libs` FALTABA, y su ausencia hacía MENTIR a esta prueba entera (2026-09-07). `atuq` declara
# `NEEDED libstdc++.so.6` y `libgcc_s.so.1` —lo hereda de firefox, que va con clang++ y la libstdc++
@@ -49,6 +50,14 @@ RAICES="atuq gcc-libs gtk3 atk gdk-pixbuf-shared pango-shared cairo-shared libep
# sonames da CERO—, lo que estaba mal era ESTA lista, o sea el instrumento y no el artefacto.
# Se nota a simple vista porque los dos ficheros no se parecen: el del corpus pesa 20.850.968 bytes
# y el del lab 2.804.104. Si algún día hay duda de cuál se cargó, se mira el tamaño.
+#
+# La segunda tanda salió del guardián que nació de ese fallo, `scripts/test-atuq-rootfs.py`, que
+# mira el rootfs HIDRATADO en vez del grafo: `bzip2-shared expat-shared ncurses-shared libsndfile
+# libudev-zero zstd dbus-shared` — siete sonames más que también se resolvían contra el lab.
+# `libexpat` y `libzstd` los pide mesa (iris_dri, libEGL, libgbm), o sea el camino de pintado;
+# `libdbus-1.so.3` lo pide pipewire y NO lo trae `dbus` sino `dbus-shared`, el mismo cuadro de las
+# variantes `-shared` de gtk3. Mantener esta lista a mano es lo que falla: por eso ahora hay un
+# guardián que la audita en vez de confiar en que alguien se acuerde.
# `grim` no lo necesita atuq: lo necesita QUIEN LO MIRA. Con `CAPTURA=<fichero.png>` este script
# saca la pantalla real desde dentro de la jaula, contra el mismo socket wayland. Es lo que separó
# «el proceso vive» de «la ventana muestra lo que queremos»: durante horas dije «mirá tu pantalla»
diff --git a/scripts/test-atuq-rootfs.py b/scripts/test-atuq-rootfs.py
new file mode 100755
index 00000000..1409d53b
--- /dev/null
+++ b/scripts/test-atuq-rootfs.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""¿El rootfs de `atuq-nested.sh` resuelve TODOS los NEEDED, o alguno se cae al LAB?
+
+ python3 scripts/test-atuq-rootfs.py # sale 0 si el rootfs se basta solo
+ python3 scripts/test-atuq-rootfs.py --negative-control # esconde una lib y exige que se note
+
+POR QUÉ EXISTE. `scripts/atuq-nested.sh` hidrata una **lista de raíces escrita a mano**, y la jaula
+monta `.dev-fs/alpine` como capa de abajo. Esas dos cosas juntas hacen que una raíz que falte no se
+note: el navegador arranca igual, resolviendo la librería contra el LAB. Pasó de verdad
+(2026-09-07): faltaba `gcc-libs` y durante dos días todas las corridas de «atuq corre» cargaron la
+`libstdc++.so.6` del lab —2.804.104 bytes— en vez de la del corpus —20.850.968—. **El artefacto
+estaba sano; el instrumento era ciego**, que es la forma que más cuesta ver porque el resultado es
+«todo bien».
+
+El vigía de sonames (`scripts/vigia-sonames.py`) cubre lo mismo pero sobre la CLAUSURA de un perfil,
+que es un grafo de recetas. Esto mira el otro objeto: **el directorio hidratado que la jaula monta
+de verdad**. Son preguntas distintas y la segunda es la que corresponde al runner, porque su lista
+no sale del grafo.
+
+LO QUE SÍ VIENE DEL LAB, A PROPÓSITO. El intérprete `/lib/ld-musl-x86_64.so.1` y `libc.so` no los
+provee NINGÚN artefacto del corpus —`musl` sella cabeceras y estáticas— y las imágenes lo copian del
+devfs (`scripts/mirada-usb.sh`). Así que van en una lista explícita de excepciones: si algún día
+aparece una receta que los provea, esta lista se achica y el guardián se vuelve más estricto solo.
+"""
+import os
+import re
+import subprocess
+import sys
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+RFS = os.environ.get("RFS", "/mnt/cosecha/escritorios/atuq-rootfs")
+STORE = os.environ.get("HAMMER_STORE", os.path.join(ROOT, "store"))
+HAMMER = os.path.join(ROOT, "target/release/hammer")
+
+# Lo que el corpus NO provee y las imágenes copian del devfs. Documentado, no olvidado.
+DEL_LAB = {"libc.so", "libc.musl-x86_64.so.1", "ld-musl-x86_64.so.1"}
+
+
+def fatal(msg):
+ print(f"✗ {msg}", file=sys.stderr)
+ raise SystemExit(1)
+
+
+def artefacto(receta, nombre):
+ r = subprocess.run([HAMMER, "--store", STORE, "hash", f"recipes/{receta}.toml"],
+ cwd=ROOT, capture_output=True, text=True)
+ if r.returncode != 0:
+ fatal(f"no pude hashear {receta}: {r.stderr.strip()}")
+ h = r.stdout.strip().splitlines()[-1].removeprefix("b3:")
+ d = os.path.join(STORE, f"{h}-{nombre}")
+ if not os.path.isdir(d):
+ fatal(f"{nombre} b3:{h[:8]} no está sellado")
+ return d
+
+
+def elfs(raiz):
+ """Los ficheros ELF de un árbol, por número mágico y no por extensión.
+
+ Por nombre no alcanza: hay `.so` que son scripts de linker y ejecutables sin extensión."""
+ for base, _, ficheros in os.walk(raiz):
+ for f in ficheros:
+ p = os.path.join(base, f)
+ if os.path.islink(p) or not os.path.isfile(p):
+ continue
+ try:
+ with open(p, "rb") as fh:
+ if fh.read(4) != b"\x7fELF":
+ continue
+ except OSError:
+ continue
+ yield p
+
+
+NEEDED = re.compile(r"\(NEEDED\).*\[([^\]]+)\]")
+SONAME = re.compile(r"\(SONAME\).*\[([^\]]+)\]")
+
+
+def dinamico(paths):
+ """(pide, provee) leídos con readelf en tandas."""
+ pide, provee = {}, set()
+ for i in range(0, len(paths), 80):
+ lote = paths[i:i + 80]
+ r = subprocess.run(["readelf", "-d"] + lote, capture_output=True, text=True)
+ actual = None
+ for linea in r.stdout.splitlines():
+ if linea.startswith("File: "):
+ actual = linea[6:].strip()
+ m = NEEDED.search(linea)
+ if m:
+ pide.setdefault(m.group(1), set()).add(actual or lote[0])
+ m = SONAME.search(linea)
+ if m:
+ provee.add(m.group(1))
+ if len(lote) == 1:
+ # readelf no imprime «File:» cuando recibe UNO solo: sin esto, el único fichero de la
+ # última tanda quedaría sin dueño y el informe no se podría triar.
+ for k in pide:
+ pide[k].discard(None)
+ return pide, provee
+
+
+def main():
+ negativo = "--negative-control" in sys.argv[1:]
+ if not os.path.isdir(RFS):
+ fatal(f"no está el rootfs en {RFS} — corré antes `scripts/atuq-nested.sh`")
+ atuq = artefacto("atuq", "atuq")
+
+ # El appdir de atuq trae DECENAS de `.so` propias (libxul, libnspr4…): proveen y piden a la vez.
+ paths = sorted(set(list(elfs(RFS)) + list(elfs(atuq))))
+ print(f"rootfs {RFS}\nartefacto {os.path.basename(atuq)}\nELF mirados: {len(paths)}")
+ pide, provee = dinamico(paths)
+
+ # Un fichero puede proveer por su SONAME o por su NOMBRE: `libgcc_s.so.1` no declara SONAME en
+ # algunas construcciones y se resuelve por el nombre del fichero. Se aceptan los dos.
+ nombres = set()
+ for base in (RFS, atuq):
+ for b, _, fs in os.walk(base):
+ nombres.update(fs)
+ provee |= nombres
+
+ if negativo:
+ # SABOTAJE: se hace de cuenta que el rootfs no trae libstdc++. Si el guardián no lo caza,
+ # es que no está mirando — que es justo el modo de fallo que este fichero existe para negar.
+ provee -= {"libstdc++.so.6", "libstdc++.so.6.0.34"}
+ print("== MODO CONTROL NEGATIVO: se esconde libstdc++.so.6 y se exige que el guardián lo cace")
+
+ faltan = {s: v for s, v in pide.items() if s not in provee and s not in DEL_LAB}
+ for s, quien in sorted(faltan.items()):
+ muestra = sorted(os.path.basename(q) for q in quien if q)[:3]
+ print(f" ✗ {s} ← lo piden: {', '.join(muestra)}")
+ delegados = sorted(s for s in pide if s in DEL_LAB)
+ print(f"del lab por diseño ({len(delegados)}): {', '.join(delegados) or 'ninguno'}")
+ print(f"sonames pedidos: {len(pide)} · sin proveedor en el rootfs: {len(faltan)}")
+
+ if negativo:
+ ok = "libstdc++.so.6" in faltan
+ print("RESULTADO:", "el guardián CAZA la librería escondida" if ok
+ else "NO SIRVE: no vio el sabotaje")
+ return 0 if ok else 1
+ print("RESULTADO:", "el rootfs se basta solo" if not faltan
+ else "hay NEEDED que hoy resuelve el LAB — el runner miente")
+ return 0 if not faltan else 1
+
+
+sys.exit(main())