harkaq: primer barrido en la granja — la deuda irreducible de la muestra es perl (§4.10)

harkaq-farm-setup.sh: provisiona un worker para barrer (idempotente; gatea por
ABI>=7 y aborta si no llega — con la golden vieja el barrido daría SinEvidencia
en TODO). Deriva el runtime base DEL ROOTFS DEL WORKER, no copia el del laptop:
es por-rootfs (§4.3) y los sonames dependen de la versión de Alpine.

Barrido: 24 recetas, 14 con veredicto. Primer número crudo: 4 irreducibles (29%).
ERA FALSO, y por dos motivos propios:

1. GAP DE POLÍTICA. Los `list` salían sólo de los ancestros de la clausura ⇒ una
   receta SIN deps (bash) no generaba ni uno y todo escaneo del árbol caía como
   denegación (/usr/lib). Listar NO es leer: Landlock separa READ_DIR de
   READ_FILE ⇒ la estructura del árbol es CONTRATO. Se puede `ls /usr/lib` sin
   leer un solo fichero no declarado; la deuda es leer lo ajeno, no saber que
   existe. Idem /var/tmp: con --tmp-overlay / es scratch descartable, como /tmp.
2. La heurística del catálogo es por NOMBRE y un fichero no se llama como su
   paquete: /usr/bin/ranlib lo trae `binutils`, /usr/bin/diff lo trae
   `diffutils`. El único que sabe la verdad es el STORE (conoce la lista de
   ficheros de cada artefacto) — y el worker tiene 103 artefactos contra los
   cientos del hub.

⇒ EL WORKER MIDE, EL HUB CLASIFICA. Misma separación que lector/clasificador: el
que tiene el privilegio —o los datos— hace lo mínimo. Reclasificado en el hub:
    bash             ranlib,/usr/lib,/var/tmp → nada (gap de política)
    doas             /usr/bin/diff            → nada (diffutils lo provee)
    ca-certificates  /usr/bin/perl            → /usr/bin/perl
    curl             /usr/bin/perl            → /usr/bin/perl

RESULTADO: la deuda irreducible de toda la muestra es UN path — /usr/bin/perl
(2 de 14 = 14%). No hay recipes/perl.toml ni artefacto: deuda genuina y nombrada.
Todo lo demás era declarable (make ×10, ranlib→binutils, diff→diffutils).

Sigue por encima del <5% del §7, pero el perfil es el que importa: NO hay cola
larga de sorpresas. La deuda de un catálogo entero se resume en "declarar make" y
"no tenemos perl".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 17:50:34 -04:00
co-authored by Claude Opus 4.8
parent ea28a3dbcc
commit 13233ee4e9
4 changed files with 153 additions and 2 deletions
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
# harkaq-farm-setup.sh <ip> — deja un worker de la granja listo para barrer con harkaq.
#
# La granja es el compilador continuo (SDD 16 §4.8), así que esto tiene que ser reproducible y
# no un copy-paste. Idempotente: re-correrlo sobre un worker ya provisto no rompe nada.
#
# Precondición dura: la golden 408909310 (kernel 6.17 ⇒ Landlock ABI 7). Con la vieja (6.8, ABI 4)
# el barrido correría y daría `SinEvidencia` en TODO — que es lo que D7 manda y lo que no sirve
# para medir. Por eso lo primero que hace es verificar el ABI y abortar si no llega.
set -eu
IP="${1:?uso: harkaq-farm-setup.sh <ip>}"
KEY="${SSH_KEY:-$HOME/.ssh/github5}"
REMOTE="${REMOTE:-/opt/hammer}"
SSH="ssh -i $KEY -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes"
HERE="$(cd "$(dirname "$0")" && pwd)"
echo "== 1/4 verificando el ABI (gate: >=7 o no hay evidencia)"
abi=$($SSH "root@$IP" 'python3 -c "
import ctypes; libc=ctypes.CDLL(\"libc.so.6\",use_errno=True)
print(libc.syscall(444,None,0,1))"' 2>/dev/null | tr -d '\r')
echo " kernel: $($SSH "root@$IP" uname -r 2>/dev/null) → Landlock ABI = $abi"
if [ "${abi:-0}" -lt 7 ]; then
echo " ABORTO: ABI $abi < 7 ⇒ el kernel no audita denegaciones. Todos los veredictos"
echo " saldrían SinEvidencia. ¿El worker salió de la golden 408909310?"
exit 4
fi
echo "== 2/4 copiando y compilando las piezas"
$SSH "root@$IP" "mkdir -p /root/.cache/harkaq $REMOTE/scripts/harkaq"
scp -q -i "$KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$HERE"/harkaq-uapi.h "$HERE"/harkaq-audit.c "$HERE"/harkaq-exec.c \
"$HERE"/harkaq-policy.sh "$HERE"/harkaq-verdict.py "$HERE"/harkaq-suggest.py \
"$HERE"/harkaq-base-closure.py "$HERE"/fase2-barrido.sh \
"root@$IP:$REMOTE/scripts/harkaq/"
# harkaq-uapi.h no es una comodidad: el worker tiene headers 6.8 sobre kernel 6.17 y NO define
# ni AUDIT_LANDLOCK_ACCESS. Sin él el lector filtraría por un número que no conoce y vería cero
# denegaciones — el falso `Hermetico` de D9, por headers viejos (§4.8).
$SSH "root@$IP" "cd $REMOTE/scripts/harkaq && chmod +x *.sh *.py &&
gcc -O1 -Wall -o /root/.cache/harkaq/harkaq-audit harkaq-audit.c &&
gcc -O1 -Wall -static -o /root/.cache/harkaq/harkaq-exec harkaq-exec.c &&
echo ' compiladas ✓'"
echo "== 3/4 derivando el runtime base DEL ROOTFS DEL WORKER"
# El runtime base es POR ROOTFS (§4.3) — derivarlo del rootfs del worker, no copiar el del
# laptop: los sonames de las libs (libz.so.1.3.2, …) dependen de la versión de Alpine que tenga.
$SSH "root@$IP" "cd $REMOTE &&
python3 scripts/harkaq/harkaq-base-closure.py .dev-fs/alpine \
/bin/sh /bin/busybox /bin/bash /bin/coreutils /usr/bin/env 2>/dev/null \
> /root/.cache/harkaq/base.policy
printf 'ro /usr/lib/os-release\n' >> /root/.cache/harkaq/base.policy
# Sondas de compilador de Alpine: se DENIEGAN y se clasifican (D3). Concederlas dejaría a
# zig/configure usar el gcc de Alpine por detrás — justo lo que la campaña matar-gcc caza.
for p in /usr/bin/gcc /usr/bin/ldd /usr/bin/c89 /usr/bin/c99; do
printf '# expect %s\n' \"\$p\" >> /root/.cache/harkaq/base.policy
done
echo \" runtime base: \$(grep -c '^ro ' /root/.cache/harkaq/base.policy) reglas + \$(grep -c '^# expect' /root/.cache/harkaq/base.policy) esperadas\""
echo "== 4/4 comprobando la cadena de evidencia de punta a punta"
$SSH "root@$IP" "cd $REMOTE && cargo build -q -p hammer-cli 2>/dev/null || true; ls target/debug/hammer >/dev/null 2>&1 && echo ' hammer-cli listo ✓' || echo ' OJO: falta target/debug/hammer (cargo build -p hammer-cli)'"
cat <<EOF
listo. En el worker:
cd $REMOTE
export HARKAQ_BIN=/root/.cache/harkaq HARKAQ_BASE=/root/.cache/harkaq/base.policy
scripts/harkaq/fase2-barrido.sh recipes/zlib.toml recipes/bzip2.toml ...
EOF
+37 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""harkaq-suggest — convierte la deuda en una acción concreta (SDD 16 §4.5).
harkaq-suggest.py <verdict-crudo.json> <politica> <store>
harkaq-suggest.py <verdict-crudo.json> <politica> <store> [recipes-dir]
Un veredicto `Impuro` dice *qué* usó el build sin declarar. Esto dice *qué hacer*: por cada path
de deuda busca en el store un artefacto sellado que provea ese mismo path relativo. Si lo hay, la
@@ -50,12 +50,31 @@ def nombre_de(art):
return art.split("-", 1)[1] if "-" in art else art
def catalogo(recipes_dir):
"""Nombres de receta que hammer SABE construir, tenga o no el artefacto a mano.
Sin esto, "declarable" sería relativo a la máquina: el store del worker de la granja trae 103
artefactos y el del laptop cientos, así que el MISMO path (`/usr/bin/make`) salía declarable
en uno e irreducible en el otro. Eso no es una propiedad de la receta: es del disco donde
corrés el barrido, y habría inflado la métrica de la Fase 2 en la granja — que es justo donde
se va a medir en serio.
El catálogo es la respuesta correcta a "¿esto lo sabemos construir?". El store sólo dice
"¿lo tenemos construido AHORA?", que es otra pregunta.
"""
if not recipes_dir or not os.path.isdir(recipes_dir):
return set()
return {f[:-5] for f in os.listdir(recipes_dir) if f.endswith(".toml")}
def main():
if len(sys.argv) < 4:
print(__doc__.split("\n")[2].strip(), file=sys.stderr)
return 2
crudo = json.load(open(sys.argv[1]))
politica, store = sys.argv[2], sys.argv[3]
recipes_dir = sys.argv[4] if len(sys.argv) > 4 else os.path.join(
os.path.dirname(os.path.abspath(store)), "recipes")
esperadas = set()
with open(politica) as f:
@@ -70,11 +89,28 @@ def main():
return 0
idx = indice_del_store(store)
recetas = catalogo(recipes_dir)
declarable, irreducible = {}, []
for p in deuda:
provs = idx.get(p)
if provs:
# El store es autoritativo: sabe qué ficheros aporta cada artefacto.
declarable[p] = sorted({nombre_de(a) for a in provs})
elif os.path.basename(p) in recetas:
# Respaldo por catálogo: el artefacto no está construido AQUÍ, pero hammer sabe
# construirlo. Es declarable igual — la deuda es de la receta, no del disco.
#
# OJO, heurística por NOMBRE y por eso sólo un respaldo: acierta con `make`, pero
# falla con `/usr/bin/ranlib` (lo trae `binutils`) y `/usr/bin/diff` (lo trae
# `diffutils`). Un fichero no se llama como su paquete. El único que sabe la verdad
# es el store, porque conoce la lista de ficheros de cada artefacto.
#
# ⇒ CLASIFICAR EN EL HUB, no en el worker. El worker de la granja mide (produce los
# veredictos crudos) y el hub clasifica, porque es quien tiene el store COMPLETO. Con
# el store parcial del worker (103 artefactos) esto marcaba bash y doas como deuda
# IRREDUCIBLE y habría dado una métrica de Fase 2 inflada. Misma separación que
# lector/clasificador: el que tiene el privilegio (o los datos) hace lo mínimo.
declarable[p] = [os.path.basename(p) + " (receta en el catálogo, sin construir aquí)"]
else:
irreducible.append(p)