Files
takana/scripts/harkaq
SergioandClaude Opus 5 1d9ddcee37 qorpa D10: Steam en la mano, y los tres muros que sólo se ven así
Paso 6 del ADR 0015, PARCIAL y dicho como parcial. Steam 1.0.0.87 instalado de
verdad desde el multilib de Arch —las libs de 32 bits que la F2 del plan de
juegos daba por «una campaña entera»—, su cliente i386 bajado y desempacado por
el bootstrap de Valve junto con el Steam Runtime, y el BWRAP ANIDADO VERIFICADO
EXPLÍCITAMENTE (24 montajes propios dentro de la instancia), que es lo que este
paso pedía. Lo que NO se pudo: la máquina no tiene sesión gráfica, y el runtime
sniper sólo se baja al instalar un juego, que exige credenciales ⇒
pressure-vessel con un juego real sigue sin ejercitarse. Se dice, no se insinúa.

Tres muros, ninguno en el ADR:

1. LA JAULA MATABA TODOS LOS BINARIOS DE 32 BITS. El filtro seccomp comprueba
   arch == x86_64 y MATA lo que no lo sea; para un build es la defensa clásica y
   correcta, para el montón B es fatal porque el cliente de Steam es un ELF i386.
   El síntoma fue `ldd: exited with unknown exit code (159)` = 128+31 = SIGSYS,
   que no se parece en nada a la causa. La salida no es aflojar el check sino
   darle a i386 su propia tabla con la MISMA política. Los 25 números se
   verificaron uno por uno contra /usr/include/asm/unistd_32.h: 24 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 de memoria y una
   verificada.

2. STEAM SE NIEGA A CORRER COMO ROOT, y cambiar el mapa para evitarlo CORROMPE
   la instancia: un fichero creado bajo un mapa aparece con otro uid bajo el
   otro, así que el useradd de la preparación deja un /home que su propio dueño
   no puede escribir. ⇒ el mapa es parte de la IDENTIDAD de la instancia. La vía
   correcta es la de cualquier runtime de contenedores: un solo mapa y se BAJA de
   privilegio adentro — campo `run_as`, 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 al Steam Runtime sin poder crear su
   temporal. Se lee como un aviso menor hasta que algo deja de andar sin decir
   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 — la forma exacta
en que Valve prueba Proton.

1 test nuevo (run_as resuelve uid/gid/home del passwd de la imagen, y un run_as
inexistente NO cae a root). 48/48.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2
2026-09-03 12:01:32 +00:00
..

harkaq — banco de pruebas y piezas

Las piezas (Fase 1)

Fichero Qué es Dónde corre
harkaq-audit.c el lector de evidencia; emite el Verdict JSON host, junto a hammerd; necesita CAP_AUDIT_READ
harkaq-exec.c aplica la política y ejecuta el builder dentro de bwrap, estático (rootfs musl)
harkaq-run.sh encadena lector + bwrap + exec + canario host
mkdir -p ~/.cache/harkaq
gcc -O1 -Wall -o ~/.cache/harkaq/harkaq-audit harkaq-audit.c
gcc -O1 -Wall -static -o ~/.cache/harkaq/harkaq-exec harkaq-exec.c
sudo setcap cap_audit_read,cap_audit_control+ep ~/.cache/harkaq/harkaq-audit   # 1 vez

printf 'ro /usr\nro /bin\nro /lib\nro /lib64\nro /etc\n' > /tmp/p.txt
./harkaq-run.sh /tmp/p.txt '/bin/echo hola'

setcap se pierde al recompilar el lector: hay que repetirlo. Es a propósito que el privilegio viva en un helper chico y no en hammerd (§8 Q1c): CAP_AUDIT_* es superficie de más para el daemon entero.

El hito de Fase 1 (2026-07-15)

limpio  {"estado":"Hermetico","canario_visto":true,"contador_kernel":1,"denials":[]}
impuro  {"estado":"Impuro","canario_visto":true,"contador_kernel":2,
         "denials":[{"blockers":"fs.read_file","path":"/home/sergio/hammer/README.md",}]}

Canario visto en ambos (la evidencia está ganada, no supuesta), contador del kernel coherente en ambos, y el impuro nombra el path exacto. Falta el acto real: derivar la política de la clausura de una receta de verdad (harkaq-policy) y contrastar una receta de-Alpinizada contra una que no lo está.

Sutileza: lo que DAC ya bloquea es invisible

Un acceso que los permisos Unix rechazan no genera registro de Landlock — el hook del LSM ni llega a correr. Descubierto probando con /root/.bashrc (drwx------): el escenario "impuro" dio Hermetico porque DAC lo bloqueó antes.

No rompe la afirmación de D2 (si DAC lo bloqueó, el build tampoco lo usó ⇒ Hermetico sigue siendo cierto), pero acota el diagnóstico: harkaq no te dice que el build lo intentó. Para la de-Alpinización da igual — el rootfs Alpine es todo world-readable — pero al escribir tests hay que elegir paths que DAC permita, o el control no controla nada.


Banco de pruebas

Ver docs/16-harkaq-jaula.md. Acá viven los experimentos que sostienen las afirmaciones del SDD: si el doc dice "medido", el que lo midió está en este directorio.

q1-audit.c — el gate del proyecto (Q1, cerrado 2026-07-15)

Responde: ¿llegan los registros AUDIT_LANDLOCK_* a un lector? Todo harkaq cuelga de eso — su producto es la evidencia, no la jaula.

gcc -O1 -Wall -o q1-audit q1-audit.c
for s in same-exec new-exec new-exec-logon; do sudo ./q1-audit $s; done

Necesita root (CAP_AUDIT_READ para leer el multicast, CAP_AUDIT_CONTROL para encender el audit). Enciende audit_enabled=1 globalmente y no lo restaura — reversible, no persiste al reboot (no toca el cmdline), pero deja dmesg más ruidoso mientras tanto.

Resultados en el laptop (ABI 10, kernel 7.2.0-rc3)

Escenario Registros ACCESS Qué prueba
same-exec 2 el kernel loguea por defecto sin execve
new-exec 0 flags por defecto ⇒ ciego tras execve — el caso real de harkaq
new-exec-logon 4 LOG_NEW_EXEC_ON lo arregla

Los registros traen blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369 y el exe= que lo intentó. Análisis completo en docs/16-harkaq-jaula.md §3.4.

Por qué aborta con código 4

Si no puede confirmar audit_enabled=1, aborta en vez de reportar 0 registros. No es paranoia: la primera corrida dio 0 en los tres escenarios y parecía confirmar la hipótesis. Era un bug del instrumento (AUDIT_GET con NLM_F_ACK → el ACK llegaba antes que la respuesta → nunca se encendía el audit). Se detectó sólo porque el control (same-exec) también dio 0, y eso era imposible.

Es el modo de falla de harkaq sobre su propio banco de pruebas: un sistema cuyo producto es la ausencia de algo no falla ruidosamente — dice que todo está bien. De ahí sale D9 (el canario deliberado), y de ahí sale la regla de este directorio: todo experimento lleva un control que debe dar positivo. Un experimento sin control no mide, tranquiliza.

q1b-attrib.c — userns y atribución (Q1b, cerrado 2026-07-15)

Responde: ¿el lector del host ve las denegaciones de adentro de bwrap, y se puede atribuir cada registro a SU build con la granja en paralelo?

gcc -O1 -Wall -o ~/.cache/harkaq/q1b q1b-attrib.c   # NO compilar bajo /tmp: --tmpfs /tmp lo tapa
sudo ~/.cache/harkaq/q1b

Lanza 2 víctimas concurrentes en bwrap --unshare-all (los ns de Sandbox::bwrap_args), cada una con un canario de nonce distinto, y escucha desde el host. Las víctimas bajan a $SUDO_UID: el despliegue real es lector privilegiado + builds sin privilegio.

Resultado: los registros cruzan el userns/pidns/mountns, y el canario con nonce atribuye sin ambigüedad (AAAA=1 BBBB=1, cero cruces). Análisis en docs/16-harkaq-jaula.md §3.5.

Los tres rastrillos de este banco, para el próximo que lo toque

  1. No compilar bajo /tmp: el sandbox hace --tmpfs /tmp y tapa el binario.
  2. El canario necesita un punto de montaje escribible. Con --ro-bind / /, bwrap no puede crear /harkaq-canary-X en la raíz de sólo lectura (el sandbox real usa --tmp-overlay / y no tendría el problema). Va bajo /tmp, y por eso /tmp no entra en la clausura de la víctima.
  3. Root + --unshare-all no atraviesa un home drwx------. bwrap mapea sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear y CAP_DAC_OVERRIDE en un userns sólo vale sobre uids mapeados. De ahí drop_privs().

Por qué también aborta con código 4

Mismo gate que q1-audit, más uno: si alguna víctima no sale con status 0, aborta en vez de contar registros. La corrida que lo motivó imprimió "NADA cruzó ⇒ harkaq-audit no puede vivir en el host: replantear" cuando bwrap ni siquiera había podido ejecutar el binario. Una conclusión arquitectónica falsa, a partir de cero estímulo.

Van tres veces en una sola sesión (NLM_F_ACK, el redirect a /dev/null, el exec) que un cero resultó ser el instrumento y no el kernel. Un veredicto que no verifica su propio estímulo es el falso Hermetico de D9, pero del lado del banco.

harkaq-policy.sh + q2-runtime-base.sh — la clausura y el runtime base

harkaq-policy.sh <dir-dep>... deriva la política de la clausura (D1: no se escribe, se deriva). Enumera fichero a fichero —no por directorio— porque el sandbox funde las deps y Alpine en el mismo /usr (§4.1). Emite list de los directorios para que pkgconf/configure puedan escanear sin poder leer lo no declarado.

q2-runtime-base.sh responde Q2 midiendo, no adivinando: corre un build real en el sandbox real con política = clausura y deja que las denegaciones nombren el runtime base. Se itera con BASE_EXTRA (una línea por regla), y cada línea que se añade ahí debe venir de una denegación que el kernel denunció, nunca de una corazonada — si se define de más, la política concede Alpine entero y la evidencia deja de valer.

export BASE_EXTRA='ro /bin/sh
ro /bin/busybox
ro /lib/ld-musl-x86_64.so.1'
HARKAQ_TIMEOUT=25 scripts/harkaq/q2-runtime-base.sh

Resultado (2026-07-15, contra la dep zlib del store):

estado: Impuro | canario: True | contador kernel: 3
    fs.read_file /usr/bin/env
    fs.read_file /usr/lib/libz.so.1.3.2      ← ¡la zlib de ALPINE, no la dep declarada!

Dos rastrillos más, aprendidos acá

  1. El canario no puede depender de un binario. Con cat $CANARY moría en /bin/cat (applet de busybox fuera de la clausura) y el probe no probaba nada. Ahora usa sólo builtins: read _ < $CANARY || true.
  2. El canario no puede vivir en /tmp. La política concede rw /tmp (es el HOME del build), así que un canario ahí cae DENTRO de la clausura, se lee sin problema y no deniega nada ⇒ SinEvidencia eterno. Va en la raíz, donde la política no alcanza por construcción.

El segundo lo cazó D9 mismo: el lector se negó a certificar (SinEvidencia) en vez de decir Hermetico. El canario funcionando como se diseñó, sobre un bug de quien lo diseñó.

Q2 resuelta: el runtime base son 5 paths (2026-07-15)

MODE=base corre sin ninguna dep declarada: sin clausura que pueda explicarlas, toda denegación es runtime base por definición. Eso aísla la respuesta sin juicio de valor.

export BASE_EXTRA='ro /bin/sh
ro /bin/busybox
ro /lib/ld-musl-x86_64.so.1
ro /usr/bin/env
ro /usr/lib/os-release
rw /dev/null
ro /dev/urandom'
MODE=base scripts/harkaq/q2-runtime-base.sh   # rc=0, sólo queda /usr/bin/gcc (esperada)
MODE=zlib scripts/harkaq/q2-runtime-base.sh   # Impuro: [/usr/lib/libz.so.1.3.2] — deuda pura

Tres categorías, todas medidas (detalle en docs/16-harkaq-jaula.md §4.2):

  • Contrato: /src /out /tmp /dev/null(rw) /dev/urandom /proc /opt/zig — los monta bwrap, no son Alpine.
  • Runtime base Alpine: /bin/sh /bin/busybox /lib/ld-musl /usr/bin/env /usr/lib/os-release. Cinco. El resto de Alpine es deuda genuina.
  • Denegación esperada: /usr/bin/gcczig cc lo sondea y el build sale rc=0 igual. Concederla dejaría a zig usar el gcc de Alpine por detrás. Se deniega y se clasifica (D3).

Ojo con dos: /dev/null es destino de escritura (rw, no ro — con ro quedan fs.write_file colgando), y el piso duro es /bin/shbusyboxld-musl: sin eso ni el canario corre y no hay diagnóstico, sólo SinEvidencia.

¿Aguantan los 5 paths en otra forma de build? No — pero converge (§4.3)

MODE=configure corre un configure de autotools real (work/sources/libgpg-error-*, override con Q2_SRC). Iterando con el mismo método:

Ronda Deuda restante (paths únicos)
1 /bin/bash, /bin/coreutils
2 libacl, libattr, libcrypto, libreadline, libutmps
3 libncursesw, libskarnet
4 /usr/bin/c89, /usr/bin/c99, /usr/bin/ldd, /usr/bin/make

El runtime base es por forma de build (~16 entradas para autotools, no 5), pero converge en 3 rondas y se queda chico ⇒ la métrica de <5% de la Fase 2 sigue siendo plausible.

Lo importante: el runtime base no es una lista de binarios, es su cierre de .so (bash arrastra libreadline+libncursesw; coreutils arrastra libacl+libattr). Es computable con ldd, no adivinable — harkaq-policy debería derivarlo igual que deriva la clausura de las deps.

Y el residuo es todo señal: c89/c99/ldd son sondas de compilador de Alpine (→ esperadas, como gcc), y /usr/bin/make es una decisión de diseño real — hoy los builds usan el make de Alpine sin declararlo. Es la misma lista que reemplazan los swaps del selfhost-verify, descubierta por otro camino.

El clasificador va FUERA del lector

harkaq-verdict.py <verdict-crudo> <politica> [--human]. El lector tiene CAP_AUDIT_READ; clasificar es política, no privilegio. Mantenerlo tonto es el argumento de Q1c, y de yapa se itera la clasificación sin recompilar el binario capabilitado (sin perder el setcap).

Las expectativas viajan en la política como # expect <path> — una sola fuente de verdad, y 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

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.

Fase 2 — el barrido

fase2-barrido.sh <receta.toml>... + harkaq-suggest.py. La métrica del SDD (<5% de recetas necesitan excepción) medía otra cosa: una deuda que el store YA PROVEE no es una excepción, es una línea en [deps]. Sólo la deuda irreducible cuenta.

scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine /bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env > ~/.cache/harkaq/base.policy
printf "ro /usr/lib/os-release\n# expect /usr/bin/gcc\n# expect /usr/bin/ldd\n" >> ~/.cache/harkaq/base.policy
scripts/harkaq/fase2-barrido.sh recipes/zlib.toml recipes/brotli.toml ...

Primer barrido (6 recetas C, fuentes cacheadas): 5 con sólo deuda declarable —y las 5 por el MISMO path, /usr/bin/make y 1 irreducible (brotli: libstdc++/libgcc_s, el runtime C++ de Alpine = la frontera que matar gcc ya tenía identificada). No hay cola larga de excepciones, que era el riesgo de muerte del §7.

Gotcha del barrido: copiar la receta a otro directorio rompe la resolución de deps.build (son relativas al dir de la receta) ⇒ se descartaban en silencio justo las recetas CON deps. La copia va al lado del original.

El bucle cerrado (§4.7)

Diagnosticar no vale nada si no se puede accionar. Sobre recipes/zlib.toml, cada paso guiado SÓLO por lo que dijo harkaq:

Paso Veredicto harkaq dijo Acción
zlib tal cual Impuro /usr/bin/make → declarar dep: make [deps] build=["make"]
+ make Impuro /usr/bin/ranlib → declarar dep: binutils build=["make","binutils"]
+ binutils Impuro liblto_plugin.so → irreducible clasificar como esperada
final Hermetico ×3 b3:adc5c251… sellado

Cada capa destapa la siguiente y todas convergen en el gcc de Alpine. El último hallazgo no se encuentra a mano: los binutils DE HAMMER cargan el plugin LTO del gcc DE ALPINE — un agujero de soberanía dentro de un artefacto que hammer construye, no un fallo de la receta.

El hash del artefacto es idéntico antes y después de clasificar (b3:adc5c251… las dos veces): clasificar cambia el veredicto, no el build. La evidencia es comportamiento, no identidad.

NO modificar recipes/zlib.toml para probar esto: cambiaría su hash e invalidaría el artefacto sellado y todo lo aguas abajo. Se prueba en una copia recipes/.harkaq-*.toml (al lado del original, o se rompe la resolución de deps).