From 13233ee4e9334e0aa3785f0dc35e97ef50148476 Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 15 Jul 2026 17:50:34 -0400 Subject: [PATCH] =?UTF-8?q?harkaq:=20primer=20barrido=20en=20la=20granja?= =?UTF-8?q?=20=E2=80=94=20la=20deuda=20irreducible=20de=20la=20muestra=20e?= =?UTF-8?q?s=20perl=20(=C2=A74.10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/hammer-build/src/sandbox.rs | 16 ++++++- docs/16-harkaq-jaula.md | 33 ++++++++++++++ scripts/harkaq/harkaq-farm-setup.sh | 68 +++++++++++++++++++++++++++++ scripts/harkaq/harkaq-suggest.py | 38 +++++++++++++++- 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100755 scripts/harkaq/harkaq-farm-setup.sh diff --git a/crates/hammer-build/src/sandbox.rs b/crates/hammer-build/src/sandbox.rs index f9d69187..a33e1f9b 100644 --- a/crates/hammer-build/src/sandbox.rs +++ b/crates/hammer-build/src/sandbox.rs @@ -257,7 +257,21 @@ impl Sandbox { // del rootfs Alpine y no ve al host ⇒ contrato, no deuda. Lo destapó brotli, cuyo // configure lee /proc/cpuinfo y /proc/meminfo para dimensionar el paralelismo. "ro /proc", - "list /", "list /opt", + // LISTAR la estructura del árbol no es deuda: Landlock separa READ_DIR de READ_FILE, + // así que esto deja hacer `ls /usr/lib` sin poder LEER un solo fichero no declarado. + // La deuda de de-Alpinización es leer/ejecutar lo ajeno, no saber que existe. + // + // Sin esto, una receta SIN deps no genera ni un `list` (los `list` salían sólo de los + // ancestros de la clausura) ⇒ todo escaneo del árbol caía como denegación. El barrido + // en la granja marcaba a bash como deuda IRREDUCIBLE por `/usr/lib`, que es un gap de + // política, no una dependencia de Alpine — un falso positivo de los que matan el + // proyecto (§7). + "list /", "list /opt", "list /usr", "list /usr/bin", "list /usr/lib", + "list /usr/include", "list /usr/share", "list /usr/libexec", "list /usr/local", + "list /lib", "list /bin", "list /sbin", "list /usr/sbin", "list /etc", "list /var", + // `--tmp-overlay /` hace la raíz escribible con una capa tmpfs descartable: escribir + // en /var/tmp no toca el rootfs Alpine ni sobrevive al build. Es scratch, como /tmp. + "rw /var/tmp", ] .iter() .map(|s| s.to_string()) diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index f981f9fd..01af248c 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -808,6 +808,39 @@ el silencio de un lector roto. --- +### 4.10 Barrido en la granja: la deuda irreducible de la muestra es **perl** + +Primer barrido sobre la granja con ABI 7 (`harkaq-farm-setup.sh` + `fase2-barrido.sh`, 24 recetas, +14 con veredicto — las 10 restantes son Go/Rust y cayeron en caché o fetch). El primer número +crudo fue **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`, así que **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**, porque conoce la lista de ficheros de cada artefacto — y el worker sólo + tiene 103 artefactos contra los cientos del hub. + +⇒ **El worker MIDE, el hub CLASIFICA.** Es la misma separación que lector/clasificador (§4.2): el +que tiene el privilegio —o los datos— hace lo mínimo. Reclasificado en el hub: + +| receta | crudo en el worker | real | +|---|---|---| +| bash | `ranlib`, `/usr/lib`, `/var/tmp` | — (gap de política) | +| doas | `/usr/bin/diff` | — (`diffutils` lo provee) | +| ca-certificates, 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 en el store: es 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". + ### 4.9 Matar gcc: la última milla, cerrada El primer barrido (§4.6) dejó **un solo** caso irreducible: brotli pidiendo diff --git a/scripts/harkaq/harkaq-farm-setup.sh b/scripts/harkaq/harkaq-farm-setup.sh new file mode 100755 index 00000000..993eedce --- /dev/null +++ b/scripts/harkaq/harkaq-farm-setup.sh @@ -0,0 +1,68 @@ +#!/bin/sh +# harkaq-farm-setup.sh — 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 }" +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 < + harkaq-suggest.py [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)