Files
sergioandClaude Opus 4.8 83d40af35c harkaq: el clasificador nunca propone runtime base como declarable (fix de raíz de busybox)
El bug que declaró `busybox` en 29 recetas y rompió binutils: harkaq-suggest veía
`/bin/busybox` denegado, encontraba `recipes/busybox.toml` en el store y decía
"declarar dep: busybox" — sin mirar que la política YA lo concede (`ro /bin/busybox`:
el sandbox corre `sh -c` y /bin/sh→busybox ⇒ es CONTRATO, no dep).

Fix: harkaq-suggest ahora lee los paths CONCEDIDOS de la política (`ro`/`rw`/`list`),
no sólo los `# expect`. Si un path está concedido ⇒ categoría RUNTIME BASE: no se
declara, aunque el store lo provea.

Las 4 clasificaciones verificadas tras el cambio:
  /bin/busybox              → RUNTIME BASE (ya concedido; NO declarar)   ← el fix
  /usr/bin/make             → DECLARABLE (declarar dep: make)
  /usr/lib/libstdc++.so     → IRREDUCIBLE (nadie lo provee)
  /usr/bin/gcc en broot     → DEUDA DE COMPILADOR (compiler=gcc)
  /usr/bin/gcc en zlib      → sin deuda (sonda esperada; zlib compila con zig)

Con esto puesto, busybox JAMÁS se habría declarado. Cierra el TODO que dejó el
revert: la campaña automática ya no puede cimentar contrato como dependencia.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 23:27:57 -04:00

185 lines
8.8 KiB
Python
Executable File

#!/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> [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
deuda es **declarable** — la receta sólo tiene que añadir la dep, y hammer ya sabe construirla.
La distinción importa para la métrica de la Fase 2 (<5% de recetas necesitan excepción):
declarable el store ya lo provee ⇒ NO es una excepción, es una línea en `[deps]`. El
diagnóstico es accionable y el arreglo, mecánico.
irreducible nadie lo provee ⇒ o se escribe una receta, o se acepta como runtime base. Ésta
es la que cuenta contra el <5%.
Ejemplo real: zlib salió `Impuro` por `/usr/bin/make`. Parecía runtime base — pero
`recipes/make.toml` existe y `store/…-make` está sellado, y `SWAP_MAKE` es uno de los swaps del
selfhost-verify. O sea: no es base, es deuda declarable. La diferencia entre "aceptamos que
hammer usa el make de Alpine" y "a zlib le falta una línea" la decide este cruce, no una opinión.
"""
import json
import os
import sys
def indice_del_store(store):
"""path-dentro-del-sandbox → [artefactos que lo proveen].
`--overlay-src <dep>` funde `<dep>/usr/...` en `/usr/...`, así que el path relativo dentro
del artefacto ES el path del sandbox. El mismo mapeo que usa harkaq-policy, al revés.
"""
idx = {}
for art in sorted(os.listdir(store)):
raiz = os.path.join(store, art)
if not os.path.isdir(raiz):
continue
for dirpath, _, files in os.walk(raiz):
if "/.hammer" in dirpath:
continue
for f in files:
completo = os.path.join(dirpath, f)
dentro = "/" + os.path.relpath(completo, raiz)
idx.setdefault(dentro, []).append(art)
return idx
def nombre_de(art):
"""`<64hex>-<name>` → `<name>`."""
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")}
# Los frontends del gcc de Alpine. Son "sonda esperada" (D3) SÓLO cuando la receta compila con
# zig (el default): el configure los sondea y zig no los usa. Pero para una receta `compiler=gcc`
# (`CC=gcc`, el gcc de Alpine), gcc ES el compilador y su uso es DEUDA DE COMPILADOR — el frente
# matar-gcc. Sin esta distinción, harkaq marcaba `Hermetico` recetas que dependen del gcc de
# Alpine (47 de ellas), ciego a la deuda mayor. SDD 16, límite del modelo (2026-07-16).
FRONTENDS_GCC = {"/usr/bin/gcc", "/usr/bin/cpp", "/usr/bin/c89", "/usr/bin/c99", "/usr/bin/g++",
"/usr/bin/cc", "/usr/bin/ld", "/usr/bin/as"}
def compiler_de(recipe_toml):
"""`gcc` si la receta usa el gcc de Alpine como compilador, si no `zig`."""
try:
s = open(recipe_toml).read()
except OSError:
return "zig"
import re
m = re.search(r'compiler\s*=\s*"([^"]+)"', s)
return "gcc" if m and m.group(1) == "gcc" else "zig"
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")
# 5º arg opcional: la receta que se midió. Si usa compiler=gcc, los frontends de gcc dejan de
# ser "esperada" y pasan a DEUDA DE COMPILADOR (matar-gcc).
receta_toml = sys.argv[5] if len(sys.argv) > 5 else None
usa_gcc = receta_toml and compiler_de(receta_toml) == "gcc"
esperadas = set()
# RUNTIME BASE: lo que la política ya CONCEDE (`ro`/`rw`/`list`). Si un path está acá, NO es
# deuda declarable aunque el store lo provea — es contrato del sandbox.
#
# Este bug costó caro: `/bin/busybox` está concedido (el sandbox corre `sh -c` y /bin/sh→
# busybox), pero como `recipes/busybox.toml` existe, el clasificador propuso "declarar dep:
# busybox" y la cosecha lo declaró en 29 recetas. Declararlo apila el busybox de hammer sobre
# el de Alpine y ROMPE el build (binutils: "cannot run C compiled programs"). Además va contra
# la Etapa C, que reemplaza busybox por uutils. Medir bien y accionar mal.
concedidos = set()
with open(politica) as f:
for l in f:
l = l.strip()
if l.startswith("# expect "):
esperadas.add(l[len("# expect "):].strip())
elif l[:3] in ("ro ", "rw ") or l.startswith("list "):
concedidos.add(l.split(None, 1)[1].strip())
# Si la receta compila con gcc, los frontends NO se perdonan: son la deuda de compilador.
if usa_gcc:
esperadas -= FRONTENDS_GCC
deuda = sorted({d["path"] for d in crudo.get("denials", []) if d["path"] not in esperadas})
if not deuda:
print("sin deuda ✓")
return 0
idx = indice_del_store(store)
recetas = catalogo(recipes_dir)
declarable, irreducible, compilador, base = {}, [], [], []
for p in deuda:
if usa_gcc and p in FRONTENDS_GCC:
compilador.append(p)
continue
# ¿Ya concedido por la política? ⇒ runtime base, NO se declara (ver arriba: busybox).
if p in concedidos or any(p.startswith(c.rstrip("/") + "/") for c in concedidos if c != "/"):
base.append(p)
continue
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)
if base:
print("RUNTIME BASE — ya concedido por la política; NO declarar (es contrato del sandbox):")
for p in base:
print(f" {p}")
if compilador:
print("DEUDA DE COMPILADOR (matar-gcc) — la receta usa el gcc de Alpine (compiler=gcc):")
for p in compilador:
print(f" {p} → migrar a zig-cc, o aceptar como escape declarativo documentado")
if declarable:
print("DEUDA DECLARABLE — el store ya lo provee; falta la línea en [deps]:")
for p, quien in declarable.items():
print(f" {p}\n → declarar dep: {', '.join(quien)}")
if irreducible:
print("DEUDA IRREDUCIBLE — nadie la provee (escribir receta, o aceptar como runtime base):")
for p in irreducible:
print(f" {p}")
# El exit code separa "arreglable con una línea" de "hace falta trabajo de verdad": es lo que
# un barrido de la Fase 2 necesita para contar contra el <5% sin leer texto.
return 0 if not irreducible else 1
if __name__ == "__main__":
sys.exit(main())