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
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
- No compilar bajo
/tmp: el sandbox hace--tmpfs /tmpy tapa el binario. - El canario necesita un punto de montaje escribible. Con
--ro-bind / /, bwrap no puede crear/harkaq-canary-Xen 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/tmpno entra en la clausura de la víctima. - Root +
--unshare-allno atraviesa un homedrwx------. bwrap mapea sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear yCAP_DAC_OVERRIDEen 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á
- El canario no puede depender de un binario. Con
cat $CANARYmorí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. - El canario no puede vivir en
/tmp. La política concederw /tmp(es el HOME del build), así que un canario ahí cae DENTRO de la clausura, se lee sin problema y no deniega nada ⇒SinEvidenciaeterno. 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/gcc—zig cclo sondea y el build salerc=0igual. 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/sh→busybox→ld-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).