Files
takana/scripts/harkaq
Sergio 476168bb07 takana etapa 5c: comentarios de scripts, MOTD, y un BUG que introdujo la etapa 4
250 líneas de comentario en 151 scripts. Control verificado: el diff no toca
NI UNA línea que no empiece por #, y la sintaxis de los 151 pasa.

El barrido saltea heredocs y cadenas triples, y el guardián DISPARÓ 3 veces:
las tres eran el MOTD que el script escribe DENTRO de la imagen construida —
texto del producto, no comentario del script. Se cambiaron aparte y a
propósito, que es rebranding, no limpieza.

Y el hallazgo caro:  casaba contra ,
que es el TARGET de tracing — o sea el module_path!, o sea el nombre del crate.
La etapa 4 lo movió a  y el script quedó casando NADA. No fallaba:
imprimía cero atribuciones, indistinguible de un log sin problemas. Comprobado
con el binario (RUST_LOG=info sobre zlib), no deducido. Ahora acepta las dos, y
tiene que seguir aceptándolas porque los logs viejos en disco dicen la vieja.

Además 14 rutas de módulo  en docs, que el barrido anterior no tocó
porque  no es frontera de palabra.
2026-09-09 19:28:48 +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).