harkaq: Q2 RESUELTA — el runtime base son 5 paths, y aparece una 3ª categoría

Método (lo importante): NO se adivina, se mide. MODE=base corre SIN ninguna dep
declarada ⇒ sin clausura que pueda explicarlas, toda denegación es runtime base
por definición. Aísla la respuesta sin juicio de valor.

MODE=base, hello.c mínimo con zig cc: 18 denegaciones, 4 paths distintos
(contador del kernel 19 = 18 + canario ✓):
    8× /dev/urandom   6× /usr/lib/os-release   3× /dev/null   1× /usr/bin/env
Y el build salió rc=0: no son cosas que el build NECESITE, son cosas que INTENTA
— pero salen en todos los builds y sin tratarlas entierran la deuda real.

La partición cierra en TRES categorías, todas medidas (§4.2):
  - Contrato del sandbox: /src /out /tmp /dev/null(rw) /dev/urandom /proc
    /opt/zig — los monta BWRAP, no son Alpine ⇒ no son deuda. Ésta era la línea
    divisoria que faltaba.
  - 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, y eso
    hace viable todo el planteo.
  - Denegación ESPERADA (no estaba en el diseño): /usr/bin/gcc. zig cc lo sondea
    3× y el build sale rc=0 igual ⇒ NO es ruido a permitir, es la jaula haciendo
    su trabajo: concederla dejaría a zig invocar el gcc de Alpine por detrás,
    justo lo que la campaña "matar gcc" persigue a mano. Es D3 literal ("una
    denegación esperada no se calla, se CLASIFICA") y confirma que no tener
    `quiet` era correcto: silenciarla habría borrado el hallazgo.

El contraste con el mismo runtime base:
  MODE=base  rc=0  Impuro [ /usr/bin/gcc ]            ← esperada
  MODE=zlib  rc=1  Impuro [ /usr/lib/libz.so.1.3.2 ]  ← DEUDA PURA, sin ruido

Consecuencia de diseño para el Verdict: las denegaciones tienen que venir
CLASIFICADAS (base|esperada|deuda), no en lista plana. `Hermetico` debe
significar "cero deuda", no "cero denegaciones" — si no, ningún build real lo
alcanzaría jamás.

Gotchas medidos: /dev/null es destino de ESCRITURA (rw, no ro) y el piso duro es
/bin/sh→busybox→ld-musl (sin eso ni el canario corre ⇒ sólo SinEvidencia).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:28:47 -04:00
co-authored by Claude Opus 4.8
parent 24c6ab455e
commit 61171bfe7f
3 changed files with 111 additions and 23 deletions
+55 -18
View File
@@ -378,6 +378,58 @@ Dos consecuencias de diseño:
un fichero regular hacen que el kernel rechace la regla entera con `EINVAL`. Como la clausura
es mayormente ficheros sueltos, sin recortar por tipo no arranca ni la primera regla.
### 4.2 Q2 resuelta: el runtime base son 5 paths, y hay una tercera categoría
El método es lo importante: **no se adivina, se mide**. `scripts/harkaq/q2-runtime-base.sh` corre
un build real en el sandbox real con `política = clausura declarada` y deja que las denegaciones
nombren el resto. `MODE=base` (sin **ninguna** dep declarada) aísla la respuesta sin juicio de
valor: sin clausura que pueda explicarlas, *toda* denegación es runtime base por definición.
`MODE=base`, `hello.c` mínimo con `zig cc` — 18 denegaciones, 4 paths distintos (contador del
kernel 19 = 18 + canario ✓):
```
8× /dev/urandom 6× /usr/lib/os-release 3× /dev/null 1× /usr/bin/env
```
**Y el build salió `rc=0`.** No son cosas que el build *necesite*: son cosas que *intenta*. Pero
aparecen en todos los builds, así que sin tratarlas cada veredicto sale `Impuro` con los mismos
paths y la deuda real queda enterrada. Iterando (cada línea añadida sólo tras una denegación
real, nunca por corazonada) la partición se cierra en **tres** categorías:
| Categoría | Qué | Qué hacer |
|---|---|---|
| **Contrato del sandbox** | `/src`, `/out`, `/tmp`, `/dev/null` (**rw**: es destino de escritura), `/dev/urandom`, `/proc`, `/opt/zig` | conceder — **los crea bwrap, no son Alpine** |
| **Runtime base Alpine** | `/bin/sh`, `/bin/busybox`, `/lib/ld-musl-x86_64.so.1`, `/usr/bin/env`, `/usr/lib/os-release` | conceder — irreducible, y son **5** |
| **Denegación esperada** | `/usr/bin/gcc` | **denegar y clasificar** (D3) |
La primera línea divisoria es la que hacía falta: `/dev` y `/proc` **los monta bwrap**, no salen
del rootfs Alpine ⇒ son superficie de contrato como `/src`, no deuda. Sólo 5 paths de Alpine son
irreducibles. Eso hace viable todo el planteo: **el resto de Alpine es, genuinamente, deuda**.
**La tercera categoría no estaba en el diseño y es la más interesante.** `zig cc` sondea
`/usr/bin/gcc` (`fs.execute,fs.read_file`, 3 veces) y el build igual sale `rc=0` sin él. Esa
denegación **no es ruido a permitir: es la jaula haciendo su trabajo**. Concederla dejaría a zig
invocar el gcc de Alpine por detrás — justo lo que la campaña *matar gcc* persigue a mano. harkaq
da evidencia mecánica de que zig lo intenta *y* de que negárselo no rompe el build. Es D3 literal
("una denegación esperada no se calla, se *clasifica*"), y confirma que la decisión de no tener
`quiet` era correcta: silenciarla habría borrado el hallazgo.
**El contraste, con el mismo runtime base:**
```
MODE=base rc=0 Impuro, denegaciones = [ /usr/bin/gcc ] ← esperada
MODE=zlib rc=1 Impuro, denegaciones = [ /usr/lib/libz.so.1.3.2 ] ← DEUDA PURA
```
Una sola denegación, sin ruido: el build se linkaba contra **la zlib de Alpine** en vez de la dep
declarada (que aporta `libz.a`). Sin harkaq eso pasa en verde y el artefacto queda dependiendo de
Alpine. **Es exactamente el bug de §0, aislado en un build real y en una corrida de 20 segundos.**
Consecuencia para el `Verdict`: los tres estados de D9 necesitan que las denegaciones vengan
**clasificadas** (`base` | `esperada` | `deuda`), no en una lista plana. `Hermetico` debe
significar "cero deuda", no "cero denegaciones" — si no, ningún build real lo alcanzaría jamás.
---
## 5. Decisiones cerradas
@@ -626,24 +678,9 @@ vibra.
- **Q1d (Fase 1, menor):** el `exe=` del registro trae la ruta **del host**. Con el sandbox real
(`--bind <src> /src`) hay que confirmar qué ruta reporta para un builder que vive en `/src`, y si
sirve para algo o basta con `domain=`.
- **Q2 (Fase 2) — EN CURSO, con método y primeros datos.** ¿Qué es el "runtime base" que toda
receta necesita y ninguna declara? *Sigue siendo la decisión de diseño de verdad* — determina si
la métrica de la Fase 2 mide deuda real o ruido. **El método está resuelto: no se adivina, se
mide.** `scripts/harkaq/q2-runtime-base.sh` corre un build real en el sandbox real con
`política = clausura declarada` y deja que las denegaciones nombren el resto.
Medido (2026-07-15), compilando contra la dep `zlib` del store:
- **Piso duro:** `/bin/sh``/bin/busybox``/lib/ld-musl-x86_64.so.1`. Sin esto ni el canario
corre (el `execvp` de `/bin/sh` rebota) ⇒ `SinEvidencia` y cero diagnóstico. El runtime base
mínimo es, literalmente, *lo que hace falta para que el canario pueda correr*.
- **Candidato:** `/usr/bin/env` — legítimo, ninguna receta lo declara ni debería.
- **Y el hallazgo que justifica el proyecto:** `fs.read_file /usr/lib/libz.so.1.3.2`. El build
se linkaba contra **la zlib de Alpine** en vez de la dep declarada (que aporta `libz.a`). Sin
harkaq eso pasa en verde y el artefacto queda dependiendo de Alpine. Con harkaq el kernel lo
nombra, con path e inode. **Es exactamente el bug de §0, cazado en un build real.**
Falta: correrlo sobre el catálogo y separar el runtime base (pocos, estables, en todas las
recetas) de la deuda real (variable, por receta). Esa separación *es* la respuesta a Q2.
- **Q2 — ✅ RESPONDIDA (2026-07-15), y el resultado es mejor de lo esperado.** Ver §4.2. El
runtime base de Alpine son **5 paths**, medidos; la deuda queda aislada sin ruido; y apareció
una tercera categoría que no estaba en el diseño: la *denegación esperada*.
- **Q3 (Fase 2):** ¿`/proc` y `/dev` mínimos rompen builds reales? (`/proc/self` suele hacer falta;
`/proc/sys` casi nunca.) bwrap ya monta `--proc /proc --dev /dev`.
- **Q4:** ¿el `Verdict` entra en `Recipe::hash_inputs`? Por simetría con `Evidence` (H1), **no**:
+30
View File
@@ -168,3 +168,33 @@ estado: Impuro | canario: True | contador kernel: 3
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.
```sh
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 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/sh`→`busybox`→`ld-musl`: sin eso ni el canario
corre y no hay diagnóstico, sólo `SinEvidencia`.
+26 -5
View File
@@ -17,23 +17,43 @@ cd "$(dirname "$0")/../.."
BIN="${HARKAQ_BIN:-$HOME/.cache/harkaq}"
ROOTFS=.dev-fs/alpine
ZIG=.dev-fs/tools/zig
DEP=$(ls -d store/*-zlib | head -1)
# MODE=base → build SIN ninguna dep declarada. Por definición, TODA denegación es runtime base:
# no hay clausura que pueda explicarla. Aísla la respuesta a Q2 sin juicio de valor.
# MODE=zlib → el mismo build contra la dep zlib del store. Lo que sobre por encima del runtime
# base ES la deuda de de-Alpinización, sin ambigüedad.
MODE="${MODE:-zlib}"
[ -d "$ROOTFS" ] || { echo "falta $ROOTFS"; exit 1; }
[ -d "$ZIG" ] || { echo "falta $ZIG"; exit 1; }
WORK=$(mktemp -d); trap 'rm -rf "$WORK"' EXIT
mkdir -p "$WORK/src" "$WORK/out"
cat > "$WORK/src/hello.c" <<'EOF'
if [ "$MODE" = base ]; then
DEPS=""
LDFLAGS=""
cat > "$WORK/src/hello.c" <<'EOF'
#include <stdio.h>
int main(void) { printf("hola\n"); return 0; }
EOF
else
DEPS=$(ls -d store/*-zlib | head -1)
LDFLAGS="-lz"
cat > "$WORK/src/hello.c" <<'EOF'
#include <zlib.h>
#include <stdio.h>
int main(void) { printf("zlib %s\n", zlibVersion()); return 0; }
EOF
fi
# La clausura DECLARADA (D1) + las superficies que el sandbox define por contrato, no la receta:
# /src (fuentes), /out (DESTDIR), /tmp (HOME). Nada de Alpine.
POLICY="$WORK/policy"
scripts/harkaq/harkaq-policy.sh "$DEP" > "$POLICY"
# Sin deps (MODE=base) la clausura declarada es VACÍA: la política son sólo las
# superficies de contrato del sandbox. Todo lo demás que el build toque, se ve.
: > "$POLICY"
[ -n "$DEPS" ] && scripts/harkaq/harkaq-policy.sh $DEPS > "$POLICY"
{
echo "rw /src"
echo "rw /out"
@@ -45,6 +65,7 @@ scripts/harkaq/harkaq-policy.sh "$DEP" > "$POLICY"
[ -n "${BASE_EXTRA:-}" ] && printf '%s\n' "$BASE_EXTRA"
} >> "$POLICY"
echo "── MODE=$MODE deps=[${DEPS:-ninguna}]" >&2
echo "── política: $(grep -c '^ro ' "$POLICY") ro, $(grep -c '^list ' "$POLICY") list, $(grep -c '^rw ' "$POLICY") rw" >&2
NONCE="q2-$$"
@@ -67,10 +88,10 @@ read -r _ < "$FIFO" || true
# El probe del canario usa SÓLO builtins del shell: `read` + redirección. Con `cat` dependía de
# /bin/cat (otro applet de busybox) y, si no estaba en la clausura, el probe moría sin probar
# nada — el canario tiene que ser lo más barato e independiente del sistema.
CMD="read _ < $CANARY || true; cd /src && zig cc -mcpu=baseline hello.c -lz -o /out/hello && echo BUILD-OK"
CMD="read _ < $CANARY || true; cd /src && zig cc -mcpu=baseline hello.c $LDFLAGS -o /out/hello && echo BUILD-OK"
set +e
bwrap --overlay-src "$ROOTFS" --overlay-src "$DEP" --tmp-overlay / \
bwrap --overlay-src "$ROOTFS" $(for d in $DEPS; do printf -- "--overlay-src %s " "$d"; done) --tmp-overlay / \
--proc /proc --dev /dev --tmpfs /tmp \
--ro-bind "$ZIG" /opt/zig \
--bind "$WORK/src" /src --bind "$WORK/out" /out \