gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Sutichaykuna

harkaq: el runtime base DERIVADO (§4.3) + el diagnóstico sobre un configure real (§4.4)

harkaq-base-closure.py: deriva el cierre dinámico del runtime base. Es D1
aplicado a la base — mantenerla a mano sería el "alguien mantiene un perfil de
permisos" que D1 dice que mata a todos los sandboxes. No usa ldd (resolvería
contra el HOST, no contra el rootfs Alpine): lee los DT_NEEDED del ELF y
resuelve dentro del rootfs por las rutas de musl. Emite symlink Y destino: el
kernel denuncia el fichero real (libz.so.1.3.2) pero el build abre por el nombre
corto (libz.so.1).

VALIDACIÓN: el cierre derivado de {sh,busybox,bash,coreutils,env} reproduce
EXACTAMENTE las 7 librerías que las rondas 2-3 habían descubierto a mano, en una
sola pasada, y además caza los symlinks y libc.musl-x86_64.so.1 que se habían
escapado. El método es computable, no adivinado.

§4.4 — con el entorno del Sandbox real replicado (CC="zig cc -mcpu=baseline",
AR, SOURCE_DATE_EPOCH, LC_ALL=C), configure llega mucho más lejos: 205
denegaciones del kernel, y clasificadas:

  esperadas (170): /usr/bin/gcc, /usr/bin/ldd        ← sondas de compilador
  DEUDA (34) en 8 paths:
      /usr/bin/{ld,nm,objdump,strip}                 ← binutils de ALPINE
      /usr/bin/{make,getconf}
      /usr/lib/gcc/x86_64-alpine-linux-musl          ← libdir del gcc de ALPINE
      /opt                                           ← bug de política

Es la tesis del §0 hecha dato: un configure que se creía hermético va a buscar
los binutils y el libdir de gcc de Alpine. Y la clasificación es lo que lo hace
legible — sin ella son 205 denegaciones planas y el hallazgo queda enterrado;
con ella son 8 paths accionables. Coincide con los swaps del selfhost-verify y
con la campaña "matar gcc" (recipes/binutils.toml existe justo porque zig provee
as/ld/ar pero el resto se toma de Alpine).

Bug de política encontrado por el propio experimento: /opt sale como deuda
porque la política concede `ro /opt/zig` pero no deja LISTAR el padre. Regla
general: todo directorio concedido necesita `list` en sus ancestros, o el escaneo
del padre es un falso positivo. harkaq-policy.sh ya lo hace para la clausura de
las deps; falta para las superficies de contrato.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

ed614e6898f57469aa1d06022a1e509ff421319c · ruwaq sergio · (3 killa ñawpaq) · tayta 77317a729 · kay commitpi sach'ata qhaway

Semántico sayk'uy · 5

scripts/harkaq/harkaq-base-closure.py

4 qillqakuna · +191 −2

diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md
index f927c717..00e0cedb 100644
--- a/docs/16-harkaq-jaula.md
+++ b/docs/16-harkaq-jaula.md
@@ -461,8 +461,45 @@ no 5. Pero las tres cosas que importan salieron bien:
    plausible.
 3. **El runtime base no es una lista de binarios: es su CIERRE de `.so`.** Conceder `/bin/bash`
    arrastra `libreadline`+`libncursesw`; conceder `/bin/coreutils` arrastra `libacl`+`libattr`.
-   Eso es *computable* (`ldd`), no adivinable ⇒ `harkaq-policy` debe derivar también el cierre
-   dinámico del runtime base, igual que deriva la clausura de las deps. Misma idea, otro origen.
+   Eso es *computable*, no adivinable ⇒ **ya está derivado** (`harkaq-base-closure.py`). Es D1
+   aplicado al runtime base: mantenerlo a mano sería exactamente el "alguien mantiene un perfil
+   de permisos" que D1 dice que mata a todos los sandboxes.
+
+   No usa `ldd` (resolvería contra el host, no contra el rootfs Alpine): lee los `DT_NEEDED` del
+   ELF y resuelve dentro del rootfs por las rutas de musl. **Validación:** el cierre derivado de
+   `{sh, busybox, bash, coreutils, env}` reproduce **exactamente** las 7 librerías que las rondas
+   2 y 3 habían descubierto a mano, en una sola pasada, y además caza los symlinks y
+   `libc.musl-x86_64.so.1` que se habían escapado. Emite symlink **y** destino: el kernel denuncia
+   el fichero real (`libz.so.1.3.2`, no `libz.so.1`), pero el build abre por el nombre corto.
+
+### 4.4 El diagnóstico sobre un `configure` real, con el entorno fiel
+
+Con el entorno del sandbox real replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`,
+`LC_ALL=C`…), `configure` llega mucho más lejos y el diagnóstico se vuelve el que el proyecto
+prometía. **205 denegaciones del kernel**, y tras clasificar:
+
+```
+esperadas (170):  /usr/bin/gcc, /usr/bin/ldd          ← sondas de compilador
+DEUDA (34) en 8 paths:
+    /usr/bin/ld   /usr/bin/nm   /usr/bin/objdump   /usr/bin/strip    ← binutils de ALPINE
+    /usr/bin/make   /usr/bin/getconf
+    /usr/lib/gcc/x86_64-alpine-linux-musl                            ← libdir del gcc de ALPINE
+    /opt                                                             ← (bug de política, ver abajo)
+```
+
+**Esto es la tesis del §0 hecha dato.** Un `configure` que se creía hermético está yendo a buscar
+los binutils y el libdir de gcc de Alpine. Y la clasificación es lo que lo hace legible: sin ella
+son 205 denegaciones planas y el hallazgo queda enterrado; con ella son **8 paths accionables**.
+
+Conecta con dos frentes abiertos, y en ambos la lista coincide: los swaps del `selfhost-verify`
+(`SWAP_COREUTILS`, las herramientas en el path) y la campaña *matar gcc* (`recipes/binutils.toml`
+existe justamente porque zig provee `as/ld/ar` pero el resto se toma de Alpine).
+
+**Bug de política encontrado por el propio experimento:** `/opt` aparece como deuda porque la
+política concede `ro /opt/zig` pero no deja **listar** el padre. Regla general: **todo directorio
+concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso positivo.
+`harkaq-policy.sh` ya lo hace para la clausura de las deps; falta hacerlo para las superficies de
+contrato.
 
 **Y el residuo es todo significativo, ninguno ruido:**
 
diff --git a/scripts/harkaq/README.md b/scripts/harkaq/README.md
index 02bf652d..09c6c3ab 100644
--- a/scripts/harkaq/README.md
+++ b/scripts/harkaq/README.md
@@ -233,3 +233,36 @@ Las expectativas viajan en la política como `# expect <path>` — una sola fuen
 `harkaq-exec` las ignora como comentario. `SinEvidencia` manda sobre todo: si el lector no es
 confiable no se clasifica nada, porque reinterpretarlo sería el falso `Hermetico` que el canario
 existe para impedir.
+
+### `harkaq-base-closure.py` — el runtime base, derivado
+
+```sh
+scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine /bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env
+```
+
+Emite `ro <path>` para cada binario **y todo su cierre transitivo de `.so`**, en rutas del
+sandbox. No usa `ldd` (resolvería contra el host): lee los `DT_NEEDED` del ELF y resuelve dentro
+del rootfs por las rutas de musl. Emite symlink **y** destino — el kernel denuncia el fichero real
+(`libz.so.1.3.2`) pero el build abre por el nombre corto (`libz.so.1`).
+
+**Validación:** reproduce exactamente las 7 librerías que las rondas 2 y 3 de §4.3 habían
+descubierto a mano, en una pasada, y además caza los symlinks y `libc.musl-x86_64.so.1` que se
+habían escapado.
+
+### El diagnóstico sobre un `configure` real (§4.4)
+
+Con el entorno del sandbox replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`,
+`LC_ALL=C`), 205 denegaciones del kernel se clasifican en:
+
+```
+esperadas (170): /usr/bin/gcc, /usr/bin/ldd
+DEUDA (34) en 8 paths:
+    /usr/bin/{ld,nm,objdump,strip}   ← binutils de ALPINE
+    /usr/bin/{make,getconf}
+    /usr/lib/gcc/x86_64-alpine-linux-musl
+    /opt                             ← bug de política: falta `list` en los ancestros
+```
+
+Sin clasificar son 205 líneas planas; clasificado, 8 paths accionables. **Regla que salió de acá:
+todo directorio concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso
+positivo.
diff --git a/scripts/harkaq/harkaq-base-closure.py b/scripts/harkaq/harkaq-base-closure.py
new file mode 100755
index 00000000..d4a3c75f
--- /dev/null
+++ b/scripts/harkaq/harkaq-base-closure.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+"""harkaq-base-closure — deriva el cierre dinámico del runtime base (SDD 16 §4.3).
+
+    harkaq-base-closure.py <rootfs> <binario-dentro> [<binario-dentro>...]
+
+Emite reglas `ro <path>` para cada binario y **todo su cierre transitivo de `.so`**, en las rutas
+tal como se ven DENTRO del sandbox.
+
+Por qué existe: §4.3 midió que el runtime base no es una lista de binarios sino su cierre.
+Conceder /bin/bash arrastra libreadline+libncursesw; /bin/coreutils arrastra libacl+libattr. Se
+descubrió a mano, en rondas, mirando denegaciones — pero es **computable**, así que mantenerlo a
+mano sería exactamente el "alguien mantiene un perfil de permisos" que D1 dice que mata a todos
+los sandboxes. Es D1 aplicado al runtime base: se deriva, no se escribe.
+
+No se usa `ldd`: resolvería contra el host, no contra el rootfs Alpine. Se leen los DT_NEEDED del
+ELF (readelf funciona sobre cualquier ELF, sea cual sea su libc) y se resuelve dentro del rootfs.
+"""
+import os
+import subprocess
+import sys
+
+# Rutas de búsqueda de ld-musl. El default de musl es /lib:/usr/local/lib:/usr/lib; Alpine no
+# suele traer ld-musl-*.path, pero si estuviera, mandaría — se lee si existe.
+SEARCH_POR_DEFECTO = ["/lib", "/usr/local/lib", "/usr/lib"]
+
+
+def rutas_de_busqueda(rootfs):
+    conf = os.path.join(rootfs, "etc/ld-musl-x86_64.path")
+    if os.path.exists(conf):
+        with open(conf) as f:
+            rutas = [l.strip() for l in f if l.strip()]
+        if rutas:
+            return rutas
+    return SEARCH_POR_DEFECTO
+
+
+def needed(ruta_host):
+    """DT_NEEDED + el intérprete. Devuelve ([sonames], interp|None)."""
+    try:
+        out = subprocess.run(["readelf", "-dl", ruta_host], capture_output=True, text=True).stdout
+    except FileNotFoundError:
+        print("hace falta readelf (binutils)", file=sys.stderr)
+        sys.exit(2)
+    sonames, interp = [], None
+    for linea in out.splitlines():
+        if "(NEEDED)" in linea and "Shared library:" in linea:
+            sonames.append(linea.split("[")[1].split("]")[0])
+        elif "program interpreter:" in linea:
+            interp = linea.split("program interpreter:")[1].strip().rstrip("]")
+    return sonames, interp
+
+
+def resolver(rootfs, soname, search):
+    for d in search:
+        cand = os.path.join(rootfs, d.lstrip("/"), soname)
+        if os.path.exists(cand):
+            return cand
+    return None
+
+
+def dentro(rootfs, ruta_host):
+    """Path del sandbox = path del host sin el prefijo del rootfs."""
+    return "/" + os.path.relpath(ruta_host, rootfs)
+
+
+def main():
+    if len(sys.argv) < 3:
+        print(__doc__.split("\n")[2].strip(), file=sys.stderr)
+        return 2
+    rootfs = os.path.realpath(sys.argv[1])
+    search = rutas_de_busqueda(rootfs)
+
+    pendientes = list(sys.argv[2:])
+    vistos, salida = set(), set()
+
+    while pendientes:
+        p = pendientes.pop()
+        host = os.path.join(rootfs, p.lstrip("/"))
+        if not os.path.exists(host):
+            print(f"# AVISO: no existe en el rootfs: {p}", file=sys.stderr)
+            continue
+        # Resolver symlinks: /bin/sh → /bin/busybox, /usr/lib/libz.so.1 → libz.so.1.3.2. El
+        # kernel denuncia el fichero REAL (medido: `libz.so.1.3.2`, no el symlink), así que la
+        # regla tiene que caer sobre el destino. Emitimos AMBOS: el symlink porque es el nombre
+        # por el que se abre, y el destino porque es el que Landlock evalúa.
+        real = os.path.realpath(host)
+        for h in {host, real}:
+            if h.startswith(rootfs) and os.path.exists(h):
+                salida.add(dentro(rootfs, h))
+        if real in vistos:
+            continue
+        vistos.add(real)
+
+        sonames, interp = needed(real)
+        if interp:
+            pendientes.append(interp)
+        for so in sonames:
+            hallado = resolver(rootfs, so, search)
+            if hallado:
+                pendientes.append(dentro(rootfs, hallado))
+            else:
+                # Un soname irresoluble NO se calla: o el rootfs está roto, o la búsqueda está
+                # mal, y en cualquier caso la política saldría más chica que el runtime real ⇒
+                # falsos positivos, que es lo que mata a estos proyectos (§7).
+                print(f"# AVISO: soname sin resolver: {so} (de {dentro(rootfs, real)})",
+                      file=sys.stderr)
+
+    print(f"# runtime base: cierre dinámico de {len(sys.argv) - 2} binario(s) → {len(salida)} rutas")
+    for r in sorted(salida):
+        print(f"ro {r}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/scripts/harkaq/q2-runtime-base.sh b/scripts/harkaq/q2-runtime-base.sh
index 963e6203..f42f426b 100755
--- a/scripts/harkaq/q2-runtime-base.sh
+++ b/scripts/harkaq/q2-runtime-base.sh
@@ -118,6 +118,10 @@ bwrap --overlay-src "$ROOTFS" $(for d in $DEPS; do printf -- "--overlay-src %s "
       --chdir /src --unshare-all --die-with-parent \
       --setenv PATH /opt/zig:/usr/bin:/bin \
       --setenv HOME /tmp \
+      --setenv CC "zig cc -mcpu=baseline" \
+      --setenv CXX "zig c++ -mcpu=baseline" \
+      --setenv AR "zig ar" \
+      --setenv SOURCE_DATE_EPOCH 1 --setenv LC_ALL C --setenv LANG C --setenv TZ UTC \
       /tmp/harkaq-exec --policy /tmp/harkaq.policy -- /bin/sh -c "$CMD"
 RC=$?
 set -e