Files
hammer/docs/16-harkaq-jaula.md
sergioandClaude Opus 4.8 ec7ceb0915 harkaq: Q1-copy.fail RESPONDIDA — fs-verity mata el vector page-cache
El SDD 16 dejó abierto: '¿el vector page-cache de copy.fail sobrevive a un
ruleset que deniegue escritura a nivel de inode? Investigar antes de afirmar
nada'. Investigado en un fs AISLADO (loop en un worker efímero, nunca el fs del
usuario):

  escribir al artefacto sellado              → EPERM
  dd como ROOT                               → Operation not permitted (ni root)
  machacar los bytes POR DEBAJO (device)+leer→ Input/output error

El tercero ES el vector de copy.fail: modificar el fichero por debajo del fs. El
kernel detecta la corrupción AL LEER (el Merkle no cuadra) y rechaza la lectura.
⇒ fs-verity mata el vector. Landlock read-only no alcanzaba; fs-verity sí.

Costo medido, y corrige al SDD 17 §5: 'hash = identidad' es FALSO — fs-verity usa
Merkle SHA-256, hammer direcciona con BLAKE3 ⇒ son DOS hashes, no uno. Y ext4
exige -O verity Y blocksize = PAGE_SIZE (con 1024 el mount falla:
'Unsupported blocksize for fs-verity'). No es gratis, pero es barato para lo que da.

NO se tocó el fs del laptop (habilitar verity pediría tune2fs sobre la partición
del usuario): el experimento corrió en un loop device de un worker descartable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 22:17:01 -04:00

1075 lines
63 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SDD 16 — harkaq: la jaula de hammer
**Estado:** Draft 2 · **Fecha:** 2026-07-15 · **Nombre provisional:** *harkaq* ("el que detiene"; renombrable, ningún identificador depende del nombre)
> **Qué cambió del Draft 1.** El Draft 1 se escribió contra el sandbox de nix. hammer no usa nix
> para construir: usa bubblewrap (ADR 0004, `crates/hammer-build/src/sandbox.rs`). Eso invalidó
> por irrelevancia ~40% del documento — el argumento anti-nix, las FOD y su red abierta, y la fase
> del *fetcher mediado* que era el mayor riesgo de cronograma. Fase 0 (reconocimiento) se ejecutó
> y sus tres mediciones están en §3. El resultado neto es un proyecto **más chico, más barato y
> con un beneficio que cobra antes**. Lo que sobrevive intacto es el corazón: D1 (política
> derivada), D2 (evidencia negativa) y D3 (cero quieting).
**Tesis:** hammer es una distro cuyo autor es un modelo. Su sandbox (bwrap) fue diseñado para dar
*hermeticidad de facto* — aislar el build del host — no para *probar* hermeticidad ni para acotar
lo que el build usa **de adentro de la jaula**. harkaq cierra esa brecha sin pedirle política a
nadie: **la clausura de entradas declaradas de la receta ya es la política**. El subproducto es
más valioso que el producto — la ausencia de denegaciones bajo una política igual a la clausura
es *evidencia mecánica* de que lo declarado **basta**, encadenable en willay y consumible por iniy.
---
## 0. Por qué esto no lo resuelve bwrap
El sandbox actual (`sandbox.rs:224`) hace bien lo que se propuso hacer:
- `--unshare-all` — netns, pidns, mountns, ipcns, utsns, userns. **Sin red, ya hoy.**
- `--overlay-src <rootfs Alpine> [--overlay-src <dep>…] --tmp-overlay /` — base y deps de solo
lectura, escrituras a una capa tmpfs descartable.
- `--bind <src> /src`, `--bind <out> /out` — las únicas dos superficies RW reales.
- Entorno mínimo y fijado (`SOURCE_DATE_EPOCH`, `CC`, `-mcpu=baseline`, `CARGO_BUILD_JOBS=1`).
Eso da hermeticidad **respecto del host**. La brecha no está ahí. Está adentro:
> **El sandbox monta un rootfs Alpine entero como capa base del overlay.**
O sea `política ⊋ clausura`, por un margen enorme. Una receta puede usar cualquier header,
cualquier `.so`, cualquier binario que Alpine traiga — **sin declararlo en `deps`** — y el build
pasa en verde. El artefacto queda sellado y reproducible, y aun así arrastra una dependencia
fantasma que nadie escribió.
Ese no es un bug hipotético: es exactamente el bug que la campaña de **de-Alpinización** viene
cazando *a mano, receta por receta*, leyendo logs y adivinando. El patrón documentado (strip
`makedepends`, fase configure explícita, `.pc Requires``[deps].build`) es el parche manual de
un problema que el kernel puede diagnosticar solo.
**La inversión que hace hammer:** el sandbox de bwrap asume que quien escribe la receta sabe lo
que declaró. En hammer, quien escribe la receta es un modelo, la receta se importa
automáticamente desde nixpkgs/Alpine, y "lo que declaró" es precisamente la parte que falla.
Nadie más tiene ese problema porque nadie más tiene una distro cuyo autor sea un modelo.
**No-tesis:** esto no reemplaza a bwrap. harkaq **compone** con él (§5, D4): la jaula de mount y
namespaces sigue siendo de bwrap; harkaq agrega la política de grano fino y —lo que importa— el
canal de evidencia.
### 0.1 El lugar exacto en el modelo de confianza
De `hammer-core/src/recipe.rs:229`, sobre la evidencia H1 (proof-carrying recipes, SDD 15):
> *"Frontera honesta: garantiza que **lo declarado pasa**, no que **lo declarado basta**."*
harkaq es el complemento simétrico de esa frase, y por eso encaja sin forzar nada:
| | Pregunta | Mecanismo | Evidencia |
|---|---|---|---|
| **H1** | ¿lo declarado **pasa**? | checks corridos en el sandbox | positiva (los checks pasan) |
| **harkaq** | ¿lo declarado **basta**? | política = clausura, en el kernel | negativa (nadie denegó nada) |
Juntas cierran el cuadro: H1 dice que el artefacto *hace lo que dice*; harkaq dice que *no
necesitó nada que no dijo*. Ninguna de las dos pide confiar más en el modelo. Las dos piden
verificar más — que es el principio declarado de SDD 15 §0.
---
## 1. Metas
1. Ejecutar el builder bajo una política **derivada, no escrita**: `política = clausura(deps declaradas)`.
2. Producir, por cada build, un veredicto de hermeticidad con evidencia:
`hermético ⟺ política = clausura ∧ denegaciones = ∅`.
3. Emitir esa evidencia hacia willay/iniy, direccionada por blake3, como un `kind` nuevo junto a
la evidencia H1.
4. **Convertir la de-Alpinización en un diagnóstico mecánico**: cada denegación nombra el path,
el dev y el ino de lo que el build usó sin declarar.
5. Degradación explícita y declarada: en kernel sin Landlock, harkaq marca el build **sin
evidencia** — nunca finge.
## 2. No-metas
- No corre en wawa. Ver D6.
- No reemplaza el sandbox de bwrap; se compone encima. Defensa en capas.
- No es un contenedor de propósito general ni compite con bubblewrap/landrun.
- v1 no cubre macOS (hammer es musl/Linux).
- No promete contener un exploit de kernel. Ver §8.
- **No reabre ADR 0004.** hammer no adopta nix como backend de build. Esa opción sigue diferida
y harkaq no la necesita.
---
## 3. Fase 0 — Reconocimiento: EJECUTADA (2026-07-15)
El Draft 1 estimaba "días". Fueron cinco minutos, porque las tres preguntas tenían respuesta
mecánica. Las tres respuestas están medidas, no supuestas.
### 3.1 ABI de Landlock
Medido con el syscall (`landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)`), que
es la vía correcta: los kernels de otras fuentes traen features backporteadas y **el número de
versión del kernel miente** (RHEL 9.6 backporteó hasta ABI 5 sobre un 5.14).
| Dónde | Kernel | ABI | Veredicto |
|---|---|---|---|
| Laptop de desarrollo | 7.2.0-rc3-1-cachyos-rc | **10** | techo; todo disponible |
| Kernel de hammer | 6.16.12-metal (`recipes/linux.toml`) | **ausente** | `# CONFIG_SECURITY_LANDLOCK is not set` |
Lo que hace falta de cada ABI, y desde dónde:
| ABI | Kernel | Qué trae | Para harkaq |
|---|---|---|---|
| 2 | 5.19 | `FS_REFER` | **necesario** — sin refer no compila nada real |
| 3 | 6.2 | `FS_TRUNCATE` | necesario |
| 4 | 6.7 | `NET_BIND_TCP`, `NET_CONNECT_TCP` | **inerte** — no hay red que filtrar (§6) |
| 5 | 6.10 | `FS_IOCTL_DEV` | cierra ioctls a dispositivos |
| 6 | 6.12 | `SCOPE_ABSTRACT_UNIX_SOCKET`, `SCOPE_SIGNAL` | corta fuga lateral |
| 7 | 6.15 | **audit de denegaciones** | **este es el proyecto entero** |
| 8 | 7.0 | `RESTRICT_SELF_TSYNC` | robustez (todos los hilos) |
| 10 | — | `ADD_RULE_QUIET`, `quiet_access_*` | **deliberadamente NO usar** (D3) |
El hallazgo que ordena el diseño sigue siendo ABI 7: Linux 6.15 registra las peticiones denegadas
vía audit con el motivo — `domain`, `blockers` (p.ej. `fs.make_reg,fs.refer`) y la identificación
del objeto (`path`, `dev`, `ino` para filesystem; `opid`, `ocomm` para señales).
Traducción: **el mecanismo de traza que el Draft 1 iba a construir ya lo hace el kernel.** No hay
que instrumentar syscalls ni interponer un tracer. La evidencia sale del audit log del propio LSM,
que es un productor mucho más confiable que cualquier cosa en espacio de usuario.
### 3.2 El kernel de hammer: falta un flag, no una versión
`store/060b34a6…-linux-metal/boot/config-6.16.12-metal` dice:
```
# CONFIG_SECURITY_LANDLOCK is not set
CONFIG_LSM="landlock,lockdown,yama,loadpin,safesetid,selinux,smack,tomoyo,apparmor,ipe,bpf"
CONFIG_SECCOMP_FILTER=y
```
Respuesta a la Q2 del Draft 1 ("¿vale la pena forzar ≥6.15 en la distro?"): **no hace falta
forzar nada.** 6.16.12 ya trae hasta ABI 8, y `CONFIG_LSM` ya lista `landlock` de primero. Falta
sólo compilarlo: un `scripts/config -e SECURITY_LANDLOCK` junto a los otros ajustes de la fase
`configure` de `recipes/linux.toml` (`SECURITY_PATH` entra por `select`). Cuesta un rebuild
(~35min) y cambia el hash del kernel. Es el único trabajo de infraestructura del proyecto.
seccomp ya está `=y` en ambos kernels. No hay trabajo ahí.
### 3.3 Las FODs: no existen. El fetcher mediado: ya está construido
El Draft 1 dedicaba §6 entero + D8 al problema de las derivaciones de salida fija y su red
abierta, y ponía el *fetcher mediado* en Fase 3 llamándolo *"el mayor riesgo de alcance del
proyecto"*, de *"meses"*.
Ese trabajo está hecho desde siempre, por construcción:
- `crates/hammer-build/src/download.rs` corre `curl` **en el host**, fuera del sandbox.
- El builder recibe **bytes** vía `--bind <src> /src`. Nunca un socket.
- `--unshare-all` incluye `--unshare-net`: netns vacío para **todo** build, sin excepciones,
porque no existe la categoría "derivación que necesita red".
Eso *es* la jaula con ventanilla que D8 proponía construir. hammer nunca tuvo el agujero de las
FOD porque nunca tuvo FODs: separar el fetch del build fue una decisión temprana y silenciosa que
resultó ser la correcta por razones de seguridad que nadie había escrito todavía. Queda escrita acá.
**Fase 3 se elimina del plan.** El riesgo de cronograma desaparece.
### 3.4 Q1 resuelta: la evidencia llega, con dos precondiciones y un canario obligatorio
Experimento `scripts/harkaq/q1-audit.c` (2026-07-15), corrido en el laptop (ABI 10). Tres
escenarios: denegación sin `execve`, con `execve` y flags por defecto, y con `execve` +
`LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON`.
**La evidencia llega, y es exactamente la que el diseño necesita.** Un lector no privilegiado
del grupo multicast `AUDIT_NLGRP_READLOG` recibe:
```
domain=146973ef2 blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369
domain=146973ef2 status=allocated mode=enforcing pid=3359 uid=0 exe="…/q1-audit" comm="q1-audit"
```
`blockers` + `path` + `dev` + `ino` + el `exe` que lo intentó. Ese registro **es** el diagnóstico
de de-Alpinización de §0, y sale del kernel sin instrumentar nada.
**Precondición 1 — `audit_enabled=1`.** Medido: el laptop arranca con `audit_enabled=0` (sin
`audit=1` en el cmdline, sin `auditd`). Con audit apagado **no se emite ningún registro, de
ningún escenario**. Se enciende con `AUDIT_SET` por netlink (`CAP_AUDIT_CONTROL`) o con `audit=1`
en el cmdline — que para el kernel de hammer es horneable, igual que el resto del cmdline metal.
**Precondición 2 — `LOG_NEW_EXEC_ON` es obligatorio, y su ausencia es indetectable.** Medido:
| Escenario | Registros `ACCESS` |
|---|---|
| `same-exec` (sin `execve`) | **2** — logueado por defecto |
| `new-exec` (flags por defecto) | **0** |
| `new-exec-logon` (`LOG_NEW_EXEC_ON`) | **4** |
Misma jaula, misma denegación, `cat` rebotó con `EACCES` en los tres. harkaq **restringe y después
hace `execve`** del builder (bwrap → `/bin/sh -c` → make → `zig cc`): es el caso `new-exec`. Sin el
flag, harkaq certificaría como herméticos **todos** los builds, `denials=[]`, para siempre, sin un
solo error visible. El flag no es configuración: es la diferencia entre el sistema y un sello de goma.
**No hay canario gratis en el kernel.** Se investigó si el registro `status=deallocated denials=N`
podía servir de verificación cruzada. Medido, con una ventana de escucha de 6s: **un dominio con
flags por defecto cuya única denegación es post-`exec` no emite absolutamente nada** — ni
`allocated`, ni `access`, ni el `deallocated` con su contador. El contador existe **sólo para
dominios que ya loguean**. Y el `allocated` se emite **perezosamente**, pegado al primer denial
logueado (mismo evento de audit), así que un build genuinamente limpio tampoco lo produce: su
presencia no puede usarse como prueba de que los flags tomaron.
**D9 (nueva decisión cerrada, abajo): el canario lo fabrica harkaq.**
Lo que el contador sí resuelve, cuando el logging está bien puesto: exigir
`nº de registros ACCESS recibidos == denials` del `deallocated` detecta **registros perdidos** por
desborde del backlog de audit — un riesgo real con la granja construyendo en paralelo. Son dos
chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen falta los dos.
> **Nota de método, que vale más que el resultado.** La primera corrida del experimento dio 0
> registros en los tres escenarios y parecía confirmar la hipótesis. Era un bug del instrumento:
> el `AUDIT_GET` pedía `NLM_F_ACK`, el ACK llegaba antes que la respuesta (el kernel la manda
> asíncrona), el lector lo tomaba por fallo, `enabled` quedaba en `-1`, la rama que encendía el
> audit nunca corría y el kernel no emitía nada. Sólo se detectó porque el **control**
> (`same-exec`, que debía loguear) también dio 0, y eso era imposible.
>
> Es el modo de falla de harkaq, en vivo, sobre su propio banco de pruebas: un sistema cuyo
> producto es la *ausencia* de algo no se rompe ruidosamente — dice que todo está bien. El
> experimento ahora aborta con código 4 si no puede **confirmar** `audit_enabled=1`, en vez de
> reportar cero. La misma disciplina es la que D9 le impone a harkaq.
### 3.5 Q1b resuelta: la evidencia cruza el userns, y el canario es la clave primaria
Experimento `scripts/harkaq/q1b-attrib.c` (2026-07-15). Dos builds **concurrentes**, cada uno
dentro de bwrap con `--unshare-all` (los mismos ns que `Sandbox::bwrap_args`), sin privilegio
(uid 1000), con un canario de nonce distinto. El lector corre en el **host**, como root.
```
[ACCESS] domain=146973f1c blockers=fs.read_file path="/tmp/harkaq-canary-BBBB" dev="nvme1n1p6" ino=4457713
[DOMAIN] domain=146973f1c status=allocated mode=enforcing pid=8498 uid=1000 exe="…/q1b" comm="q1b"
[ACCESS] domain=146973f1f blockers=fs.read_file path="/tmp/harkaq-canary-AAAA" dev="nvme1n1p6" ino=4457713
[DOMAIN] domain=146973f1f status=allocated mode=enforcing pid=8499 uid=1000 exe="…/q1b" comm="q1b"
[DOMAIN] domain=146973f1f status=deallocated denials=1
[DOMAIN] domain=146973f1c status=deallocated denials=1
```
**(a) Los registros cruzan.** 6 registros landlock de adentro de bwrap llegaron al lector del
host. El audit no está namespaceado y se comporta como tal. ⇒ **`harkaq-audit` vive en el host,
junto a hammerd; la arquitectura de §4 se sostiene.** Y cruzan con builds **sin privilegio**, que
es como corren de verdad (si sólo cruzaran con builds root, no servirían).
**(b) El canario con nonce atribuye.** `AAAA=1`, `BBBB=1`, cero registros ajenos, con dos builds
en paralelo. ⇒ **Q1b cerrada: la granja puede construir en paralelo sin contaminarse la
evidencia.**
Tres hallazgos colaterales que valen más que el veredicto:
1. **El contador quedó validado.** Los dos `deallocated denials=1` llegaron en ventana, uno por
dominio, y cuadran con 1 `ACCESS` cada uno. El chequeo anti-pérdida de §3.4
(`nº ACCESS == denials`) está medido, no supuesto.
2. **La identidad sobrevive a los namespaces:** `pid=8498 uid=1000` son del **host** (adentro del
pidns la víctima se ve como pid 1). El audit reporta identidad del lado donde está el lector.
3. **`dev`+`ino` identifican el OBJETO, no el BUILD.** Los dos canarios reportan el mismo
`ino=4457713` (ambos bindean `/etc/hostname`). Atribuir por dev/ino habría fundido dos builds
que leen el mismo fichero no declarado — que es *el caso más común* que harkaq va a ver. **La
clave es `domain=`**; el canario es lo que la revela.
⇒ El flujo de atribución queda cerrado: **canario ⇒ aprendo el `domain=` ⇒ atribuyo por `domain=`
todo lo demás del build.** D9 hace tres trabajos con un mecanismo: honestidad (§3.4), clave
primaria (acá) y — vía el contador — detección de pérdida.
> **Nota de método (la tercera, y ya no es anécdota).** Esta corrida también falló primero, dos
> veces más, y las dos el banco reportó un resultado inventado con total confianza:
> 1. El comando de la víctima era `cat $canario >/dev/null`. `/dev` no estaba en la clausura ⇒ sh
> fallaba **en la redirección** y `cat` no llegaba a correr. El `rc=1` venía del redirect: el
> test medía una denegación que no era la que creía medir.
> 2. Corriendo como root, bwrap no pudo ni ejecutar el binario (`execvp: Permission denied`) y el
> veredicto imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en el host: replantear"* — una
> conclusión arquitectónica falsa, a partir de cero estímulo. (Causa: `--unshare-all` mapea
> sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` **en un userns
> sólo vale sobre uids mapeados** ⇒ root-en-userns no atraviesa un home `drwx------`.)
>
> El banco ahora tiene un **gate de estímulo**: si alguna víctima no sale con status 0, aborta
> (exit 4) en vez de contar registros.
>
> Van **tres** veces en una sesión —`NLM_F_ACK`, el redirect, el exec— que un cero resultó ser el
> instrumento y no el kernel, y las tres el programa afirmó con confianza algo falso. Es la
> evidencia empírica más fuerte a favor de D9, y viene del lado incómodo: *el banco lo escribió
> alguien que sabía exactamente cuál era el riesgo, mirándolo de frente, y cayó igual, tres veces.*
> harkaq sin canario no es un riesgo de configuración: es el comportamiento por defecto.
---
## 4. Arquitectura
```
receta (escrita/importada por el agente)
├─ deps declaradas (paths del store, por hash)
┌──────────────────────────────────────────────────────────┐
│ harkaq-policy (puro, testeable sin kernel) │
│ clausura(deps) → PolicySpec { │
│ 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)) │
└───────────────────────────┬──────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ harkaq-exec (musl, Rust) — DENTRO de bwrap │
│ bwrap ya dio: ns, overlay, /src, /out, sin red │
│ 1. landlock: PolicySpec → ruleset (best-effort/ABI) │
│ 2. seccomp-bpf: allowlist (D4), vía --seccomp <fd> │
│ 3. no_new_privs, drop caps, exec builder │
└───────────────────────────┬──────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ harkaq-audit (lector de AUDIT_LANDLOCK_*) │
│ EN EL HOST, junto a hammerd — FUERA de bwrap (§3.5): │
│ el audit no está namespaceado y los registros cruzan. │
│ Requiere CAP_AUDIT_READ (§8 Q1c). │
│ canario ⇒ domain= ⇒ atribuye el resto (D9) │
│ denegaciones → Denial { blockers, path, dev, ino, ... }│
└───────────────────────────┬──────────────────────────────┘
Verdict { PolicyId, recipe_hash, artifact_hash,
estado: Hermetico | Impuro{denials[]} | SinEvidencia }
blake3 ──► CAS ──► willay ──► iniy
```
Ley de dependencia: `harkaq-policy` no conoce el kernel (función pura de `Recipe``PolicySpec`,
testeable con `cargo test` en cualquier máquina, incluso sin Landlock). `harkaq-exec` no conoce
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()`. 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.
### 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.
Implementado en `harkaq-verdict.py`, **deliberadamente fuera del lector**: éste corre con
`CAP_AUDIT_READ` y clasificar es *política, no privilegio* (mismo argumento que Q1c). Las
expectativas viajan en la propia política como `# expect <path>` — una sola fuente de verdad, y
`harkaq-exec` las ignora como comentario. Medido:
```
MODE=base rc=0 Hermetico esperadas: /usr/bin/gcc deuda: ninguna ✓
MODE=zlib rc=1 Impuro DEUDA: /usr/lib/libz.so.1.3.2
```
### 4.3 ¿Aguantan los 5 paths? No — pero converge, y eso es lo que importa
Los 5 paths de §4.2 son la respuesta para *un* shape de build (C mínimo con `zig cc`). La
pregunta honesta era si sobreviven a un `configure` de autotools, que sondea el sistema entero.
Medido contra una fuente **real** (`libgpg-error`, `MODE=configure`), iterando con el método de
§4.2 (cada línea sólo tras una denegación real):
| Ronda | Deuda restante (paths únicos) |
|---|---|
| 1 | `/bin/bash`, `/bin/coreutils` |
| 2 | `libacl`, `libattr`, `libcrypto`, `libreadline`, `libutmps` |
| 3 | `libncursesw`, `libskarnet` |
| 4 | `/usr/bin/c89`, `/usr/bin/c99`, `/usr/bin/ldd`, `/usr/bin/make` |
**Respuesta: no, el runtime base es por forma de build.** Un `configure` necesita ~16 entradas,
no 5. Pero las tres cosas que importan salieron bien:
1. **Converge, y rápido** — 3 rondas, de 34 denegaciones a 5. No es una cola infinita.
2. **Se queda chico** — ~16 entradas, no cientos. La métrica de <5% de la Fase 2 sigue siendo
plausible.
3. **El runtime base no es una lista de binarios: es su CIERRE de `.so`.** Conceder `/bin/bash`
arrastra `libreadline`+`libncursesw`; conceder `/bin/coreutils` arrastra `libacl`+`libattr`.
Eso es *computable*, no adivinable ⇒ **ya está derivado** (`harkaq-base-closure.py`). Es D1
aplicado al runtime base: mantenerlo a mano sería exactamente el "alguien mantiene un perfil
de permisos" que D1 dice que mata a todos los sandboxes.
No usa `ldd` (resolvería contra el host, no contra el rootfs Alpine): lee los `DT_NEEDED` del
ELF y resuelve dentro del rootfs por las rutas de musl. **Validación:** el cierre derivado de
`{sh, busybox, bash, coreutils, env}` reproduce **exactamente** las 7 librerías que las rondas
2 y 3 habían descubierto a mano, en una sola pasada, y además caza los symlinks y
`libc.musl-x86_64.so.1` que se habían escapado. Emite symlink **y** destino: el kernel denuncia
el fichero real (`libz.so.1.3.2`, no `libz.so.1`), pero el build abre por el nombre corto.
### 4.4 El diagnóstico sobre un `configure` real, con el entorno fiel
Con el entorno del sandbox real replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`,
`LC_ALL=C`…), `configure` llega mucho más lejos y el diagnóstico se vuelve el que el proyecto
prometía. **205 denegaciones del kernel**, y tras clasificar:
```
esperadas (170): /usr/bin/gcc, /usr/bin/ldd ← sondas de compilador
DEUDA (34) en 8 paths:
/usr/bin/ld /usr/bin/nm /usr/bin/objdump /usr/bin/strip ← binutils de ALPINE
/usr/bin/make /usr/bin/getconf
/usr/lib/gcc/x86_64-alpine-linux-musl ← libdir del gcc de ALPINE
/opt ← (bug de política, ver abajo)
```
**Esto es la tesis del §0 hecha dato.** Un `configure` que se creía hermético está yendo a buscar
los binutils y el libdir de gcc de Alpine. Y la clasificación es lo que lo hace legible: sin ella
son 205 denegaciones planas y el hallazgo queda enterrado; con ella son **8 paths accionables**.
Conecta con dos frentes abiertos, y en ambos la lista coincide: los swaps del `selfhost-verify`
(`SWAP_COREUTILS`, las herramientas en el path) y la campaña *matar gcc* (`recipes/binutils.toml`
existe justamente porque zig provee `as/ld/ar` pero el resto se toma de Alpine).
**Bug de política encontrado por el propio experimento:** `/opt` aparece como deuda porque la
política concede `ro /opt/zig` pero no deja **listar** el padre. Regla general: **todo directorio
concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso positivo.
`harkaq-policy.sh` ya lo hace para la clausura de las deps; falta hacerlo para las superficies de
contrato.
**Y el residuo es todo significativo, ninguno ruido:**
- `c89`, `c99`, `ldd` — la misma familia que `gcc` (§4.2): sondas de compilador de Alpine. Van a
**denegación esperada**; concederlas sería dejar que `configure` compile con Alpine.
- `/usr/bin/make`**la decisión de diseño de verdad**, y no la toma harkaq. Hoy los builds de
hammer usan el `make` de Alpine sin declararlo. O es runtime base (se acepta y se escribe), o
es deuda (y se declara como dep). Nótese que esto es *exactamente* lo que los swaps del
`selfhost-verify` reemplazan uno a uno: **la lista de deuda de harkaq y la lista de swaps del
bootstrap son la misma lista, descubierta por dos caminos.** Que coincidan es la mejor
validación externa que tiene el método.
---
## 5. Decisiones cerradas
### D1 — La política se deriva, no se escribe
Es el corazón, y sobrevive al Draft 1 sin un rasguño. Todo sandbox del mundo muere en la
ergonomía: alguien mantiene a mano un perfil de permisos, nadie lo revisa, todos terminan en
permisivo. hammer no tiene ese problema porque **la receta ya declara sus deps por hash**.
`clausura(deps)` es el conjunto exacto de rutas de solo lectura. Cero configuración humana,
precisión igual a la del direccionamiento por contenido.
Corolario: no existe "modo permisivo". Una receta que necesita más de lo que declara está mal
escrita, y ese es exactamente el diagnóstico que queremos que el agente reciba — hoy lo
producimos leyendo logs a mano y llamándolo de-Alpinización.
### D2 — La evidencia es negativa, y por eso es barata
**No se traza lo permitido.** La tentación es loguear todo acceso para "probar" qué tocó el build;
eso exige fanotify o eBPF, cuesta rendimiento y produce un río de datos que nadie audita.
La formulación correcta es la contrapositiva:
> `hermético(build) ⟺ política = clausura(deps) ∧ denegaciones = ∅`
Si la política concede *exactamente* la clausura declarada y el kernel no denegó nada, entonces el
build no usó nada fuera de lo declarado. **La política es la afirmación; la ausencia de
denegaciones es la prueba.** Coste de la traza: un lector de audit y una lista casi siempre vacía.
Esto convierte la reproducibilidad de hammer, que hoy es una promesa arquitectónica verificable
sólo rebuildeando, en un certificado por build.
### D3 — Cero quieting. Las denegaciones son el producto
ABI 10 permite suprimir selectivamente logs de accesos denegados por objeto (`LANDLOCK_ADD_RULE_QUIET`,
campos `quiet_access_*`). Es una feature excelente para sandboxear apps de escritorio ruidosas —
y **veneno para harkaq**. Silenciar una denegación es destruir la única evidencia que produce el
sistema. En harkaq `quiet_*` está prohibido por diseño; una denegación esperada no se calla, se
*clasifica* en el `Verdict`.
(De la propia doc del kernel: el objeto queda marcado como quiet si al menos una llamada lo pidió,
y llamadas posteriores sin el flag no lo limpian — es pegajoso. Razón extra para no tocarlo.)
Este laptop reporta ABI 10, así que la feature está a un flag de distancia. La disciplina es no
usarla.
### D4 — Landlock no basta; seccomp es obligatorio, no opcional
> **Corregida al implementarla (2026-07-15).** El texto de abajo pedía **allowlist** por syscall.
> Lo implementado es una **denylist**, y el cambio es deliberado: un allowlist para builds
> *arbitrarios* —compiladores, make, shells, linkers, perl— es un blanco móvil que cada
> herramienta nueva rompe. Es el riesgo del §7 ("los falsos positivos matan proyectos de
> sandboxing") aplicado a las syscalls, y con peor final: un build que muere por una syscall
> legítima no da un diagnóstico útil, da un misterio. Lo *peligroso*, en cambio, es enumerable y
> estable: no hay build honesto que cargue módulos, haga kexec o attachee un ptrace.
>
> Denegadas (en `harkaq-exec.c`): `io_uring_*`, `ptrace`, `process_vm_{readv,writev}`, `bpf`,
> `userfaultfd`, `keyctl`/`add_key`/`request_key`, `{init,finit,delete}_module`, `kexec_*`,
> `perf_event_open`, `mount`/`umount2`, la familia `open_tree`/`move_mount`/`fs*`, `setns`.
> **`pivot_root` NO se deniega**: bwrap lo usa *antes* de llegar a harkaq-exec.
>
> **EPERM, no KILL:** matar el proceso deja un cadáver sin explicación; EPERM deja al build
> fallar donde corresponde y al log decir por qué. Se instala **después** de `no_new_privs` y de
> Landlock, justo antes del `exec`, y se hereda por fork/exec igual que el dominio (D5). Si el
> kernel lo rechaza, **no se corre el build** (D7: fallo cerrado — media jaula creyéndose entera
> es peor que ninguna).
>
> **Comprobado:** `ptrace` → `EPERM` bajo la jaula; y un build real de zlib sale `Hermetico` ×3
> con **el hash del artefacto idéntico** al de antes de seccomp (`b3:adc5c251…`). Añadir media
> jaula no movió un byte — como debe ser: esto recorta superficie de escape, no cambia el build.
>
> Nota: el `--seccomp <fd>` de bwrap que se menciona abajo terminó **sin usarse**. `harkaq-exec`
> ya corre dentro y con `no_new_privs` puesto, así que instala el filtro él mismo — una pieza
> menos de plumbing y el filtro queda al lado de la política que lo justifica.
Landlock cubre fs, puertos TCP e IPC scoping. No cubre `ptrace`, `bpf`, `userfaultfd`, `keyctl`,
`io_uring`, montajes, ni la mitad de la superficie interesante. seccomp-bpf con allowlist por
syscall es la otra mitad de la jaula.
Buena noticia de composición: **bwrap acepta `--seccomp <fd>`**. No hay que reemplazar el
launcher; se le pasa el filtro al que ya tenemos. `CONFIG_SECCOMP_FILTER=y` en ambos kernels.
`io_uring` va explícitamente denegado en v1 (superficie de kernel enorme, y bypassea buena parte
del análisis por-syscall). Si un builder lo necesita, que lo justifique.
### D5 — Herencia y TSYNC
Las restricciones de Landlock se heredan automáticamente por `fork()` y `exec()`, así que todo el
árbol de procesos del build queda contenido sin trabajo extra — que es exactamente lo que hace
falta cuando `Sandbox::run` lanza `/bin/sh -c` que lanza make que lanza `zig cc`. Con ABI 8
disponible (6.16.12 lo tiene), usar `RESTRICT_SELF_TSYNC` para cubrir todos los hilos.
### D6 — harkaq es órgano de hammer, no de la suite. Rompe la paridad wawa, y está bien
Landlock, seccomp y cgroups son Linux puro; en `x86_64-unknown-none` no existen. Esto viola el
principio de paridad Linux/wawa que gobierna el resto del ecosistema, y hay que decirlo en voz
alta antes de que alguien lo cobre.
El costo es aceptable porque **el bucle de build del agente es una actividad Linux por
definición**. wawa nunca compila nada: consume artefactos atestados del CAS. La frontera de
confianza de wawa es la atestación; la de hammer es la jaula. Son mecanismos distintos para
posiciones distintas del pipeline, y eso es coherente, no inconsistente.
### D7 — Fallo cerrado, y honesto sobre el ABI
- `abi_minima = V3` para intentar el build (sin `REFER` y `TRUNCATE` no compila software real).
- `abi_evidencia = V7` para emitir un `Verdict` con evidencia.
- Entre V3 y V7: el build corre, el `Verdict` sale `SinEvidencia`, iniy lo trata con
`uncertainty` alta en vez de creerle.
- Sin Landlock (el kernel de hammer **hoy**): `SinEvidencia`, y el build corre igual bajo el bwrap
de siempre. harkaq es inerte, no un muro.
Nunca se emite evidencia que el kernel no respaldó. Se consulta el ABI con el syscall, jamás
`uname`.
### D8 — La red ya está cerrada; harkaq no la toca
(Reemplaza al D8 del Draft 1, que trataba de las FOD.)
`--unshare-all` ⇒ netns vacío ⇒ los derechos de red de ABI 4 son **inertes**. No se implementan.
Si algún día un build necesitara red, la respuesta no es abrir el netns: es extender el fetcher
del host (§3.3), que es el embudo con hash donde ancla el CAS y donde willay puede auditar.
`SCOPE_ABSTRACT_UNIX_SOCKET` (ABI 6) sí se usa: cierra la fuga simétrica hacia sockets abstractos.
Ojo con la semántica documentada — un socket datagram ya conectado antes del scoping puede seguir
enviando; y el scoping no admite reglas: si un dominio está scoped, no hay forma de permitir un
recurso puntual fuera. Es todo-o-nada, lo cual para nosotros está bien.
### D9 — El canario deliberado: `denials=[]` no se cree, se gana
(Nace de la medición de §3.4. Es la decisión que Q1 obligó a tomar.)
**El problema.** Un build hermético y un lector ciego producen exactamente los mismos bytes:
`denials=[]`. Y §3.4 midió que el kernel **no ofrece ninguna forma de distinguirlos** — un dominio
sin logging es invisible, y el registro `allocated` es perezoso, así que su ausencia tampoco
prueba nada. Sin resolver esto, D2 (la evidencia negativa) no vale nada: la afirmación más fuerte
del sistema sería indistinguible de su falla más común.
**La decisión.** Cada build provoca una denegación conocida, **después del `execve`**, y harkaq
**exige verla**. El canario es un path que existe en el sandbox y que la política deja
deliberadamente fuera de la clausura (p.ej. un `--ro-bind` a una ruta fija que el ruleset no
concede). El probe se antepone al comando del builder — misma vía que `Sandbox::run` ya usa
(`/bin/sh -c`), así que corre en el dominio y del lado correcto del `exec`.
**El veredicto pasa a ser de tres estados, no de dos:**
| Canario | Otras denegaciones | Veredicto |
|---|---|---|
| visto | ∅ | `Hermetico` — y *ahora sí* significa algo |
| visto | ≥1 | `Impuro { denials }` — el diagnóstico útil |
| **no visto** | cualquiera | **`SinEvidencia`** — el lector no es confiable; no se afirma nada |
El canario cubre la mala configuración (flag ausente, audit apagado, lector caído). El contador
`deallocated denials=N` de §3.4 cubre la otra falla, la pérdida de registros por backlog: se exige
`nº de ACCESS == N`. Dos chequeos, dos fallas, ninguno redundante.
**Y el canario es además la clave primaria de la evidencia** (medido en §3.5). Con un nonce único
por build, su registro revela el `domain=` de ese build, y `domain=` ata todos los registros
restantes del mismo. Sin él no hay atribución fiable con la granja construyendo en paralelo: el
`pid=` viaja en el `allocated`, que es **perezoso**, y `dev`+`ino` identifican el **objeto**, no el
build — dos builds leyendo el mismo fichero no declarado dan el mismo `ino`, que es justo el caso
más común que harkaq va a ver. Un mecanismo, tres trabajos: honestidad, atribución y detección de
pérdida.
**Por qué es un no-negociable.** Es la misma disciplina de D7 (fallo cerrado) y de D3 (cero
quieting), aplicada al único punto donde el sistema podría mentirse a sí mismo en silencio. Y no
es hipotético: el banco de pruebas de Q1 ya cayó en esa trampa una vez (§3.4, nota de método).
---
## 6. Fases
**Fase 0 — Reconocimiento.****CERRADA (2026-07-15)**. Ver §3. Salidas: ABI 10 en el laptop,
Landlock ausente-pero-a-un-flag en el kernel de hammer, cero FODs y fetcher ya construido.
**Fase 1 — El hito demostrable (una tarde).**
Precondiciones, todas ya medidas en §3 y ninguna especulativa:
1.**`-e SECURITY -e SECURITY_LANDLOCK -e AUDIT` en `recipes/linux.toml`** (2026-07-15). Es lo
único que faltaba para que harkaq corra **dentro** de hammer (VM/metal) y no sólo en el laptop
y la granja. `CONFIG_LSM` del defconfig ya lista `landlock` de primero ⇒ bastó encenderlo, sin
tocar la cadena de LSMs. 6.16.12 da **ABI 7** = el audit de denegaciones, que es de lo que
cuelga toda la evidencia. `AUDIT` ya venía `=y`; se fija explícito porque **sin él no se emite
un solo registro y harkaq certificaría todo como hermético en silencio** (§3.4). Cuesta un
rebuild (~35min) y cambia el hash del kernel.
2. `audit=1` horneado en el cmdline del kernel metal — o `AUDIT_SET` desde hammerd (§3.4, Q1c).
3. `LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON` en el `restrict_self` (§3.4). **Sin esto no hay nada.**
4. El canario de D9 antepuesto al comando del builder.
Después: tomar **una** receta real de hammer. Derivar su política de la clausura. Correrla bajo
Landlock+seccomp dentro del bwrap actual. Emitir el `Verdict` con `denials = []` **y el canario
visto**.
**✅ HITO DE FASE 1 CUMPLIDO (2026-07-15).** `hammer build` sobre una receta **real del catálogo**
(`recipes/zlib.toml`), con la política derivada de la clausura declarada y el `Verdict` emitido
**por fase**:
```
configure → Hermetico esperadas (14): /usr/bin/gcc deuda: ninguna ✓
compile → Impuro DEUDA (1): /usr/bin/make → build rc=126 (la jaula lo frenó)
```
El diagnóstico completo de zlib cabe en una frase: **lo único que toma de Alpine sin declararlo
es `make`**. Wiring en `crates/hammer-build/src/harkaq.rs`, detrás de `HARKAQ=1` e **inerte** sin
él (requisito duro: 700+ artefactos sellados no pueden cambiar de hash por encender un
diagnóstico). `harkaq-policy` es puro y se testea sin kernel (4 tests), como pide §4.
La integración se delató sola en su primera corrida: faltaba `/cache` (superficie de contrato —
`ZIG_GLOBAL_CACHE_DIR=/cache/zig`, zig **crea** directorios ahí) y la fase moría con
`fs.make_dir /cache/zig/tmp`. De ahí que las superficies de contrato las decida el `Sandbox`, que
es quien sabe qué montó, y no harkaq: `/cache` sólo existe si hay `cache_dir`.
**Estado previo — la cadena corre de punta a punta.** `harkaq-audit` (lector, host,
`CAP_AUDIT_READ`) + `harkaq-exec` (política, estático, dentro de bwrap) + `harkaq-run.sh`
(orquesta y pone el canario). Los dos actos, con comandos sintéticos:
```json
limpio {"estado":"Hermetico","canario_visto":true,"contador_kernel":1,"denials":[]}
impuro {"estado":"Impuro","canario_visto":true,"contador_kernel":2,
"denials":[{"blockers":"fs.read_file","path":"…/README.md","dev":"nvme1n1p3","ino":"2919408"}]}
```
Canario visto y contador coherente en ambos ⇒ los tres estados de D9 funcionan sobre el kernel
real. Precondiciones 24 ✅. Falta: la precondición 1 (sólo para que harkaq corra *dentro* de
hammer — el hito se demuestra en el laptop, que ya está en ABI 10), `harkaq-policy` derivando de
la clausura real, y el contraste receta-de-Alpinizada vs. receta-que-no.
**Sutileza medida: lo que DAC ya bloquea es invisible.** Un acceso que los permisos Unix rechazan
no genera registro de Landlock (el hook del LSM no llega a correr). No rompe D2 — si DAC lo
bloqueó, el build tampoco lo usó, así que `Hermetico` sigue siendo cierto — pero acota el
*diagnóstico*: harkaq no reporta el intento. Para la de-Alpinización es inocuo (el rootfs Alpine
es world-readable); para escribir tests es una trampa (un escenario "impuro" sobre un path que DAC
ya prohíbe da `Hermetico` y el control no controla nada).
Luego correr **una receta que sepamos no de-Alpinizada** y mostrar que la jaula nombra exactamente
qué usó sin declarar, con path/dev/ino.
**Ese par de corridas es el argumento entero del proyecto**, y se enseña en dos minutos.
> Cambio respecto del Draft 1: el segundo acto era una derivación de juguete que leía
> `/etc/passwd`. Una receta real no de-Alpinizada es *mejor demo* (es el problema que de verdad
> tenemos) y de paso le entrega valor inmediato a una campaña en curso.
### 4.5 Deuda *declarable* vs *irreducible*: la métrica del <5% medía otra cosa
`zlib` salió `Impuro` por `/usr/bin/make` y parecía runtime base. **No lo es:**
`recipes/make.toml` existe, `store/fbad44ac…-make` está sellado y `SWAP_MAKE` es uno de los swaps
del `selfhost-verify`. O sea que a zlib **le falta una línea en `[deps]`**, no una excepción.
Esa distinción rehace la métrica del §7, que hablaba de "recetas que necesitan excepción":
| | Qué | Cuenta contra el <5% |
|---|---|---|
| **declarable** | el store ya provee el path ⇒ una línea en `[deps]`, arreglo mecánico | **no** |
| **irreducible** | nadie lo provee ⇒ escribir receta, o aceptar como runtime base | **sí** |
`harkaq-suggest.py` cruza cada path de deuda contra el store (el mapeo de §4.1, al revés: el path
relativo dentro del artefacto **es** el path del sandbox) y lo dice:
```
DEUDA DECLARABLE — el store ya lo provee; falta la línea en [deps]:
/usr/bin/make → declarar dep: make
DEUDA IRREDUCIBLE — nadie la provee:
/usr/lib/libz.so.1.3.2
```
Las dos son correctas y la segunda es sutil: la receta de zlib de hammer produce `libz.a`
estático, no un `.so`, así que un consumidor que quiera el shared **no** se arregla declarando.
El diagnóstico deja de ser "algo pasó" y pasa a ser "hacé esto".
### 4.6 Primer barrido de Fase 2 (6 recetas)
`scripts/harkaq/fase2-barrido.sh`. Muestra chica y deliberadamente fácil (C, fuentes cacheadas):
| | |
|---|---|
| Hermetico | 0 |
| sólo deuda **declarable** | **5** — y la deuda es *el mismo path en las 5*: `/usr/bin/make` |
| con deuda **irreducible** | **1** (brotli) |
**El resultado que importa:** la deuda de 5 de 6 recetas es **un solo path**, y se arregla con una
línea. No hay una cola larga de excepciones — que era el riesgo de muerte del §7.
**Y el caso irreducible es la frontera conocida:** brotli (C++) pide
`/usr/lib/libstdc++.so.6.0.34` y `/usr/lib/libgcc_s.so.1` — el runtime C++ del gcc de Alpine, que
hammer no construye. Es exactamente la frontera que la campaña *matar gcc* ya tenía identificada
("gcc retenido sólo para {kernel, cmake}", "lo que queda: los `*-sys` con C++"). **Tercera vez que
harkaq llega por su cuenta a una lista que otro frente ya tenía**: los swaps del `selfhost-verify`
(§4.3), los binutils (§4.4) y ahora el runtime C++.
Honestidad sobre la muestra: 6 recetas no son 750, y son las fáciles. 1/6 irreducible es 17%, muy
por encima del <5% — pero el único caso es un problema *conocido, nombrado y compartido con otros
dos frentes*, no una cola de sorpresas. El barrido grande es trabajo de granja, no de diseño.
**Bug del barrido, encontrado por el propio barrido:** copiar la receta a otro directorio rompe la
resolución de `deps.build` (son relativas al directorio de la receta) ⇒ brotli fallaba con "no
pude cargar la dep 'cmake'" y se descartaba en silencio. Se descartaban **justo las recetas con
deps, que son las interesantes**. La copia va al lado del original.
### 4.7 El bucle cerrado: de `Impuro` a `Hermetico` sobre una receta real
Diagnosticar no vale nada si no se puede accionar. El bucle completo sobre `recipes/zlib.toml`,
cada paso guiado **sólo** por lo que dijo harkaq:
| Paso | Veredicto | Lo que dijo harkaq | Acción |
|---|---|---|---|
| zlib tal cual | `Impuro` | `/usr/bin/make`*declarar dep: make* | `[deps] build = ["make"]` |
| + make | `Impuro` | `/usr/bin/ranlib`*declarar dep: binutils* | `build = ["make","binutils"]` |
| + binutils | `Impuro` | `liblto_plugin.so`*irreducible* | clasificar (ver abajo) |
| final | **`Hermetico` ×3 fases** | — | artefacto `b3:adc5c251…` sellado |
**Cada capa que se pela destapa la siguiente, y todas convergen en el gcc de Alpine.** El último
hallazgo es el más interesante y no lo habría encontrado a mano: **los binutils *de hammer* — ya
declarados como dep — cargan el plugin LTO del gcc *de Alpine*
(`/usr/libexec/gcc/x86_64-alpine-linux-musl/15.2.0/liblto_plugin.so`)**. No es un problema de
declaración de la receta: es un agujero de soberanía *dentro de un artefacto que hammer construye*.
Va a **denegación esperada**, y por la misma razón que `/usr/bin/gcc` (§4.2): el build completa sin
él ⇒ es una *sonda*, no una necesidad, y **bloquearla es deseable** — impide que los binutils de
hammer usen el plugin de Alpine por detrás. D3 otra vez: no se calla, se clasifica.
**Detalle que confirma el modelo:** el hash del artefacto es **idéntico** antes y después de
clasificar la sonda (`b3:adc5c251…` las dos veces). Clasificar cambia el *veredicto*, no el
*build*. Es el principio de H1 sosteniéndose solo: **la evidencia es comportamiento, no
identidad** (`recipe.rs:228`, y por eso `Evidence` está fuera de `hash_inputs`).
**Y esto es lo que `Hermetico` significa ahora, con todo el peso:** el kernel certificó que ese
build no usó **nada** fuera de su clausura declarada, y el canario prueba que el certificado no es
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.
### 4.11 …y perl ya existía. El barrido queda en **0 irreducibles**
El comentario de `recipes/ca-certificates.toml` lo decía con todas las letras, escrito por quien
la portó:
> *"El resto del build (mk-ca-bundle.pl → cert.pem → split-ca-bundle.sh) usa **el perl +
> coreutils del rootfs**"*
**La deuda estaba escrita en prosa y era invisible para el sistema.** harkaq la convirtió en un
veredicto. Ésa es la tesis del §0 en una línea: no hace falta que nadie *descubra* nada — hace
falta que la máquina pueda *ver* lo que ya se sabía.
Y la receta también existía: `recipes/incoming-kde/perl.toml`, importada el 13/07 para la campaña
KDE (`syntax-highlighting` e `intltool` exigen perl). **Cuarta convergencia independiente**: dos
campañas necesitaban el mismo binario y llegaron a él por caminos que no se hablan. Construye tal
cual (`b3:c7a899bd…`), y con el artefacto en el store:
```
/usr/bin/perl → declarar dep: perl exit=0 ⇒ CERO deuda irreducible
```
**El barrido de 24 recetas queda en 0 irreducibles.** La métrica del §7 (<5%) se cumple —
midiendo lo que la métrica quería medir: *cosas que no sabemos construir*. Resultado: ninguna.
La receta queda promovida a `recipes/perl.toml`. Lo que queda es mecánico y no es diseño:
declarar `make` en las ~10 recetas que lo usan y `perl` en ca-certificates/curl. **Ojo:** eso
re-hashea sus artefactos, y son paquetes del base-system (índice firmado 748) ⇒ es una decisión de
rollout, no un `sed`.
### 4.9 Matar gcc: la última milla, cerrada
El primer barrido (§4.6) dejó **un solo** caso irreducible: brotli pidiendo
`libstdc++.so.6.0.34` + `libgcc_s.so.1`. brotli es **C**, no C++ — la libstdc++ no era suya.
Era de `cmake`, su dep de build. El artefacto que **hammer construye**:
```
store/99864dcc…-cmake/usr/bin/cmake
NEEDED: libstdc++.so.6, libgcc_s.so.1, libc.so
```
Y la receta lo explicaba sin verlo: `CC=gcc CXX=g++` *"el cmake mínimo que `bootstrap` compila con
`zig c++` SEGFAULTEA al correr"*. O sea que "gcc retenido para {kernel, cmake}" no era una
concesión de *build-time* como sonaba: **el artefacto de cmake arrastraba el runtime C++ de Alpine
hacia dentro de cada build que lo declarara como dep**. Un agujero de soberanía que viajaba por el
grafo de deps, invisible en la receta del consumidor.
**El arreglo no toca el compilador** — gcc sigue compilando cmake, que es el camino conocido-bueno.
Sólo deja de enlazar su runtime en dinámico:
```
CXX='g++ -static-libstdc++ -static-libgcc' LDFLAGS='-static-libstdc++ -static-libgcc'
```
Medido:
| | NEEDED | |
|---|---|---|
| antes | `libstdc++.so.6`, `libgcc_s.so.1`, `libc.so` | arrastra el runtime de gcc de Alpine |
| después | `libc.musl-x86_64.so.1` | **y corre: `cmake version 3.31.6`** |
**Y el pago:** brotli —el único irreducible del barrido— pasa a **`Hermetico` ×3 fases**, artefacto
sellado `b3:bc900676…`. Su deuda restante (`ld`, `make`) era declarable y se declaró.
El barrido queda **0 irreducibles de 6**. El caso que iba a contar contra el <5% no era una receta
mal escrita: era una herramienta de hammer filtrando Alpine, y harkaq la señaló desde el consumidor.
**Rollout: ✅ HECHO (2026-07-15).** cmake reconstruido en un worker de la granja (regla: la cadena
GUI no se rebuildea en el laptop) y cosechado al hub: `b3:834132e7…-cmake`, `NEEDED` = sólo
`libc.musl-x86_64.so.1`. El viejo (`99864dcc…`, con `libstdc++.so.6`+`libgcc_s.so.1`) queda en el
store por si algo lo referencia.
**Los 10 consumidores NO se reconstruyeron, y no hace falta:** el fix quita la dependencia de
runtime del artefacto *de cmake*. Los consumidores sólo lo usaron como *herramienta de build*
sus artefactos no linkean `libstdc++`. Rebuildearlos sólo daría frescura de hash, y eso ocurre
solo en su próximo build. Confundir "usó la herramienta" con "linkea la librería" habría costado
un rebuild de gtk4 para nada.
**Regalo del rollout:** el worker (Ubuntu, ccx23) y el laptop (CachyOS) produjeron cmake con el
**mismo hash, bit a bit** — el invariante central de hammer confirmándose entre dos máquinas
distintas, sin que nadie lo buscara. Y como los bytes son idénticos, el `NEEDED` del worker está
verificado por transitividad: no hizo falta `readelf` allá (que además no está instalado).
**Lo que NO cierra esto:** `/usr/bin/gcc`, `c89`, `c99`, `ldd` y el plugin LTO de §4.7 siguen
siendo *sondas* — la jaula las deniega, los builds completan igual, y denegarlas es lo correcto.
El gcc de Alpine sigue en el rootfs y sigue haciendo falta para {kernel, cmake} **en build-time**.
Lo cerrado es la filtración a *runtime*, que es la que contaminaba artefactos.
### 4.8 El barrido NO sale por la granja VPS (todavía): la imagen golden es ABI 4
Medido en `hworker-4` (2026-07-15) con el syscall, que es la única vía válida:
```
kernel 6.8.0-134-generic (Ubuntu 24.04) → LANDLOCK ABI = 4
CONFIG_SECURITY_LANDLOCK=y CONFIG_AUDIT=y sin auditd compitiendo
```
Landlock está compilado y el audit también, y no hay `auditd` peleando por el netlink — pero
**ABI 4 < 7 ⇒ no hay audit de denegaciones**. Por D7 la granja correría la jaula y emitiría
`SinEvidencia` en **todos** los builds: exactamente lo que el diseño manda hacer, y exactamente lo
que no sirve para medir.
**El arreglo es chico y está identificado:** `linux-image-generic-hwe-24.04`**6.17.0-40** está
en el archivo estándar de Ubuntu 24.04 (no hace falta PPA ni cambiar de distro). Un `apt install`
+ reboot + verificar ABI + re-snapshot de la imagen golden (`IMAGE=405120842`). El barrido grande
depende de ese bump, no de más diseño.
Nota operativa: `hworker-4` está vivo con la campaña KDE; el bump conviene hacerlo en un worker
efímero nuevo (`farm-up 1`) y re-snapshotear desde ahí, sin tocarlo.
---
**Fase 2 — `harkaq-policy` completo + medir la brecha.** Clausura real, `/src`, `/out`, `/tmp`,
`/cache`, `/proc` y `/dev` mínimos, y el **runtime base** (sin `/bin/sh` ni la libc del rootfs no
arranca nada — ver Q3). Correr contra N recetas del catálogo y medir la tasa de falsos positivos.
*Métrica de éxito: <5% de recetas necesitan excepción.*
El delta entre `política = clausura` y `política = clausura + base Alpine` **es** la medida
cuantitativa de la deuda de de-Alpinización del catálogo. Ese número no existe hoy.
**Fase 3 — El bucle epistémico.** `Verdict → willay → iniy`, como `kind` nuevo junto a la
evidencia H1 (§0.1). Aquí el agente empieza a recibir su propio reflejo: una receta cuyo build
denegó cosas es una receta de la que el sistema desconfía, y esa desconfianza es un número, no una
vibra.
*(La Fase 3 del Draft 1 —el fetcher mediado— se eliminó: ya existe. Ver §3.3.)*
---
## 7. Riesgos y honestidades
- **El kernel es la TCB.** harkaq no contiene un exploit de kernel. Landlock+seccomp reduce
superficie; no la elimina. Si el modelo de amenaza incluye "la IA encuentra un 0-day de kernel",
la respuesta correcta es microVM, no LSM. Decir esto en el README, no en letra chica.
- **La entrega de la evidencia es el único riesgo técnico serio que queda.** Ver Q1. Todo el
proyecto cuelga de poder leer los registros `AUDIT_LANDLOCK_*` de forma confiable desde el
proceso que orquesta el build. Si eso no se resuelve limpio, harkaq queda sin producto.
- **Falsos positivos matan proyectos de sandboxing.** Si el 30% de los builds necesita excepción
manual, harkaq se desactiva y queda como decoración. La Fase 2 existe para descubrir esto
temprano y barato. Atenuante: acá un "falso positivo" suele ser un **verdadero positivo de
de-Alpinización**, y el catálogo tiene ~750 recetas para probarlo.
- **Domesticar el vocabulario.** harkaq no prueba que un build fue hermético. Prueba que *bajo el
supuesto de que el kernel y la derivación de política son correctos*, el build no accedió fuera
de su clausura declarada. Es una afirmación fuerte y defendible. La versión sin asteriscos es la
que te van a cobrar. (Mismo criterio que SDD 15 §0: "verificado bajo supuestos nombrados".)
- **Competencia.** Como proyecto OSS genérico, "sandbox para agentes" es un espacio poblado (ya
existe landrun) y la ventana no es infinita. Como el ejecutor de hammer, no compite con nadie: el
diferenciador no es la jaula, es que **la política sale del hash y la evidencia entra al grafo de
confianza**. Nadie más tiene CAS + log monótono + grafo para cerrar ese bucle.
- **El rebuild del kernel cambia su hash.** Encender `SECURITY_LANDLOCK` invalida
`060b34a6…-linux-metal` y lo que dependa de él. Es esperado y barato, pero hay que agendarlo, no
descubrirlo.
---
## 8. Preguntas abiertas
- **Q1-copy.fail — ✅ RESPONDIDA (2026-07-16) con fs-verity.** El SDD dejó abierto: *«¿el vector
page-cache de `copy.fail` sobrevive a un ruleset que deniegue escritura a nivel de inode?
Investigar antes de afirmar nada»*. **Investigado, en un fs aislado (loop, nunca el fs real):**
| ataque | resultado |
|---|---|
| escribir al artefacto sellado | `EPERM` |
| `dd` como **root** | `Operation not permitted` — ni root |
| **machacar los bytes POR DEBAJO** (en el device) y leer | **`Input/output error`** |
El tercero es el vector de `copy.fail`: se modifica el fichero por debajo del filesystem. **El
kernel detecta la corrupción al leer** (el Merkle no cuadra) y rechaza la lectura. ⇒ **fs-verity
mata el vector**; Landlock read-only no alcanzaba, fs-verity sí.
**Costo medido, contra lo que dice el SDD 17 §5:** *«hash = identidad»* es **falso** — fs-verity
usa un Merkle **SHA-256** y hammer direcciona con **BLAKE3**: son **dos** hashes, no uno. Y en
ext4 exige `-O verity` **y blocksize = PAGE_SIZE** (con 1024 el mount falla:
`Unsupported blocksize for fs-verity`). No es gratis, pero es barato para lo que da.
- **Q1 — ✅ CERRADA (2026-07-15).** La evidencia llega, con `blockers`/`path`/`dev`/`ino`/`exe`, por
el multicast `AUDIT_NLGRP_READLOG`. Precondiciones medidas: `audit_enabled=1` (no viene de
fábrica) y `LOG_NEW_EXEC_ON` (sin él, cero registros tras el `exec`). No hay canario gratis ⇒
D9. Detalle y mediciones en §3.4. **El gate del proyecto está pasado: harkaq es viable.**
- **Q1b — ✅ CERRADA (2026-07-15).** Los registros cruzan el userns/pidns/mountns de bwrap hasta un
lector en el host, con builds **sin privilegio**. La atribución con la granja en paralelo se
resuelve con el canario de nonce único (D9): revela el `domain=`, que ata el resto. Medido con 2
builds concurrentes, cero cruces. Detalle en §3.5.
- **Q1c (Fase 1):** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ`
(leer el multicast) y, salvo que se hornee `audit=1` en el cmdline, `CAP_AUDIT_CONTROL` (para el
`AUDIT_SET`). En el laptop hizo falta root. Medido en §3.5: el lector privilegiado y los builds
sin privilegio conviven bien. Falta decidir si es hammerd o un helper con capabilities acotadas
— preferible lo segundo: `CAP_AUDIT_*` es superficie de más para el daemon entero.
- **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 — ✅ 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**:
hermeticidad es comportamiento, no identidad. Confirmar con el mismo criterio de `recipe.rs:228`.
*(Las Q1/Q4/Q5 del Draft 1 —el vector page-cache de `copy.fail`, la fracción de FODs, y si harkaq
envuelve o reemplaza a nix— se eliminan: eran todas sobre nix.)*