From 24c6ab455eb785338667e2b73bf30f76665648dd Mon Sep 17 00:00:00 2001 From: sergio Date: Wed, 15 Jul 2026 15:20:39 -0400 Subject: [PATCH] =?UTF-8?q?harkaq:=20=C2=A74=20corregido=20(pol=C3=ADtica?= =?UTF-8?q?=20POR=20FICHERO)=20+=20Q2=20con=20m=C3=A9todo=20y=20primeros?= =?UTF-8?q?=20datos=20reales?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORRECCIÓN ESTRUCTURAL del §4 (otra herencia del marco nix). El Draft 2 decía `fs_ro: [store paths de la clausura]`. Falso: esos paths NO existen dentro del sandbox. `Sandbox::bwrap_args` apila las deps con --overlay-src y las FUNDE en el mismo /usr que el rootfs Alpine, a propósito, para que el compilador las encuentre sin plumbing de flags. Medido: /usr/include/zlib.h (dep DECLARADA) dev=63 ino=13641973 /usr/include/stdio.h (Alpine, NO declarado) dev=63 ino=4196788 /usr/include (el directorio) dev=61 ino=15 ← overlayfs, uno Una regla sobre /usr concede las dos ⇒ la evidencia no valdría nada. Ni `dev` distingue. ⇒ La clausura se enumera FICHERO A FICHERO (computable: en el host sabemos qué aporta cada dep). Dos consecuencias: `list` ≠ `ro` (pkgconf NECESITA escanear /usr/lib/pkgconfig, pero listar no es leer; Landlock separa READ_DIR de READ_FILE) y máscara por tipo (derechos de-sólo-dir sobre un fichero ⇒ EINVAL y la regla entera se cae). Piezas nuevas: harkaq-policy.sh (deriva la clausura, D1) + q2-runtime-base.sh (responde Q2 midiendo, no adivinando) + harkaq-exec con `list`/máscara por tipo. Q2, primeros datos con un build REAL en el sandbox REAL contra la dep zlib: 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! 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 DEL §0, cazado en un build real. Piso duro del runtime base: /bin/sh → /bin/busybox → /lib/ld-musl (sin eso ni el canario corre ⇒ el mínimo es "lo que hace falta para que el canario pueda correr"). Dos rastrillos: el canario no puede depender de un binario (`cat` moría en /bin/cat ⇒ ahora `read _ < $CANARY`, sólo builtins) ni vivir en /tmp (la política concede `rw /tmp` ⇒ el canario caía DENTRO de la clausura y no denegaba nada). El segundo lo cazó D9 MISMO: el lector se negó a certificar en vez de decir Hermetico. El canario funcionando como se diseñó, sobre un bug de quien lo diseñó. Co-Authored-By: Claude Opus 4.8 --- docs/16-harkaq-jaula.md | 64 ++++++++++++++++--- scripts/harkaq/README.md | 40 ++++++++++++ scripts/harkaq/harkaq-exec.c | 74 +++++++++++++++------- scripts/harkaq/harkaq-policy.sh | 51 +++++++++++++++ scripts/harkaq/harkaq-run.sh | 9 ++- scripts/harkaq/q2-runtime-base.sh | 100 ++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 33 deletions(-) create mode 100755 scripts/harkaq/harkaq-policy.sh create mode 100755 scripts/harkaq/q2-runtime-base.sh diff --git a/docs/16-harkaq-jaula.md b/docs/16-harkaq-jaula.md index a7fe0b17..cdf3286c 100644 --- a/docs/16-harkaq-jaula.md +++ b/docs/16-harkaq-jaula.md @@ -304,8 +304,10 @@ primaria (acá) y — vía el contador — detección de pérdida. ┌──────────────────────────────────────────────────────────┐ │ harkaq-policy (puro, testeable sin kernel) │ │ clausura(deps) → PolicySpec { │ - │ fs_ro: [store paths de la clausura + base runtime]│ + │ fs_ro: [UN FICHERO POR ENTRADA — ver §4.1] │ + │ fs_list: [dirs de la clausura: listar, no leer] │ │ fs_rw: [/src, /out, /tmp, /cache] │ + │ base: [el runtime implícito — §8 Q2] │ │ scoped: UNIX_ABSTRACT | SIGNAL │ │ } │ │ PolicyId = blake3(postcard(PolicySpec)) │ @@ -339,8 +341,42 @@ testeable con `cargo test` en cualquier máquina, incluso sin Landlock). `harkaq la receta. El acoplamiento vive sólo en `hammer-build`. Nota de encaje: `Sandbox::bwrap_args` ya construye la lista de deps (`self.deps`) — es -literalmente el input de `clausura()`. Y `merge_deps_layer()` ya las funde en una capa. El -`PolicySpec` se deriva de datos que la struct `Sandbox` ya tiene en la mano. +literalmente el input de `clausura()`. El `PolicySpec` se deriva de datos que la struct `Sandbox` +ya tiene en la mano. + +### 4.1 La política es POR FICHERO, no por path del store (corrección medida) + +El Draft 2 decía `fs_ro: [store paths de la clausura]`. **Es falso, y era otra herencia del marco +nix**, donde cada dep se ve dentro del sandbox como un `/nix/store/-x` distinto. En hammer +no: `Sandbox::bwrap_args` apila cada dep con `--overlay-src` y las **funde en el mismo `/usr`** +que el rootfs Alpine, a propósito, para que el compilador y pkgconf las encuentren en las rutas +estándar sin plumbing de flags. + +Medido dentro del sandbox real: + +``` +/usr/include/zlib.h (dep DECLARADA) dev=63 ino=13641973 +/usr/include/stdio.h (Alpine, NO declarado) dev=63 ino=4196788 +/usr/include (el directorio) dev=61 ino=15 ← overlayfs, uno solo +``` + +Los paths del store **no existen dentro del sandbox**, y una regla sobre `/usr/include` concede +las dos cosas por igual — la evidencia dejaría de significar nada. Ni siquiera `dev` distingue: +overlayfs deja pasar el inode de la capa inferior y ambas capas están en la misma partición. + +**La clausura se enumera fichero a fichero.** Es computable sin esfuerzo: en el *host* sabemos +exactamente qué aporta cada dep (`store/-zlib/usr/include/zlib.h` → `/usr/include/zlib.h`; +la traducción es quitar el prefijo del store). `harkaq-policy.sh` lo hace hoy. + +Dos consecuencias de diseño: + +1. **`list` ≠ `ro`.** pkgconf y los tests de `configure` **necesitan** escanear `/usr/lib/pkgconfig`, + pero listar no es leer. Landlock separa `READ_DIR` de `READ_FILE`, así que se concede el + listado del directorio y se deniega la lectura de los `.pc` que la receta no declaró. Sin esta + separación, todo `ls` sería un falso positivo. +2. **Máscara por tipo.** Los derechos de-sólo-directorio (`READ_DIR`, `MAKE_*`, `REFER`, …) sobre + 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. --- @@ -590,10 +626,24 @@ vibra. - **Q1d (Fase 1, menor):** el `exe=` del registro trae la ruta **del host**. Con el sandbox real (`--bind /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):** ¿qué es exactamente el "runtime base" que toda receta necesita y ninguna - declara (`/bin/sh`, la libc del rootfs, `/opt/zig`)? ¿Va en la clausura implícitamente, o se - declara y se vuelve parte del hash de la receta? *Esta es la decisión de diseño de verdad*, y - determina si la métrica de la Fase 2 mide deuda real o ruido. +- **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. - **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**: diff --git a/scripts/harkaq/README.md b/scripts/harkaq/README.md index 1f4558b6..463f2635 100644 --- a/scripts/harkaq/README.md +++ b/scripts/harkaq/README.md @@ -128,3 +128,43 @@ 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 ...` 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. + +```sh +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ñó. diff --git a/scripts/harkaq/harkaq-exec.c b/scripts/harkaq/harkaq-exec.c index 8eecf89e..5d4aa4e2 100644 --- a/scripts/harkaq/harkaq-exec.c +++ b/scripts/harkaq/harkaq-exec.c @@ -6,8 +6,20 @@ // // Fichero de política (lo genera harkaq-policy a partir de la clausura; D1: la política se // DERIVA, no se escribe): -// ro /store/-zlib -// rw /out +// ro /usr/include/zlib.h un fichero de la clausura: leer/ejecutar SÓLO él +// ro /opt/zig un directorio: todo el subárbol +// list /usr/include listar el directorio, SIN poder leer su contenido +// rw /out +// +// El grano POR FICHERO no es capricho: el sandbox de hammer funde las deps y el rootfs Alpine +// en el mismo `/usr` (`--overlay-src` × N + `--tmp-overlay /`), así que /usr/include/zlib.h +// (dep declarada) y /usr/include/stdio.h (Alpine, no declarado) son el MISMO directorio. Una +// regla sobre /usr concede los dos y la evidencia no vale nada. En el host sí sabemos qué +// ficheros aporta cada dep ⇒ la clausura se enumera fichero a fichero. +// +// `list` existe por la misma razón: pkgconf/configure NECESITAN listar /usr/lib/pkgconfig, pero +// listar no es leer. Landlock separa READ_DIR de READ_FILE, así que se concede el listado del +// directorio y se deniega la lectura de los .pc que la receta no declaró. // // Estático a propósito: corre dentro del rootfs Alpine (musl) del sandbox. // @@ -24,6 +36,7 @@ #include #include #include +#include #include #include @@ -55,10 +68,16 @@ static int ll_restrict(int fd, __u32 f) { #define RO_RIGHTS \ (LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_EXECUTE) -struct rule { - char path[4096]; - int rw; -}; +// Derechos que Landlock SÓLO admite sobre directorios. Añadir una regla con cualquiera de +// éstos sobre un fichero regular hace que el kernel rechace la regla entera con EINVAL — y +// como la clausura es mayormente ficheros sueltos (§4), sin esta máscara no arranca nada. +#define DIR_ONLY_RIGHTS \ + (LANDLOCK_ACCESS_FS_READ_DIR | LANDLOCK_ACCESS_FS_REMOVE_DIR | \ + LANDLOCK_ACCESS_FS_REMOVE_FILE | LANDLOCK_ACCESS_FS_MAKE_CHAR | \ + LANDLOCK_ACCESS_FS_MAKE_DIR | LANDLOCK_ACCESS_FS_MAKE_REG | \ + LANDLOCK_ACCESS_FS_MAKE_SOCK | LANDLOCK_ACCESS_FS_MAKE_FIFO | \ + LANDLOCK_ACCESS_FS_MAKE_BLOCK | LANDLOCK_ACCESS_FS_MAKE_SYM | \ + LANDLOCK_ACCESS_FS_REFER) int main(int argc, char **argv) { const char *policy = NULL; @@ -92,15 +111,16 @@ int main(int argc, char **argv) { FILE *f = fopen(policy, "r"); if (!f) { perror("[harkaq] abrir política"); return 4; } char line[4200]; - int n_ro = 0, n_rw = 0; + int n_file = 0, n_dir = 0; while (fgets(line, sizeof(line), f)) { char *nl = strchr(line, '\n'); if (nl) *nl = 0; if (!line[0] || line[0] == '#') continue; - int rw; + __u64 grant; char *p; - if (!strncmp(line, "ro ", 3)) { rw = 0; p = line + 3; } - else if (!strncmp(line, "rw ", 3)) { rw = 1; p = line + 3; } + if (!strncmp(line, "ro ", 3)) { grant = RO_RIGHTS; p = line + 3; } + else if (!strncmp(line, "rw ", 3)) { grant = handled; p = line + 3; } + else if (!strncmp(line, "list ", 5)) { grant = LANDLOCK_ACCESS_FS_READ_DIR; p = line + 5; } else { fprintf(stderr, "[harkaq] política inválida: %s\n", line); return 4; @@ -114,20 +134,26 @@ int main(int argc, char **argv) { fprintf(stderr, "[harkaq] la política nombra un path inexistente: %s (%m)\n", p); return 4; } - struct landlock_path_beneath_attr pb = {.allowed_access = rw ? handled : RO_RIGHTS, - .parent_fd = dfd}; - // REFER sólo se concede sobre directorios; sobre un fichero suelto el kernel rechaza - // la regla entera con EINVAL. Reintentamos sin él antes de darlo por perdido. + // Recortar los derechos de-sólo-directorio cuando el destino NO lo es: el kernel + // rechazaría la regla entera con EINVAL. Es el caso MAYORITARIO — la clausura es una + // lista de ficheros. + struct stat st; + int is_dir = (fstat(dfd, &st) == 0) && S_ISDIR(st.st_mode); + if (!is_dir) grant &= ~(__u64)DIR_ONLY_RIGHTS; + grant &= handled; // nunca conceder más de lo que el ruleset declara manejar + if (!grant) { + fprintf(stderr, "[harkaq] regla vacía tras recortar por tipo: %s\n", p); + close(dfd); + return 4; + } + struct landlock_path_beneath_attr pb = {.allowed_access = grant, .parent_fd = dfd}; if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) { - pb.allowed_access &= ~(__u64)LANDLOCK_ACCESS_FS_REFER; - if (ll_add(rs, LANDLOCK_RULE_PATH_BENEATH, &pb, 0) < 0) { - fprintf(stderr, "[harkaq] add_rule(%s): %m\n", p); - close(dfd); - return 4; - } + fprintf(stderr, "[harkaq] add_rule(%s): %m\n", p); + close(dfd); + return 4; } close(dfd); - rw ? n_rw++ : n_ro++; + is_dir ? n_dir++ : n_file++; } fclose(f); @@ -150,8 +176,10 @@ int main(int argc, char **argv) { "ENJAULADO pero SIN EVIDENCIA; el veredicto no podrá afirmar hermeticidad.\n", abi); else - fprintf(stderr, "[harkaq] jaula puesta: ABI %d, %d ro + %d rw, logging post-exec ON\n", - abi, n_ro, n_rw); + fprintf(stderr, + "[harkaq] jaula puesta: ABI %d, clausura = %d ficheros + %d dirs, " + "logging post-exec ON\n", + abi, n_file, n_dir); execvp(cmd[0], cmd); fprintf(stderr, "[harkaq] execvp %s: %m\n", cmd[0]); diff --git a/scripts/harkaq/harkaq-policy.sh b/scripts/harkaq/harkaq-policy.sh new file mode 100755 index 00000000..e450203a --- /dev/null +++ b/scripts/harkaq/harkaq-policy.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# harkaq-policy — deriva la política de la clausura declarada (SDD 16, D1). +# +# harkaq-policy.sh [...] > politica +# +# D1: la política NO se escribe, se DERIVA. La receta ya declara sus `deps` por hash; cada dep +# es un artefacto sellado del store (`store/-/usr/...`). La clausura es, exactamente, +# el conjunto de ficheros que esas deps aportan — ni uno más. +# +# El sandbox de hammer apila cada dep con `--overlay-src` sobre el rootfs Alpine, así que el +# fichero `store/-zlib/usr/include/zlib.h` aparece dentro como `/usr/include/zlib.h`. La +# traducción es sólo quitar el prefijo del store: el resto del path YA es el path del sandbox. +# +# Por qué fichero a fichero y no por directorio: adentro, la dep declarada y el rootfs Alpine +# comparten `/usr` (medido: /usr/include/zlib.h y /usr/include/stdio.h son el mismo directorio +# overlayfs). Una regla sobre /usr concede los dos y la evidencia deja de significar algo. Los +# `list` de los directorios se emiten para que las herramientas puedan ESCANEAR (pkgconf, los +# tests de configure) sin poder LEER lo no declarado — Landlock separa READ_DIR de READ_FILE. +set -eu + +emit_dirs() { + # Los directorios que contienen ficheros de la clausura: listables, no legibles. + # Se emiten deduplicados y con sus ancestros, porque un `ls /usr` también es un escaneo + # legítimo y su denegación sería un falso positivo, no deuda de de-Alpinización. + while IFS= read -r d; do + while [ "$d" != "/" ] && [ -n "$d" ]; do + echo "list $d" + d=$(dirname "$d") + done + done | sort -u +} + +TMP="$(mktemp)" +trap 'rm -f "$TMP" "$TMP.d"' EXIT +: > "$TMP" +: > "$TMP.d" + +for dep in "$@"; do + real=$(readlink -f "$dep") + # `.hammer/` es metadata del artefacto (recipe.toml), no parte de la clausura que el build + # consume: si el build lo leyera sería una denegación legítima que queremos ver. + find "$real" -type f -o -type l | grep -v "/\.hammer/" | while IFS= read -r f; do + inside="${f#$real}" + echo "ro $inside" >> "$TMP" + dirname "$inside" >> "$TMP.d" + done +done + +echo "# harkaq-policy: clausura derivada de $# dep(s)" +sort -u "$TMP.d" | emit_dirs +sort -u "$TMP" diff --git a/scripts/harkaq/harkaq-run.sh b/scripts/harkaq/harkaq-run.sh index fc24db84..cb404868 100755 --- a/scripts/harkaq/harkaq-run.sh +++ b/scripts/harkaq/harkaq-run.sh @@ -42,9 +42,12 @@ read -r _ < "$FIFO" || true # bloquea hasta que el lector esté escuchando # valida lo que tiene que validar (que el logging post-exec funciona). Si lo hiciera harkaq-exec # antes de ejecutar, sería una denegación same-exec —que el kernel loguea por defecto— y no # probaría nada sobre el caso que importa (§3.4). -# Sin redirects: `>/dev/null` exige /dev en la clausura y, si falta, sh falla en la redirección -# y el probe no llega a correr — el error medía otra cosa (§3.5, nota de método). -CMD="cat $CANARY; $*" +# Sin redirects a /dev/null: exige /dev en la clausura y, si falta, sh falla en la redirección y +# el probe no llega a correr — el error medía otra cosa (§3.5, nota de método). +# Y sin `cat`: dependía de /bin/cat (applet de busybox); fuera de la clausura, el probe moría +# antes de probar nada. Sólo builtins: `read` + redirección abre el fichero igual y dispara el +# hook del LSM, sin depender de un solo binario del sistema. +CMD="read _ < $CANARY || true; $*" # harkaq-exec y la política van bajo /tmp (tmpfs) y NO bajo /: con `--ro-bind / /` la raíz es de # sólo lectura y bwrap no puede crear ahí el punto de montaje. Que vivan en /tmp no los mete en diff --git a/scripts/harkaq/q2-runtime-base.sh b/scripts/harkaq/q2-runtime-base.sh new file mode 100755 index 00000000..14d98122 --- /dev/null +++ b/scripts/harkaq/q2-runtime-base.sh @@ -0,0 +1,100 @@ +#!/bin/sh +# Q2 — ¿qué es el "runtime base" implícito? (SDD 16 §8) +# +# La pregunta de diseño que decide si la métrica de la Fase 2 mide deuda real de de-Alpinización +# o mide ruido. Ninguna receta declara /bin/sh, la libc o /opt/zig, y ninguna debería. Pero si el +# runtime base se define de más, la política concede Alpine entero y la evidencia no vale nada. +# +# Método: NO adivinarlo. Correr un build real con `política = clausura declarada` y dejar que +# las denegaciones lo nombren. Lo que aparezca es, por definición, lo que todo build necesita y +# nadie declara. +# +# Replica el sandbox real (`Sandbox::bwrap_args`): overlay del rootfs Alpine + las deps apiladas, +# `--tmp-overlay /`, /proc, /dev, /tmp, zig en /opt/zig, fuentes en /src, salida en /out. +set -eu +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) + +[ -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' +#include +#include +int main(void) { printf("zlib %s\n", zlibVersion()); return 0; } +EOF + +# 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" +{ + echo "rw /src" + echo "rw /out" + echo "rw /tmp" + echo "ro /opt/zig" + # BASE_EXTRA: lo que las corridas previas demostraron que hace falta. Empieza vacío y se + # llena SÓLO con lo que el kernel denunció — nunca por corazonada. Cada línea de acá es una + # respuesta medida a Q2, no una conjetura. + [ -n "${BASE_EXTRA:-}" ] && printf '%s\n' "$BASE_EXTRA" +} >> "$POLICY" + +echo "── política: $(grep -c '^ro ' "$POLICY") ro, $(grep -c '^list ' "$POLICY") list, $(grep -c '^rw ' "$POLICY") rw" >&2 + +NONCE="q2-$$" +# El canario va en la RAÍZ, no en /tmp: la política concede `rw /tmp` (es el HOME del build), así +# que un canario ahí caería DENTRO de la clausura, se leería sin problema y no generaría ninguna +# denegación ⇒ SinEvidencia eterno. El canario tiene que vivir donde la política NO alcanza, por +# construcción. En el sandbox real la raíz es escribible (`--tmp-overlay /`) y bwrap puede crear +# ahí el punto de montaje. (Lo cazó el propio D9: el lector se negó a certificar.) +CANARY="/harkaq-canary-$NONCE" +CSRC=$(mktemp); echo canario > "$CSRC" +FIFO=$(mktemp -u); mkfifo "$FIFO" +VERDICT=$(mktemp) +trap 'rm -rf "$WORK" "$CSRC" "$FIFO" "$VERDICT"' EXIT + +"$BIN/harkaq-audit" --canary "$CANARY" --timeout "${HARKAQ_TIMEOUT:-60}" --ready-fd 3 \ + 3>"$FIFO" >"$VERDICT" & +AUDIT_PID=$! +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" + +set +e +bwrap --overlay-src "$ROOTFS" --overlay-src "$DEP" --tmp-overlay / \ + --proc /proc --dev /dev --tmpfs /tmp \ + --ro-bind "$ZIG" /opt/zig \ + --bind "$WORK/src" /src --bind "$WORK/out" /out \ + --ro-bind "$CSRC" "$CANARY" \ + --ro-bind "$BIN/harkaq-exec" /tmp/harkaq-exec \ + --ro-bind "$POLICY" /tmp/harkaq.policy \ + --chdir /src --unshare-all --die-with-parent \ + --setenv PATH /opt/zig:/usr/bin:/bin \ + --setenv HOME /tmp \ + /tmp/harkaq-exec --policy /tmp/harkaq.policy -- /bin/sh -c "$CMD" +RC=$? +set -e + +sleep 2 +kill -TERM "$AUDIT_PID" 2>/dev/null || true +wait "$AUDIT_PID" 2>/dev/null || true + +echo "── build rc=$RC" >&2 +echo "── denegaciones (= candidatos a runtime base):" >&2 +python3 -c ' +import json,sys +v=json.load(open(sys.argv[1])) +print(" estado:", v["estado"], "| canario:", v["canario_visto"], "| contador kernel:", v["contador_kernel"]) +for d in v["denials"]: + print(" ", d["blockers"], d["path"]) +if not v["denials"]: print(" (ninguna)") +' "$VERDICT"