harkaq: §4 corregido (política POR FICHERO) + Q2 con método y primeros datos reales

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:20:39 -04:00
co-authored by Claude Opus 4.8
parent 4eecba06f4
commit 24c6ab455e
6 changed files with 305 additions and 33 deletions
+57 -7
View File
@@ -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/<hash>-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/<hash>-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> /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**:
+40
View File
@@ -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 <dir-dep>...` 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ñó.
+51 -23
View File
@@ -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/<hash>-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 <stdlib.h>
#include <string.h>
#include <sys/prctl.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>
@@ -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]);
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# harkaq-policy — deriva la política de la clausura declarada (SDD 16, D1).
#
# harkaq-policy.sh <dir-dep> [<dir-dep>...] > 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/<hash>-<name>/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/<hash>-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"
+6 -3
View File
@@ -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
+100
View File
@@ -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 <zlib.h>
#include <stdio.h>
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"