DOS ERRORES MÍOS, uno de dirección y otro técnico, los dos en la cosecha automática:
1. DIRECCIÓN: declaró `busybox` como dep en 29 recetas — cuando la Etapa C lo está
ELIMINANDO (USERLAND_COMPONENTS=[uutils,findutils,…], ya cerrada; joyas-reusables
§5 lo confirma: "uutils… Ubuntu 25.10 los envía como default"). Estaba cimentando
la deuda que el roadmap borra.
2. TÉCNICO: busybox YA está en el runtime base de harkaq (`ro /bin/busybox`, `ro
/bin/sh` — el sandbox corre `sh -c` y /bin/sh→/bin/busybox). Es CONTRATO, no dep.
Declararlo apila el busybox de hammer sobre el de Alpine y ROMPE el build:
binutils daba "cannot run C compiled programs" en las DOS máquinas. Verificado:
sin busybox declarado, binutils construye (b3:f4507dcd…). Y la cadena se explica:
binutils roto ⇒ zlib (que lo declara) tampoco construía.
Auditoría de la cosecha (diff real, no la línea completa del +): añadió sólo 5 deps
distintas — make ×73, busybox ×29, perl ×8, pkgconf ×5, binutils ×1. Sólo busybox
estaba mal; las otras 4 son deps reales medidas. busybox revertido de 30 recetas
(queda sólo en busybox.toml, pre-existente).
Es el mismo error que ya me habían señalado con otro disfraz: MEDIR BIEN Y ACCIONAR
MAL. harkaq midió correcto (el build toca /bin/busybox: es el shell); la acción
correcta no era declararlo sino reconocerlo como contrato.
+ COORDINACIÓN del §3 con Fable 5 (mismo diseño, tareas repartidas):
- Su lección casper queda CONFIRMADA y REFORZADA: la clausura de build no sólo le
FALTAN las clases del mundo (offline) — también le SOBRA casi todo (headers, gcc).
Medido: htop (estático, 0 NEEDED) no toca NADA al correr ⇒ política = su binario.
- Su "la clase viaja como campo de la ConcesionCapacidad, sin formato nuevo" se
cumple LITERALMENTE: lo firmado es format::Permisos = u32 bitmask en 36 bytes
canónicos (Ring 0) ⇒ las clases SON los bits. La cripto no se toca.
- Diseño unificado: frontera (clases, u32, declaradas) + detalle (paths, Landlock,
medidos). D3 rige en ambos.
- Reparto: clases→Fable 5; medición/harness→Opus. CONTACTO: runtime-policy.sh ahora
emite la CLASE detectada (/etc/resolv.conf→dns, /etc/ssl/certs→tls-certs, …), no
sólo el path: la medición alimenta la tabla, la tabla decide el bit.
- Consumidor esperando: plan-jaula-juegos F1 (Steam que no puede leer ~/.ssh).
+ juez.sh (§10): nombre único por corrida (con uno fijo pega en caché ⇒ falso
"sin evidencia"). Fue el juez quien destapó todo esto en su primera corrida real.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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).