tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Sutichaykuna

⎇ 2ecce583e59c6996355b7e96aa55c12d70496af1 docs/16-harkaq-jaula.md Markdown · 1074 siq'ikuna · 63.2 KB Ñawpa willay ch'uya

takana etapa 5b: los 59 docs de diseño, runbooks y ADR 97ceb7241 · Sergio ·

1# SDD 16 — harkaq: la jaula de takana
2
3**Estado:** Draft 2 · **Fecha:** 2026-07-15 · **Nombre provisional:** *harkaq* ("el que detiene"; renombrable, ningún identificador depende del nombre)
4
5> **Qué cambió del Draft 1.** El Draft 1 se escribió contra el sandbox de nix. takana no usa nix
6> para construir: usa bubblewrap (ADR 0004, `crates/hammer-build/src/sandbox.rs`). Eso invalidó
7> por irrelevancia ~40% del documento — el argumento anti-nix, las FOD y su red abierta, y la fase
8> del *fetcher mediado* que era el mayor riesgo de cronograma. Fase 0 (reconocimiento) se ejecutó
9> y sus tres mediciones están en §3. El resultado neto es un proyecto **más chico, más barato y
10> con un beneficio que cobra antes**. Lo que sobrevive intacto es el corazón: D1 (política
11> derivada), D2 (evidencia negativa) y D3 (cero quieting).
12
13**Tesis:** takana es una distro cuyo autor es un modelo. Su sandbox (bwrap) fue diseñado para dar
14*hermeticidad de facto* — aislar el build del host — no para *probar* hermeticidad ni para acotar
15lo que el build usa **de adentro de la jaula**. harkaq cierra esa brecha sin pedirle política a
16nadie: **la clausura de entradas declaradas de la receta ya es la política**. El subproducto es
17más valioso que el producto — la ausencia de denegaciones bajo una política igual a la clausura
18es *evidencia mecánica* de que lo declarado **basta**, encadenable en willay y consumible por iniy.
19
20---
21
22## 0. Por qué esto no lo resuelve bwrap
23
24El sandbox actual (`sandbox.rs:224`) hace bien lo que se propuso hacer:
25
26- `--unshare-all` — netns, pidns, mountns, ipcns, utsns, userns. **Sin red, ya hoy.**
27- `--overlay-src <rootfs Alpine> [--overlay-src <dep>…] --tmp-overlay /` — base y deps de solo
28  lectura, escrituras a una capa tmpfs descartable.
29- `--bind <src> /src`, `--bind <out> /out` — las únicas dos superficies RW reales.
30- Entorno mínimo y fijado (`SOURCE_DATE_EPOCH`, `CC`, `-mcpu=baseline`, `CARGO_BUILD_JOBS=1`).
31
32Eso da hermeticidad **respecto del host**. La brecha no está ahí. Está adentro:
33
34> **El sandbox monta un rootfs Alpine entero como capa base del overlay.**
35
36O sea `política ⊋ clausura`, por un margen enorme. Una receta puede usar cualquier header,
37cualquier `.so`, cualquier binario que Alpine traiga — **sin declararlo en `deps`** — y el build
38pasa en verde. El artefacto queda sellado y reproducible, y aun así arrastra una dependencia
39fantasma que nadie escribió.
40
41Ese no es un bug hipotético: es exactamente el bug que la campaña de **de-Alpinización** viene
42cazando *a mano, receta por receta*, leyendo logs y adivinando. El patrón documentado (strip
43`makedepends`, fase configure explícita, `.pc Requires` → `[deps].build`) es el parche manual de
44un problema que el kernel puede diagnosticar solo.
45
46**La inversión que hace takana:** el sandbox de bwrap asume que quien escribe la receta sabe lo
47que declaró. En takana, quien escribe la receta es un modelo, la receta se importa
48automáticamente desde nixpkgs/Alpine, y "lo que declaró" es precisamente la parte que falla.
49Nadie más tiene ese problema porque nadie más tiene una distro cuyo autor sea un modelo.
50
51**No-tesis:** esto no reemplaza a bwrap. harkaq **compone** con él (§5, D4): la jaula de mount y
52namespaces sigue siendo de bwrap; harkaq agrega la política de grano fino y —lo que importa— el
53canal de evidencia.
54
55### 0.1 El lugar exacto en el modelo de confianza
56
57De `takana-core/src/recipe.rs:229`, sobre la evidencia H1 (proof-carrying recipes, SDD 15):
58
59> *"Frontera honesta: garantiza que **lo declarado pasa**, no que **lo declarado basta**."*
60
61harkaq es el complemento simétrico de esa frase, y por eso encaja sin forzar nada:
62
63| | Pregunta | Mecanismo | Evidencia |
64|---|---|---|---|
65| **H1** | ¿lo declarado **pasa**? | checks corridos en el sandbox | positiva (los checks pasan) |
66| **harkaq** | ¿lo declarado **basta**? | política = clausura, en el kernel | negativa (nadie denegó nada) |
67
68Juntas cierran el cuadro: H1 dice que el artefacto *hace lo que dice*; harkaq dice que *no
69necesitó nada que no dijo*. Ninguna de las dos pide confiar más en el modelo. Las dos piden
70verificar más — que es el principio declarado de SDD 15 §0.
71
72---
73
74## 1. Metas
75
761. Ejecutar el builder bajo una política **derivada, no escrita**: `política = clausura(deps declaradas)`.
772. Producir, por cada build, un veredicto de hermeticidad con evidencia:
78   `hermético ⟺ política = clausura ∧ denegaciones = ∅`.
793. Emitir esa evidencia hacia willay/iniy, direccionada por blake3, como un `kind` nuevo junto a
80   la evidencia H1.
814. **Convertir la de-Alpinización en un diagnóstico mecánico**: cada denegación nombra el path,
82   el dev y el ino de lo que el build usó sin declarar.
835. Degradación explícita y declarada: en kernel sin Landlock, harkaq marca el build **sin
84   evidencia** — nunca finge.
85
86## 2. No-metas
87
88- No corre en wawa. Ver D6.
89- No reemplaza el sandbox de bwrap; se compone encima. Defensa en capas.
90- No es un contenedor de propósito general ni compite con bubblewrap/landrun.
91- v1 no cubre macOS (takana es musl/Linux).
92- No promete contener un exploit de kernel. Ver §8.
93- **No reabre ADR 0004.** takana no adopta nix como backend de build. Esa opción sigue diferida
94  y harkaq no la necesita.
95
96---
97
98## 3. Fase 0 — Reconocimiento: EJECUTADA (2026-07-15)
99
100El Draft 1 estimaba "días". Fueron cinco minutos, porque las tres preguntas tenían respuesta
101mecánica. Las tres respuestas están medidas, no supuestas.
102
103### 3.1 ABI de Landlock
104
105Medido con el syscall (`landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)`), que
106es la vía correcta: los kernels de otras fuentes traen features backporteadas y **el número de
107versión del kernel miente** (RHEL 9.6 backporteó hasta ABI 5 sobre un 5.14).
108
109| Dónde | Kernel | ABI | Veredicto |
110|---|---|---|---|
111| Laptop de desarrollo | 7.2.0-rc3-1-cachyos-rc | **10** | techo; todo disponible |
112| Kernel de takana | 6.16.12-metal (`recipes/linux.toml`) | **ausente** | `# CONFIG_SECURITY_LANDLOCK is not set` |
113
114Lo que hace falta de cada ABI, y desde dónde:
115
116| ABI | Kernel | Qué trae | Para harkaq |
117|---|---|---|---|
118| 2 | 5.19 | `FS_REFER` | **necesario** — sin refer no compila nada real |
119| 3 | 6.2 | `FS_TRUNCATE` | necesario |
120| 4 | 6.7 | `NET_BIND_TCP`, `NET_CONNECT_TCP` | **inerte** — no hay red que filtrar (§6) |
121| 5 | 6.10 | `FS_IOCTL_DEV` | cierra ioctls a dispositivos |
122| 6 | 6.12 | `SCOPE_ABSTRACT_UNIX_SOCKET`, `SCOPE_SIGNAL` | corta fuga lateral |
123| 7 | 6.15 | **audit de denegaciones** | **este es el proyecto entero** |
124| 8 | 7.0 | `RESTRICT_SELF_TSYNC` | robustez (todos los hilos) |
125| 10 | — | `ADD_RULE_QUIET`, `quiet_access_*` | **deliberadamente NO usar** (D3) |
126
127El hallazgo que ordena el diseño sigue siendo ABI 7: Linux 6.15 registra las peticiones denegadas
128vía audit con el motivo — `domain`, `blockers` (p.ej. `fs.make_reg,fs.refer`) y la identificación
129del objeto (`path`, `dev`, `ino` para filesystem; `opid`, `ocomm` para señales).
130
131Traducción: **el mecanismo de traza que el Draft 1 iba a construir ya lo hace el kernel.** No hay
132que instrumentar syscalls ni interponer un tracer. La evidencia sale del audit log del propio LSM,
133que es un productor mucho más confiable que cualquier cosa en espacio de usuario.
134
135### 3.2 El kernel de takana: falta un flag, no una versión
136
137`store/060b34a6…-linux-metal/boot/config-6.16.12-metal` dice:
138
139```
140# CONFIG_SECURITY_LANDLOCK is not set
141CONFIG_LSM="landlock,lockdown,yama,loadpin,safesetid,selinux,smack,tomoyo,apparmor,ipe,bpf"
142CONFIG_SECCOMP_FILTER=y
143```
144
145Respuesta a la Q2 del Draft 1 ("¿vale la pena forzar ≥6.15 en la distro?"): **no hace falta
146forzar nada.** 6.16.12 ya trae hasta ABI 8, y `CONFIG_LSM` ya lista `landlock` de primero. Falta
147sólo compilarlo: un `scripts/config -e SECURITY_LANDLOCK` junto a los otros ajustes de la fase
148`configure` de `recipes/linux.toml` (`SECURITY_PATH` entra por `select`). Cuesta un rebuild
149(~35min) y cambia el hash del kernel. Es el único trabajo de infraestructura del proyecto.
150
151seccomp ya está `=y` en ambos kernels. No hay trabajo ahí.
152
153### 3.3 Las FODs: no existen. El fetcher mediado: ya está construido
154
155El Draft 1 dedicaba §6 entero + D8 al problema de las derivaciones de salida fija y su red
156abierta, y ponía el *fetcher mediado* en Fase 3 llamándolo *"el mayor riesgo de alcance del
157proyecto"*, de *"meses"*.
158
159Ese trabajo está hecho desde siempre, por construcción:
160
161- `crates/hammer-build/src/download.rs` corre `curl` **en el host**, fuera del sandbox.
162- El builder recibe **bytes** vía `--bind <src> /src`. Nunca un socket.
163- `--unshare-all` incluye `--unshare-net`: netns vacío para **todo** build, sin excepciones,
164  porque no existe la categoría "derivación que necesita red".
165
166Eso *es* la jaula con ventanilla que D8 proponía construir. takana nunca tuvo el agujero de las
167FOD porque nunca tuvo FODs: separar el fetch del build fue una decisión temprana y silenciosa que
168resultó ser la correcta por razones de seguridad que nadie había escrito todavía. Queda escrita acá.
169
170**Fase 3 se elimina del plan.** El riesgo de cronograma desaparece.
171
172### 3.4 Q1 resuelta: la evidencia llega, con dos precondiciones y un canario obligatorio
173
174Experimento `scripts/harkaq/q1-audit.c` (2026-07-15), corrido en el laptop (ABI 10). Tres
175escenarios: denegación sin `execve`, con `execve` y flags por defecto, y con `execve` +
176`LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON`.
177
178**La evidencia llega, y es exactamente la que el diseño necesita.** Un lector no privilegiado
179del grupo multicast `AUDIT_NLGRP_READLOG` recibe:
180
181```
182domain=146973ef2 blockers=fs.read_file path="/etc/passwd" dev="nvme1n1p6" ino=4458369
183domain=146973ef2 status=allocated mode=enforcing pid=3359 uid=0 exe="…/q1-audit" comm="q1-audit"
184```
185
186`blockers` + `path` + `dev` + `ino` + el `exe` que lo intentó. Ese registro **es** el diagnóstico
187de de-Alpinización de §0, y sale del kernel sin instrumentar nada.
188
189**Precondición 1 — `audit_enabled=1`.** Medido: el laptop arranca con `audit_enabled=0` (sin
190`audit=1` en el cmdline, sin `auditd`). Con audit apagado **no se emite ningún registro, de
191ningún escenario**. Se enciende con `AUDIT_SET` por netlink (`CAP_AUDIT_CONTROL`) o con `audit=1`
192en el cmdline — que para el kernel de takana es horneable, igual que el resto del cmdline metal.
193
194**Precondición 2 — `LOG_NEW_EXEC_ON` es obligatorio, y su ausencia es indetectable.** Medido:
195
196| Escenario | Registros `ACCESS` |
197|---|---|
198| `same-exec` (sin `execve`) | **2** — logueado por defecto |
199| `new-exec` (flags por defecto) | **0** |
200| `new-exec-logon` (`LOG_NEW_EXEC_ON`) | **4** |
201
202Misma jaula, misma denegación, `cat` rebotó con `EACCES` en los tres. harkaq **restringe y después
203hace `execve`** del builder (bwrap → `/bin/sh -c` → make → `zig cc`): es el caso `new-exec`. Sin el
204flag, harkaq certificaría como herméticos **todos** los builds, `denials=[]`, para siempre, sin un
205solo error visible. El flag no es configuración: es la diferencia entre el sistema y un sello de goma.
206
207**No hay canario gratis en el kernel.** Se investigó si el registro `status=deallocated denials=N`
208podía servir de verificación cruzada. Medido, con una ventana de escucha de 6s: **un dominio con
209flags por defecto cuya única denegación es post-`exec` no emite absolutamente nada** — ni
210`allocated`, ni `access`, ni el `deallocated` con su contador. El contador existe **sólo para
211dominios que ya loguean**. Y el `allocated` se emite **perezosamente**, pegado al primer denial
212logueado (mismo evento de audit), así que un build genuinamente limpio tampoco lo produce: su
213presencia no puede usarse como prueba de que los flags tomaron.
214
215⇒ **D9 (nueva decisión cerrada, abajo): el canario lo fabrica harkaq.**
216
217Lo que el contador sí resuelve, cuando el logging está bien puesto: exigir
218`nº de registros ACCESS recibidos == denials` del `deallocated` detecta **registros perdidos** por
219desborde del backlog de audit — un riesgo real con la granja construyendo en paralelo. Son dos
220chequeos para dos fallas distintas (mala configuración vs. pérdida), y hacen falta los dos.
221
222> **Nota de método, que vale más que el resultado.** La primera corrida del experimento dio 0
223> registros en los tres escenarios y parecía confirmar la hipótesis. Era un bug del instrumento:
224> el `AUDIT_GET` pedía `NLM_F_ACK`, el ACK llegaba antes que la respuesta (el kernel la manda
225> asíncrona), el lector lo tomaba por fallo, `enabled` quedaba en `-1`, la rama que encendía el
226> audit nunca corría y el kernel no emitía nada. Sólo se detectó porque el **control**
227> (`same-exec`, que debía loguear) también dio 0, y eso era imposible.
228>
229> Es el modo de falla de harkaq, en vivo, sobre su propio banco de pruebas: un sistema cuyo
230> producto es la *ausencia* de algo no se rompe ruidosamente — dice que todo está bien. El
231> experimento ahora aborta con código 4 si no puede **confirmar** `audit_enabled=1`, en vez de
232> reportar cero. La misma disciplina es la que D9 le impone a harkaq.
233
234### 3.5 Q1b resuelta: la evidencia cruza el userns, y el canario es la clave primaria
235
236Experimento `scripts/harkaq/q1b-attrib.c` (2026-07-15). Dos builds **concurrentes**, cada uno
237dentro de bwrap con `--unshare-all` (los mismos ns que `Sandbox::bwrap_args`), sin privilegio
238(uid 1000), con un canario de nonce distinto. El lector corre en el **host**, como root.
239
240```
241[ACCESS] domain=146973f1c blockers=fs.read_file path="/tmp/harkaq-canary-BBBB" dev="nvme1n1p6" ino=4457713
242[DOMAIN] domain=146973f1c status=allocated mode=enforcing pid=8498 uid=1000 exe="…/q1b" comm="q1b"
243[ACCESS] domain=146973f1f blockers=fs.read_file path="/tmp/harkaq-canary-AAAA" dev="nvme1n1p6" ino=4457713
244[DOMAIN] domain=146973f1f status=allocated mode=enforcing pid=8499 uid=1000 exe="…/q1b" comm="q1b"
245[DOMAIN] domain=146973f1f status=deallocated denials=1
246[DOMAIN] domain=146973f1c status=deallocated denials=1
247```
248
249**(a) Los registros cruzan.** 6 registros landlock de adentro de bwrap llegaron al lector del
250host. El audit no está namespaceado y se comporta como tal. ⇒ **`harkaq-audit` vive en el host,
251junto a hammerd; la arquitectura de §4 se sostiene.** Y cruzan con builds **sin privilegio**, que
252es como corren de verdad (si sólo cruzaran con builds root, no servirían).
253
254**(b) El canario con nonce atribuye.** `AAAA=1`, `BBBB=1`, cero registros ajenos, con dos builds
255en paralelo. ⇒ **Q1b cerrada: la granja puede construir en paralelo sin contaminarse la
256evidencia.**
257
258Tres hallazgos colaterales que valen más que el veredicto:
259
2601. **El contador quedó validado.** Los dos `deallocated denials=1` llegaron en ventana, uno por
261   dominio, y cuadran con 1 `ACCESS` cada uno. El chequeo anti-pérdida de §3.4
262   (`nº ACCESS == denials`) está medido, no supuesto.
2632. **La identidad sobrevive a los namespaces:** `pid=8498 uid=1000` son del **host** (adentro del
264   pidns la víctima se ve como pid 1). El audit reporta identidad del lado donde está el lector.
2653. **`dev`+`ino` identifican el OBJETO, no el BUILD.** Los dos canarios reportan el mismo
266   `ino=4457713` (ambos bindean `/etc/hostname`). Atribuir por dev/ino habría fundido dos builds
267   que leen el mismo fichero no declarado — que es *el caso más común* que harkaq va a ver. **La
268   clave es `domain=`**; el canario es lo que la revela.
269
270⇒ El flujo de atribución queda cerrado: **canario ⇒ aprendo el `domain=` ⇒ atribuyo por `domain=`
271todo lo demás del build.** D9 hace tres trabajos con un mecanismo: honestidad (§3.4), clave
272primaria (acá) y — vía el contador — detección de pérdida.
273
274> **Nota de método (la tercera, y ya no es anécdota).** Esta corrida también falló primero, dos
275> veces más, y las dos el banco reportó un resultado inventado con total confianza:
276> 1. El comando de la víctima era `cat $canario >/dev/null`. `/dev` no estaba en la clausura ⇒ sh
277>    fallaba **en la redirección** y `cat` no llegaba a correr. El `rc=1` venía del redirect: el
278>    test medía una denegación que no era la que creía medir.
279> 2. Corriendo como root, bwrap no pudo ni ejecutar el binario (`execvp: Permission denied`) y el
280>    veredicto imprimió *"NADA cruzó ⇒ harkaq-audit no puede vivir en el host: replantear"* — una
281>    conclusión arquitectónica falsa, a partir de cero estímulo. (Causa: `--unshare-all` mapea
282>    sólo uid 0→0; los ficheros de uid 1000 quedan sin mapear y `CAP_DAC_OVERRIDE` **en un userns
283>    sólo vale sobre uids mapeados** ⇒ root-en-userns no atraviesa un home `drwx------`.)
284>
285> El banco ahora tiene un **gate de estímulo**: si alguna víctima no sale con status 0, aborta
286> (exit 4) en vez de contar registros.
287>
288> Van **tres** veces en una sesión —`NLM_F_ACK`, el redirect, el exec— que un cero resultó ser el
289> instrumento y no el kernel, y las tres el programa afirmó con confianza algo falso. Es la
290> evidencia empírica más fuerte a favor de D9, y viene del lado incómodo: *el banco lo escribió
291> alguien que sabía exactamente cuál era el riesgo, mirándolo de frente, y cayó igual, tres veces.*
292> harkaq sin canario no es un riesgo de configuración: es el comportamiento por defecto.
293
294---
295
296## 4. Arquitectura
297
298```
299 receta (escrita/importada por el agente)
300        │
301        ├─ deps declaradas (paths del store, por hash)
302        │
303        ▼
304 ┌──────────────────────────────────────────────────────────┐
305 │ harkaq-policy   (puro, testeable sin kernel)             │
306 │   clausura(deps) → PolicySpec {                          │
307 │      fs_ro:   [UN FICHERO POR ENTRADA — ver §4.1]        │
308 │      fs_list: [dirs de la clausura: listar, no leer]     │
309 │      fs_rw:   [/src, /out, /tmp, /cache]                 │
310 │      base:    [el runtime implícito — §8 Q2]             │
311 │      scoped:  UNIX_ABSTRACT | SIGNAL                     │
312 │   }                                                      │
313 │   PolicyId = blake3(postcard(PolicySpec))                │
314 └───────────────────────────┬──────────────────────────────┘
315                             ▼
316 ┌──────────────────────────────────────────────────────────┐
317 │ harkaq-exec     (musl, Rust) — DENTRO de bwrap           │
318 │   bwrap ya dio: ns, overlay, /src, /out, sin red         │
319 │   1. landlock: PolicySpec → ruleset (best-effort/ABI)    │
320 │   2. seccomp-bpf: allowlist (D4), vía --seccomp <fd>     │
321 │   3. no_new_privs, drop caps, exec builder               │
322 └───────────────────────────┬──────────────────────────────┘
323                             ▼
324 ┌──────────────────────────────────────────────────────────┐
325 │ harkaq-audit    (lector de AUDIT_LANDLOCK_*)             │
326 │   EN EL HOST, junto a hammerd — FUERA de bwrap (§3.5):   │
327 │   el audit no está namespaceado y los registros cruzan.   │
328 │   Requiere CAP_AUDIT_READ (§8 Q1c).                       │
329 │   canario ⇒ domain= ⇒ atribuye el resto (D9)             │
330 │   denegaciones → Denial { blockers, path, dev, ino, ... }│
331 └───────────────────────────┬──────────────────────────────┘
332                             ▼
333   Verdict { PolicyId, recipe_hash, artifact_hash,
334             estado: Hermetico | Impuro{denials[]} | SinEvidencia }
335                             │
336              blake3 ──► CAS ──► willay ──► iniy
337```
338
339Ley de dependencia: `harkaq-policy` no conoce el kernel (función pura de `Recipe` → `PolicySpec`,
340testeable con `cargo test` en cualquier máquina, incluso sin Landlock). `harkaq-exec` no conoce
341la receta. El acoplamiento vive sólo en `takana-build`.
342
343Nota de encaje: `Sandbox::bwrap_args` ya construye la lista de deps (`self.deps`) — es
344literalmente el input de `clausura()`. El `PolicySpec` se deriva de datos que la struct `Sandbox`
345ya tiene en la mano.
346
347### 4.1 La política es POR FICHERO, no por path del store (corrección medida)
348
349El Draft 2 decía `fs_ro: [store paths de la clausura]`. **Es falso, y era otra herencia del marco
350nix**, donde cada dep se ve dentro del sandbox como un `/nix/store/<hash>-x` distinto. En takana
351no: `Sandbox::bwrap_args` apila cada dep con `--overlay-src` y las **funde en el mismo `/usr`**
352que el rootfs Alpine, a propósito, para que el compilador y pkgconf las encuentren en las rutas
353estándar sin plumbing de flags.
354
355Medido dentro del sandbox real:
356
357```
358/usr/include/zlib.h    (dep DECLARADA)          dev=63 ino=13641973
359/usr/include/stdio.h   (Alpine, NO declarado)   dev=63 ino=4196788
360/usr/include           (el directorio)          dev=61 ino=15   ← overlayfs, uno solo
361```
362
363Los paths del store **no existen dentro del sandbox**, y una regla sobre `/usr/include` concede
364las dos cosas por igual — la evidencia dejaría de significar nada. Ni siquiera `dev` distingue:
365overlayfs deja pasar el inode de la capa inferior y ambas capas están en la misma partición.
366
367**La clausura se enumera fichero a fichero.** Es computable sin esfuerzo: en el *host* sabemos
368exactamente qué aporta cada dep (`store/<hash>-zlib/usr/include/zlib.h` → `/usr/include/zlib.h`;
369la traducción es quitar el prefijo del store). `harkaq-policy.sh` lo hace hoy.
370
371Dos consecuencias de diseño:
372
3731. **`list` ≠ `ro`.** pkgconf y los tests de `configure` **necesitan** escanear `/usr/lib/pkgconfig`,
374   pero listar no es leer. Landlock separa `READ_DIR` de `READ_FILE`, así que se concede el
375   listado del directorio y se deniega la lectura de los `.pc` que la receta no declaró. Sin esta
376   separación, todo `ls` sería un falso positivo.
3772. **Máscara por tipo.** Los derechos de-sólo-directorio (`READ_DIR`, `MAKE_*`, `REFER`, …) sobre
378   un fichero regular hacen que el kernel rechace la regla entera con `EINVAL`. Como la clausura
379   es mayormente ficheros sueltos, sin recortar por tipo no arranca ni la primera regla.
380
381### 4.2 Q2 resuelta: el runtime base son 5 paths, y hay una tercera categoría
382
383El método es lo importante: **no se adivina, se mide**. `scripts/harkaq/q2-runtime-base.sh` corre
384un build real en el sandbox real con `política = clausura declarada` y deja que las denegaciones
385nombren el resto. `MODE=base` (sin **ninguna** dep declarada) aísla la respuesta sin juicio de
386valor: sin clausura que pueda explicarlas, *toda* denegación es runtime base por definición.
387
388`MODE=base`, `hello.c` mínimo con `zig cc` — 18 denegaciones, 4 paths distintos (contador del
389kernel 19 = 18 + canario ✓):
390
391```
3928× /dev/urandom      6× /usr/lib/os-release      3× /dev/null      1× /usr/bin/env
393```
394
395**Y el build salió `rc=0`.** No son cosas que el build *necesite*: son cosas que *intenta*. Pero
396aparecen en todos los builds, así que sin tratarlas cada veredicto sale `Impuro` con los mismos
397paths y la deuda real queda enterrada. Iterando (cada línea añadida sólo tras una denegación
398real, nunca por corazonada) la partición se cierra en **tres** categorías:
399
400| Categoría | Qué | Qué hacer |
401|---|---|---|
402| **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** |
403| **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** |
404| **Denegación esperada** | `/usr/bin/gcc` | **denegar y clasificar** (D3) |
405
406La primera línea divisoria es la que hacía falta: `/dev` y `/proc` **los monta bwrap**, no salen
407del rootfs Alpine ⇒ son superficie de contrato como `/src`, no deuda. Sólo 5 paths de Alpine son
408irreducibles. Eso hace viable todo el planteo: **el resto de Alpine es, genuinamente, deuda**.
409
410**La tercera categoría no estaba en el diseño y es la más interesante.** `zig cc` sondea
411`/usr/bin/gcc` (`fs.execute,fs.read_file`, 3 veces) y el build igual sale `rc=0` sin él. Esa
412denegación **no es ruido a permitir: es la jaula haciendo su trabajo**. Concederla dejaría a zig
413invocar el gcc de Alpine por detrás — justo lo que la campaña *matar gcc* persigue a mano. harkaq
414da evidencia mecánica de que zig lo intenta *y* de que negárselo no rompe el build. Es D3 literal
415("una denegación esperada no se calla, se *clasifica*"), y confirma que la decisión de no tener
416`quiet` era correcta: silenciarla habría borrado el hallazgo.
417
418**El contraste, con el mismo runtime base:**
419
420```
421MODE=base   rc=0   Impuro, denegaciones = [ /usr/bin/gcc ]              ← esperada
422MODE=zlib   rc=1   Impuro, denegaciones = [ /usr/lib/libz.so.1.3.2 ]    ← DEUDA PURA
423```
424
425Una sola denegación, sin ruido: el build se linkaba contra **la zlib de Alpine** en vez de la dep
426declarada (que aporta `libz.a`). Sin harkaq eso pasa en verde y el artefacto queda dependiendo de
427Alpine. **Es exactamente el bug de §0, aislado en un build real y en una corrida de 20 segundos.**
428
429Consecuencia para el `Verdict`: los tres estados de D9 necesitan que las denegaciones vengan
430**clasificadas** (`base` | `esperada` | `deuda`), no en una lista plana. `Hermetico` debe
431significar "cero deuda", no "cero denegaciones" — si no, ningún build real lo alcanzaría jamás.
432Implementado en `harkaq-verdict.py`, **deliberadamente fuera del lector**: éste corre con
433`CAP_AUDIT_READ` y clasificar es *política, no privilegio* (mismo argumento que Q1c). Las
434expectativas viajan en la propia política como `# expect <path>` — una sola fuente de verdad, y
435`harkaq-exec` las ignora como comentario. Medido:
436
437```
438MODE=base   rc=0   Hermetico   esperadas: /usr/bin/gcc            deuda: ninguna ✓
439MODE=zlib   rc=1   Impuro      DEUDA: /usr/lib/libz.so.1.3.2
440```
441
442### 4.3 ¿Aguantan los 5 paths? No — pero converge, y eso es lo que importa
443
444Los 5 paths de §4.2 son la respuesta para *un* shape de build (C mínimo con `zig cc`). La
445pregunta honesta era si sobreviven a un `configure` de autotools, que sondea el sistema entero.
446Medido contra una fuente **real** (`libgpg-error`, `MODE=configure`), iterando con el método de
447§4.2 (cada línea sólo tras una denegación real):
448
449| Ronda | Deuda restante (paths únicos) |
450|---|---|
451| 1 | `/bin/bash`, `/bin/coreutils` |
452| 2 | `libacl`, `libattr`, `libcrypto`, `libreadline`, `libutmps` |
453| 3 | `libncursesw`, `libskarnet` |
454| 4 | `/usr/bin/c89`, `/usr/bin/c99`, `/usr/bin/ldd`, `/usr/bin/make` |
455
456**Respuesta: no, el runtime base es por forma de build.** Un `configure` necesita ~16 entradas,
457no 5. Pero las tres cosas que importan salieron bien:
458
4591. **Converge, y rápido** — 3 rondas, de 34 denegaciones a 5. No es una cola infinita.
4602. **Se queda chico** — ~16 entradas, no cientos. La métrica de <5% de la Fase 2 sigue siendo
461   plausible.
4623. **El runtime base no es una lista de binarios: es su CIERRE de `.so`.** Conceder `/bin/bash`
463   arrastra `libreadline`+`libncursesw`; conceder `/bin/coreutils` arrastra `libacl`+`libattr`.
464   Eso es *computable*, no adivinable ⇒ **ya está derivado** (`harkaq-base-closure.py`). Es D1
465   aplicado al runtime base: mantenerlo a mano sería exactamente el "alguien mantiene un perfil
466   de permisos" que D1 dice que mata a todos los sandboxes.
467
468   No usa `ldd` (resolvería contra el host, no contra el rootfs Alpine): lee los `DT_NEEDED` del
469   ELF y resuelve dentro del rootfs por las rutas de musl. **Validación:** el cierre derivado de
470   `{sh, busybox, bash, coreutils, env}` reproduce **exactamente** las 7 librerías que las rondas
471   2 y 3 habían descubierto a mano, en una sola pasada, y además caza los symlinks y
472   `libc.musl-x86_64.so.1` que se habían escapado. Emite symlink **y** destino: el kernel denuncia
473   el fichero real (`libz.so.1.3.2`, no `libz.so.1`), pero el build abre por el nombre corto.
474
475### 4.4 El diagnóstico sobre un `configure` real, con el entorno fiel
476
477Con el entorno del sandbox real replicado (`CC="zig cc -mcpu=baseline"`, `AR`, `SOURCE_DATE_EPOCH`,
478`LC_ALL=C`…), `configure` llega mucho más lejos y el diagnóstico se vuelve el que el proyecto
479prometía. **205 denegaciones del kernel**, y tras clasificar:
480
481```
482esperadas (170):  /usr/bin/gcc, /usr/bin/ldd          ← sondas de compilador
483DEUDA (34) en 8 paths:
484    /usr/bin/ld   /usr/bin/nm   /usr/bin/objdump   /usr/bin/strip    ← binutils de ALPINE
485    /usr/bin/make   /usr/bin/getconf
486    /usr/lib/gcc/x86_64-alpine-linux-musl                            ← libdir del gcc de ALPINE
487    /opt                                                             ← (bug de política, ver abajo)
488```
489
490**Esto es la tesis del §0 hecha dato.** Un `configure` que se creía hermético está yendo a buscar
491los binutils y el libdir de gcc de Alpine. Y la clasificación es lo que lo hace legible: sin ella
492son 205 denegaciones planas y el hallazgo queda enterrado; con ella son **8 paths accionables**.
493
494Conecta con dos frentes abiertos, y en ambos la lista coincide: los swaps del `selfhost-verify`
495(`SWAP_COREUTILS`, las herramientas en el path) y la campaña *matar gcc* (`recipes/binutils.toml`
496existe justamente porque zig provee `as/ld/ar` pero el resto se toma de Alpine).
497
498**Bug de política encontrado por el propio experimento:** `/opt` aparece como deuda porque la
499política concede `ro /opt/zig` pero no deja **listar** el padre. Regla general: **todo directorio
500concedido necesita `list` en sus ancestros**, o el escaneo del padre es un falso positivo.
501`harkaq-policy.sh` ya lo hace para la clausura de las deps; falta hacerlo para las superficies de
502contrato.
503
504**Y el residuo es todo significativo, ninguno ruido:**
505
506- `c89`, `c99`, `ldd` — la misma familia que `gcc` (§4.2): sondas de compilador de Alpine. Van a
507  **denegación esperada**; concederlas sería dejar que `configure` compile con Alpine.
508- `/usr/bin/make` — **la decisión de diseño de verdad**, y no la toma harkaq. Hoy los builds de
509  takana usan el `make` de Alpine sin declararlo. O es runtime base (se acepta y se escribe), o
510  es deuda (y se declara como dep). Nótese que esto es *exactamente* lo que los swaps del
511  `selfhost-verify` reemplazan uno a uno: **la lista de deuda de harkaq y la lista de swaps del
512  bootstrap son la misma lista, descubierta por dos caminos.** Que coincidan es la mejor
513  validación externa que tiene el método.
514
515---
516
517## 5. Decisiones cerradas
518
519### D1 — La política se deriva, no se escribe
520
521Es el corazón, y sobrevive al Draft 1 sin un rasguño. Todo sandbox del mundo muere en la
522ergonomía: alguien mantiene a mano un perfil de permisos, nadie lo revisa, todos terminan en
523permisivo. takana no tiene ese problema porque **la receta ya declara sus deps por hash**.
524`clausura(deps)` es el conjunto exacto de rutas de solo lectura. Cero configuración humana,
525precisión igual a la del direccionamiento por contenido.
526
527Corolario: no existe "modo permisivo". Una receta que necesita más de lo que declara está mal
528escrita, y ese es exactamente el diagnóstico que queremos que el agente reciba — hoy lo
529producimos leyendo logs a mano y llamándolo de-Alpinización.
530
531### D2 — La evidencia es negativa, y por eso es barata
532
533**No se traza lo permitido.** La tentación es loguear todo acceso para "probar" qué tocó el build;
534eso exige fanotify o eBPF, cuesta rendimiento y produce un río de datos que nadie audita.
535
536La formulación correcta es la contrapositiva:
537
538> `hermético(build) ⟺ política = clausura(deps) ∧ denegaciones = ∅`
539
540Si la política concede *exactamente* la clausura declarada y el kernel no denegó nada, entonces el
541build no usó nada fuera de lo declarado. **La política es la afirmación; la ausencia de
542denegaciones es la prueba.** Coste de la traza: un lector de audit y una lista casi siempre vacía.
543
544Esto convierte la reproducibilidad de takana, que hoy es una promesa arquitectónica verificable
545sólo rebuildeando, en un certificado por build.
546
547### D3 — Cero quieting. Las denegaciones son el producto
548
549ABI 10 permite suprimir selectivamente logs de accesos denegados por objeto (`LANDLOCK_ADD_RULE_QUIET`,
550campos `quiet_access_*`). Es una feature excelente para sandboxear apps de escritorio ruidosas —
551y **veneno para harkaq**. Silenciar una denegación es destruir la única evidencia que produce el
552sistema. En harkaq `quiet_*` está prohibido por diseño; una denegación esperada no se calla, se
553*clasifica* en el `Verdict`.
554
555(De la propia doc del kernel: el objeto queda marcado como quiet si al menos una llamada lo pidió,
556y llamadas posteriores sin el flag no lo limpian — es pegajoso. Razón extra para no tocarlo.)
557
558Este laptop reporta ABI 10, así que la feature está a un flag de distancia. La disciplina es no
559usarla.
560
561### D4 — Landlock no basta; seccomp es obligatorio, no opcional
562
563> **Corregida al implementarla (2026-07-15).** El texto de abajo pedía **allowlist** por syscall.
564> Lo implementado es una **denylist**, y el cambio es deliberado: un allowlist para builds
565> *arbitrarios* —compiladores, make, shells, linkers, perl— es un blanco móvil que cada
566> herramienta nueva rompe. Es el riesgo del §7 ("los falsos positivos matan proyectos de
567> sandboxing") aplicado a las syscalls, y con peor final: un build que muere por una syscall
568> legítima no da un diagnóstico útil, da un misterio. Lo *peligroso*, en cambio, es enumerable y
569> estable: no hay build honesto que cargue módulos, haga kexec o attachee un ptrace.
570>
571> Denegadas (en `harkaq-exec.c`): `io_uring_*`, `ptrace`, `process_vm_{readv,writev}`, `bpf`,
572> `userfaultfd`, `keyctl`/`add_key`/`request_key`, `{init,finit,delete}_module`, `kexec_*`,
573> `perf_event_open`, `mount`/`umount2`, la familia `open_tree`/`move_mount`/`fs*`, `setns`.
574> **`pivot_root` NO se deniega**: bwrap lo usa *antes* de llegar a harkaq-exec.
575>
576> **EPERM, no KILL:** matar el proceso deja un cadáver sin explicación; EPERM deja al build
577> fallar donde corresponde y al log decir por qué. Se instala **después** de `no_new_privs` y de
578> Landlock, justo antes del `exec`, y se hereda por fork/exec igual que el dominio (D5). Si el
579> kernel lo rechaza, **no se corre el build** (D7: fallo cerrado — media jaula creyéndose entera
580> es peor que ninguna).
581>
582> **Comprobado:** `ptrace` → `EPERM` bajo la jaula; y un build real de zlib sale `Hermetico` ×3
583> con **el hash del artefacto idéntico** al de antes de seccomp (`b3:adc5c251…`). Añadir media
584> jaula no movió un byte — como debe ser: esto recorta superficie de escape, no cambia el build.
585>
586> Nota: el `--seccomp <fd>` de bwrap que se menciona abajo terminó **sin usarse**. `harkaq-exec`
587> ya corre dentro y con `no_new_privs` puesto, así que instala el filtro él mismo — una pieza
588> menos de plumbing y el filtro queda al lado de la política que lo justifica.
589
590Landlock cubre fs, puertos TCP e IPC scoping. No cubre `ptrace`, `bpf`, `userfaultfd`, `keyctl`,
591`io_uring`, montajes, ni la mitad de la superficie interesante. seccomp-bpf con allowlist por
592syscall es la otra mitad de la jaula.
593
594Buena noticia de composición: **bwrap acepta `--seccomp <fd>`**. No hay que reemplazar el
595launcher; se le pasa el filtro al que ya tenemos. `CONFIG_SECCOMP_FILTER=y` en ambos kernels.
596
597`io_uring` va explícitamente denegado en v1 (superficie de kernel enorme, y bypassea buena parte
598del análisis por-syscall). Si un builder lo necesita, que lo justifique.
599
600### D5 — Herencia y TSYNC
601
602Las restricciones de Landlock se heredan automáticamente por `fork()` y `exec()`, así que todo el
603árbol de procesos del build queda contenido sin trabajo extra — que es exactamente lo que hace
604falta cuando `Sandbox::run` lanza `/bin/sh -c` que lanza make que lanza `zig cc`. Con ABI 8
605disponible (6.16.12 lo tiene), usar `RESTRICT_SELF_TSYNC` para cubrir todos los hilos.
606
607### D6 — harkaq es órgano de takana, no de la suite. Rompe la paridad wawa, y está bien
608
609Landlock, seccomp y cgroups son Linux puro; en `x86_64-unknown-none` no existen. Esto viola el
610principio de paridad Linux/wawa que gobierna el resto del ecosistema, y hay que decirlo en voz
611alta antes de que alguien lo cobre.
612
613El costo es aceptable porque **el bucle de build del agente es una actividad Linux por
614definición**. wawa nunca compila nada: consume artefactos atestados del CAS. La frontera de
615confianza de wawa es la atestación; la de takana es la jaula. Son mecanismos distintos para
616posiciones distintas del pipeline, y eso es coherente, no inconsistente.
617
618### D7 — Fallo cerrado, y honesto sobre el ABI
619
620- `abi_minima = V3` para intentar el build (sin `REFER` y `TRUNCATE` no compila software real).
621- `abi_evidencia = V7` para emitir un `Verdict` con evidencia.
622- Entre V3 y V7: el build corre, el `Verdict` sale `SinEvidencia`, iniy lo trata con
623  `uncertainty` alta en vez de creerle.
624- Sin Landlock (el kernel de takana **hoy**): `SinEvidencia`, y el build corre igual bajo el bwrap
625  de siempre. harkaq es inerte, no un muro.
626
627Nunca se emite evidencia que el kernel no respaldó. Se consulta el ABI con el syscall, jamás
628`uname`.
629
630### D8 — La red ya está cerrada; harkaq no la toca
631
632(Reemplaza al D8 del Draft 1, que trataba de las FOD.)
633
634`--unshare-all` ⇒ netns vacío ⇒ los derechos de red de ABI 4 son **inertes**. No se implementan.
635Si algún día un build necesitara red, la respuesta no es abrir el netns: es extender el fetcher
636del host (§3.3), que es el embudo con hash donde ancla el CAS y donde willay puede auditar.
637
638`SCOPE_ABSTRACT_UNIX_SOCKET` (ABI 6) sí se usa: cierra la fuga simétrica hacia sockets abstractos.
639Ojo con la semántica documentada — un socket datagram ya conectado antes del scoping puede seguir
640enviando; y el scoping no admite reglas: si un dominio está scoped, no hay forma de permitir un
641recurso puntual fuera. Es todo-o-nada, lo cual para nosotros está bien.
642
643### D9 — El canario deliberado: `denials=[]` no se cree, se gana
644
645(Nace de la medición de §3.4. Es la decisión que Q1 obligó a tomar.)
646
647**El problema.** Un build hermético y un lector ciego producen exactamente los mismos bytes:
648`denials=[]`. Y §3.4 midió que el kernel **no ofrece ninguna forma de distinguirlos** — un dominio
649sin logging es invisible, y el registro `allocated` es perezoso, así que su ausencia tampoco
650prueba nada. Sin resolver esto, D2 (la evidencia negativa) no vale nada: la afirmación más fuerte
651del sistema sería indistinguible de su falla más común.
652
653**La decisión.** Cada build provoca una denegación conocida, **después del `execve`**, y harkaq
654**exige verla**. El canario es un path que existe en el sandbox y que la política deja
655deliberadamente fuera de la clausura (p.ej. un `--ro-bind` a una ruta fija que el ruleset no
656concede). El probe se antepone al comando del builder — misma vía que `Sandbox::run` ya usa
657(`/bin/sh -c`), así que corre en el dominio y del lado correcto del `exec`.
658
659**El veredicto pasa a ser de tres estados, no de dos:**
660
661| Canario | Otras denegaciones | Veredicto |
662|---|---|---|
663| visto | ∅ | `Hermetico` — y *ahora sí* significa algo |
664| visto | ≥1 | `Impuro { denials }` — el diagnóstico útil |
665| **no visto** | cualquiera | **`SinEvidencia`** — el lector no es confiable; no se afirma nada |
666
667El canario cubre la mala configuración (flag ausente, audit apagado, lector caído). El contador
668`deallocated denials=N` de §3.4 cubre la otra falla, la pérdida de registros por backlog: se exige
669`nº de ACCESS == N`. Dos chequeos, dos fallas, ninguno redundante.
670
671**Y el canario es además la clave primaria de la evidencia** (medido en §3.5). Con un nonce único
672por build, su registro revela el `domain=` de ese build, y `domain=` ata todos los registros
673restantes del mismo. Sin él no hay atribución fiable con la granja construyendo en paralelo: el
674`pid=` viaja en el `allocated`, que es **perezoso**, y `dev`+`ino` identifican el **objeto**, no el
675build — dos builds leyendo el mismo fichero no declarado dan el mismo `ino`, que es justo el caso
676más común que harkaq va a ver. Un mecanismo, tres trabajos: honestidad, atribución y detección de
677pérdida.
678
679**Por qué es un no-negociable.** Es la misma disciplina de D7 (fallo cerrado) y de D3 (cero
680quieting), aplicada al único punto donde el sistema podría mentirse a sí mismo en silencio. Y no
681es hipotético: el banco de pruebas de Q1 ya cayó en esa trampa una vez (§3.4, nota de método).
682
683---
684
685## 6. Fases
686
687**Fase 0 — Reconocimiento.** ✅ **CERRADA (2026-07-15)**. Ver §3. Salidas: ABI 10 en el laptop,
688Landlock ausente-pero-a-un-flag en el kernel de takana, cero FODs y fetcher ya construido.
689
690**Fase 1 — El hito demostrable (una tarde).**
691Precondiciones, todas ya medidas en §3 y ninguna especulativa:
6921. ✅ **`-e SECURITY -e SECURITY_LANDLOCK -e AUDIT` en `recipes/linux.toml`** (2026-07-15). Es lo
693   único que faltaba para que harkaq corra **dentro** de takana (VM/metal) y no sólo en el laptop
694   y la granja. `CONFIG_LSM` del defconfig ya lista `landlock` de primero ⇒ bastó encenderlo, sin
695   tocar la cadena de LSMs. 6.16.12 da **ABI 7** = el audit de denegaciones, que es de lo que
696   cuelga toda la evidencia. `AUDIT` ya venía `=y`; se fija explícito porque **sin él no se emite
697   un solo registro y harkaq certificaría todo como hermético en silencio** (§3.4). Cuesta un
698   rebuild (~35min) y cambia el hash del kernel.
6992. `audit=1` horneado en el cmdline del kernel metal — o `AUDIT_SET` desde hammerd (§3.4, Q1c).
7003. `LANDLOCK_RESTRICT_SELF_LOG_NEW_EXEC_ON` en el `restrict_self` (§3.4). **Sin esto no hay nada.**
7014. El canario de D9 antepuesto al comando del builder.
702
703Después: tomar **una** receta real de takana. Derivar su política de la clausura. Correrla bajo
704Landlock+seccomp dentro del bwrap actual. Emitir el `Verdict` con `denials = []` **y el canario
705visto**.
706
707**✅ HITO DE FASE 1 CUMPLIDO (2026-07-15).** `takana build` sobre una receta **real del catálogo**
708(`recipes/zlib.toml`), con la política derivada de la clausura declarada y el `Verdict` emitido
709**por fase**:
710
711```
712configure →  Hermetico   esperadas (14): /usr/bin/gcc     deuda: ninguna ✓
713compile   →  Impuro      DEUDA (1): /usr/bin/make         → build rc=126 (la jaula lo frenó)
714```
715
716El diagnóstico completo de zlib cabe en una frase: **lo único que toma de Alpine sin declararlo
717es `make`**. Wiring en `crates/hammer-build/src/harkaq.rs`, detrás de `HARKAQ=1` e **inerte** sin
718él (requisito duro: 700+ artefactos sellados no pueden cambiar de hash por encender un
719diagnóstico). `harkaq-policy` es puro y se testea sin kernel (4 tests), como pide §4.
720
721La integración se delató sola en su primera corrida: faltaba `/cache` (superficie de contrato —
722`ZIG_GLOBAL_CACHE_DIR=/cache/zig`, zig **crea** directorios ahí) y la fase moría con
723`fs.make_dir /cache/zig/tmp`. De ahí que las superficies de contrato las decida el `Sandbox`, que
724es quien sabe qué montó, y no harkaq: `/cache` sólo existe si hay `cache_dir`.
725
726**Estado previo — la cadena corre de punta a punta.** `harkaq-audit` (lector, host,
727`CAP_AUDIT_READ`) + `harkaq-exec` (política, estático, dentro de bwrap) + `harkaq-run.sh`
728(orquesta y pone el canario). Los dos actos, con comandos sintéticos:
729
730```json
731limpio  {"estado":"Hermetico","canario_visto":true,"contador_kernel":1,"denials":[]}
732impuro  {"estado":"Impuro","canario_visto":true,"contador_kernel":2,
733         "denials":[{"blockers":"fs.read_file","path":"…/README.md","dev":"nvme1n1p3","ino":"2919408"}]}
734```
735
736Canario visto y contador coherente en ambos ⇒ los tres estados de D9 funcionan sobre el kernel
737real. Precondiciones 2–4 ✅. Falta: la precondición 1 (sólo para que harkaq corra *dentro* de
738takana — el hito se demuestra en el laptop, que ya está en ABI 10), `harkaq-policy` derivando de
739la clausura real, y el contraste receta-de-Alpinizada vs. receta-que-no.
740
741**Sutileza medida: lo que DAC ya bloquea es invisible.** Un acceso que los permisos Unix rechazan
742no genera registro de Landlock (el hook del LSM no llega a correr). No rompe D2 — si DAC lo
743bloqueó, el build tampoco lo usó, así que `Hermetico` sigue siendo cierto — pero acota el
744*diagnóstico*: harkaq no reporta el intento. Para la de-Alpinización es inocuo (el rootfs Alpine
745es world-readable); para escribir tests es una trampa (un escenario "impuro" sobre un path que DAC
746ya prohíbe da `Hermetico` y el control no controla nada).
747Luego correr **una receta que sepamos no de-Alpinizada** y mostrar que la jaula nombra exactamente
748qué usó sin declarar, con path/dev/ino.
749**Ese par de corridas es el argumento entero del proyecto**, y se enseña en dos minutos.
750
751> Cambio respecto del Draft 1: el segundo acto era una derivación de juguete que leía
752> `/etc/passwd`. Una receta real no de-Alpinizada es *mejor demo* (es el problema que de verdad
753> tenemos) y de paso le entrega valor inmediato a una campaña en curso.
754
755### 4.5 Deuda *declarable* vs *irreducible*: la métrica del <5% medía otra cosa
756
757`zlib` salió `Impuro` por `/usr/bin/make` y parecía runtime base. **No lo es:**
758`recipes/make.toml` existe, `store/fbad44ac…-make` está sellado y `SWAP_MAKE` es uno de los swaps
759del `selfhost-verify`. O sea que a zlib **le falta una línea en `[deps]`**, no una excepción.
760
761Esa distinción rehace la métrica del §7, que hablaba de "recetas que necesitan excepción":
762
763| | Qué | Cuenta contra el <5% |
764|---|---|---|
765| **declarable** | el store ya provee el path ⇒ una línea en `[deps]`, arreglo mecánico | **no** |
766| **irreducible** | nadie lo provee ⇒ escribir receta, o aceptar como runtime base | **sí** |
767
768`harkaq-suggest.py` cruza cada path de deuda contra el store (el mapeo de §4.1, al revés: el path
769relativo dentro del artefacto **es** el path del sandbox) y lo dice:
770
771```
772DEUDA DECLARABLE — el store ya lo provee; falta la línea en [deps]:
773    /usr/bin/make          → declarar dep: make
774DEUDA IRREDUCIBLE — nadie la provee:
775    /usr/lib/libz.so.1.3.2
776```
777
778Las dos son correctas y la segunda es sutil: la receta de zlib de takana produce `libz.a`
779estático, no un `.so`, así que un consumidor que quiera el shared **no** se arregla declarando.
780El diagnóstico deja de ser "algo pasó" y pasa a ser "hacé esto".
781
782### 4.6 Primer barrido de Fase 2 (6 recetas)
783
784`scripts/harkaq/fase2-barrido.sh`. Muestra chica y deliberadamente fácil (C, fuentes cacheadas):
785
786| | |
787|---|---|
788| Hermetico | 0 |
789| sólo deuda **declarable** | **5** — y la deuda es *el mismo path en las 5*: `/usr/bin/make` |
790| con deuda **irreducible** | **1** (brotli) |
791
792**El resultado que importa:** la deuda de 5 de 6 recetas es **un solo path**, y se arregla con una
793línea. No hay una cola larga de excepciones — que era el riesgo de muerte del §7.
794
795**Y el caso irreducible es la frontera conocida:** brotli (C++) pide
796`/usr/lib/libstdc++.so.6.0.34` y `/usr/lib/libgcc_s.so.1` — el runtime C++ del gcc de Alpine, que
797takana no construye. Es exactamente la frontera que la campaña *matar gcc* ya tenía identificada
798("gcc retenido sólo para {kernel, cmake}", "lo que queda: los `*-sys` con C++"). **Tercera vez que
799harkaq llega por su cuenta a una lista que otro frente ya tenía**: los swaps del `selfhost-verify`
800(§4.3), los binutils (§4.4) y ahora el runtime C++.
801
802Honestidad sobre la muestra: 6 recetas no son 750, y son las fáciles. 1/6 irreducible es 17%, muy
803por encima del <5% — pero el único caso es un problema *conocido, nombrado y compartido con otros
804dos frentes*, no una cola de sorpresas. El barrido grande es trabajo de granja, no de diseño.
805
806**Bug del barrido, encontrado por el propio barrido:** copiar la receta a otro directorio rompe la
807resolución de `deps.build` (son relativas al directorio de la receta) ⇒ brotli fallaba con "no
808pude cargar la dep 'cmake'" y se descartaba en silencio. Se descartaban **justo las recetas con
809deps, que son las interesantes**. La copia va al lado del original.
810
811### 4.7 El bucle cerrado: de `Impuro` a `Hermetico` sobre una receta real
812
813Diagnosticar no vale nada si no se puede accionar. El bucle completo sobre `recipes/zlib.toml`,
814cada paso guiado **sólo** por lo que dijo harkaq:
815
816| Paso | Veredicto | Lo que dijo harkaq | Acción |
817|---|---|---|---|
818| zlib tal cual | `Impuro` | `/usr/bin/make` → *declarar dep: make* | `[deps] build = ["make"]` |
819| + make | `Impuro` | `/usr/bin/ranlib` → *declarar dep: binutils* | `build = ["make","binutils"]` |
820| + binutils | `Impuro` | `liblto_plugin.so` → *irreducible* | clasificar (ver abajo) |
821| final | **`Hermetico` ×3 fases** | — | artefacto `b3:adc5c251…` sellado |
822
823**Cada capa que se pela destapa la siguiente, y todas convergen en el gcc de Alpine.** El último
824hallazgo es el más interesante y no lo habría encontrado a mano: **los binutils *de takana* — ya
825declarados como dep — cargan el plugin LTO del gcc *de Alpine*
826(`/usr/libexec/gcc/x86_64-alpine-linux-musl/15.2.0/liblto_plugin.so`)**. No es un problema de
827declaración de la receta: es un agujero de soberanía *dentro de un artefacto que takana construye*.
828
829Va a **denegación esperada**, y por la misma razón que `/usr/bin/gcc` (§4.2): el build completa sin
830él ⇒ es una *sonda*, no una necesidad, y **bloquearla es deseable** — impide que los binutils de
831takana usen el plugin de Alpine por detrás. D3 otra vez: no se calla, se clasifica.
832
833**Detalle que confirma el modelo:** el hash del artefacto es **idéntico** antes y después de
834clasificar la sonda (`b3:adc5c251…` las dos veces). Clasificar cambia el *veredicto*, no el
835*build*. Es el principio de H1 sosteniéndose solo: **la evidencia es comportamiento, no
836identidad** (`recipe.rs:228`, y por eso `Evidence` está fuera de `hash_inputs`).
837
838**Y esto es lo que `Hermetico` significa ahora, con todo el peso:** el kernel certificó que ese
839build no usó **nada** fuera de su clausura declarada, y el canario prueba que el certificado no es
840el silencio de un lector roto.
841
842---
843
844### 4.10 Barrido en la granja: la deuda irreducible de la muestra es **perl**
845
846Primer barrido sobre la granja con ABI 7 (`harkaq-farm-setup.sh` + `fase2-barrido.sh`, 24 recetas,
84714 con veredicto — las 10 restantes son Go/Rust y cayeron en caché o fetch). El primer número
848crudo fue **4 irreducibles (29%)**. Era falso, y por dos motivos propios:
849
8501. **Gap de política.** Los `list` salían sólo de los ancestros de la clausura ⇒ una receta **sin
851   deps** (bash) no generaba ni uno, y todo escaneo del árbol caía como denegación (`/usr/lib`).
852   Listar no es leer: Landlock separa `READ_DIR` de `READ_FILE`, así que **la estructura del árbol
853   es contrato** — se puede `ls /usr/lib` sin leer un solo fichero no declarado. La deuda es leer
854   lo ajeno, no saber que existe. (Idem `/var/tmp`: con `--tmp-overlay /` es scratch descartable,
855   como `/tmp`.)
8562. **La heurística del catálogo es por NOMBRE**, y un fichero no se llama como su paquete:
857   `/usr/bin/ranlib` lo trae `binutils`, `/usr/bin/diff` lo trae `diffutils`. El único que sabe la
858   verdad es el **store**, porque conoce la lista de ficheros de cada artefacto — y el worker sólo
859   tiene 103 artefactos contra los cientos del hub.
860
861⇒ **El worker MIDE, el hub CLASIFICA.** Es la misma separación que lector/clasificador (§4.2): el
862que tiene el privilegio —o los datos— hace lo mínimo. Reclasificado en el hub:
863
864| receta | crudo en el worker | real |
865|---|---|---|
866| bash | `ranlib`, `/usr/lib`, `/var/tmp` | — (gap de política) |
867| doas | `/usr/bin/diff` | — (`diffutils` lo provee) |
868| ca-certificates, curl | `/usr/bin/perl` | **`/usr/bin/perl`** |
869
870**Resultado: la deuda irreducible de toda la muestra es UN path — `/usr/bin/perl`** (2 de 14 =
87114%). No hay `recipes/perl.toml` ni artefacto en el store: es deuda genuina y nombrada. Todo lo
872demás era declarable — `make` ×10, `ranlib`→binutils, `diff`→diffutils.
873
874### 4.11 …y perl ya existía. El barrido queda en **0 irreducibles**
875
876El comentario de `recipes/ca-certificates.toml` lo decía con todas las letras, escrito por quien
877la portó:
878
879> *"El resto del build (mk-ca-bundle.pl → cert.pem → split-ca-bundle.sh) usa **el perl +
880> coreutils del rootfs**"*
881
882**La deuda estaba escrita en prosa y era invisible para el sistema.** harkaq la convirtió en un
883veredicto. Ésa es la tesis del §0 en una línea: no hace falta que nadie *descubra* nada — hace
884falta que la máquina pueda *ver* lo que ya se sabía.
885
886Y la receta también existía: `recipes/incoming-kde/perl.toml`, importada el 13/07 para la campaña
887KDE (`syntax-highlighting` e `intltool` exigen perl). **Cuarta convergencia independiente**: dos
888campañas necesitaban el mismo binario y llegaron a él por caminos que no se hablan. Construye tal
889cual (`b3:c7a899bd…`), y con el artefacto en el store:
890
891```
892/usr/bin/perl  → declarar dep: perl        exit=0  ⇒  CERO deuda irreducible
893```
894
895**El barrido de 24 recetas queda en 0 irreducibles.** La métrica del §7 (<5%) se cumple —
896midiendo lo que la métrica quería medir: *cosas que no sabemos construir*. Resultado: ninguna.
897
898La receta queda promovida a `recipes/perl.toml`. Lo que queda es mecánico y no es diseño:
899declarar `make` en las ~10 recetas que lo usan y `perl` en ca-certificates/curl. **Ojo:** eso
900re-hashea sus artefactos, y son paquetes del base-system (índice firmado 748) ⇒ es una decisión de
901rollout, no un `sed`.
902
903### 4.9 Matar gcc: la última milla, cerrada
904
905El primer barrido (§4.6) dejó **un solo** caso irreducible: brotli pidiendo
906`libstdc++.so.6.0.34` + `libgcc_s.so.1`. brotli es **C**, no C++ — la libstdc++ no era suya.
907
908Era de `cmake`, su dep de build. El artefacto que **takana construye**:
909
910```
911store/99864dcc…-cmake/usr/bin/cmake
912    NEEDED: libstdc++.so.6, libgcc_s.so.1, libc.so
913```
914
915Y la receta lo explicaba sin verlo: `CC=gcc CXX=g++` *"el cmake mínimo que `bootstrap` compila con
916`zig c++` SEGFAULTEA al correr"*. O sea que "gcc retenido para {kernel, cmake}" no era una
917concesión de *build-time* como sonaba: **el artefacto de cmake arrastraba el runtime C++ de Alpine
918hacia dentro de cada build que lo declarara como dep**. Un agujero de soberanía que viajaba por el
919grafo de deps, invisible en la receta del consumidor.
920
921**El arreglo no toca el compilador** — gcc sigue compilando cmake, que es el camino conocido-bueno.
922Sólo deja de enlazar su runtime en dinámico:
923
924```
925CXX='g++ -static-libstdc++ -static-libgcc'   LDFLAGS='-static-libstdc++ -static-libgcc'
926```
927
928Medido:
929
930| | NEEDED | |
931|---|---|---|
932| antes | `libstdc++.so.6`, `libgcc_s.so.1`, `libc.so` | arrastra el runtime de gcc de Alpine |
933| después | `libc.musl-x86_64.so.1` | **y corre: `cmake version 3.31.6`** |
934
935**Y el pago:** brotli —el único irreducible del barrido— pasa a **`Hermetico` ×3 fases**, artefacto
936sellado `b3:bc900676…`. Su deuda restante (`ld`, `make`) era declarable y se declaró.
937
938El barrido queda **0 irreducibles de 6**. El caso que iba a contar contra el <5% no era una receta
939mal escrita: era una herramienta de takana filtrando Alpine, y harkaq la señaló desde el consumidor.
940
941**Rollout: ✅ HECHO (2026-07-15).** cmake reconstruido en un worker de la granja (regla: la cadena
942GUI no se rebuildea en el laptop) y cosechado al hub: `b3:834132e7…-cmake`, `NEEDED` = sólo
943`libc.musl-x86_64.so.1`. El viejo (`99864dcc…`, con `libstdc++.so.6`+`libgcc_s.so.1`) queda en el
944store por si algo lo referencia.
945
946**Los 10 consumidores NO se reconstruyeron, y no hace falta:** el fix quita la dependencia de
947runtime del artefacto *de cmake*. Los consumidores sólo lo usaron como *herramienta de build* —
948sus artefactos no linkean `libstdc++`. Rebuildearlos sólo daría frescura de hash, y eso ocurre
949solo en su próximo build. Confundir "usó la herramienta" con "linkea la librería" habría costado
950un rebuild de gtk4 para nada.
951
952**Regalo del rollout:** el worker (Ubuntu, ccx23) y el laptop (CachyOS) produjeron cmake con el
953**mismo hash, bit a bit** — el invariante central de takana confirmándose entre dos máquinas
954distintas, sin que nadie lo buscara. Y como los bytes son idénticos, el `NEEDED` del worker está
955verificado por transitividad: no hizo falta `readelf` allá (que además no está instalado).
956
957**Lo que NO cierra esto:** `/usr/bin/gcc`, `c89`, `c99`, `ldd` y el plugin LTO de §4.7 siguen
958siendo *sondas* — la jaula las deniega, los builds completan igual, y denegarlas es lo correcto.
959El gcc de Alpine sigue en el rootfs y sigue haciendo falta para {kernel, cmake} **en build-time**.
960Lo cerrado es la filtración a *runtime*, que es la que contaminaba artefactos.
961
962### 4.8 El barrido NO sale por la granja VPS (todavía): la imagen golden es ABI 4
963
964Medido en `hworker-4` (2026-07-15) con el syscall, que es la única vía válida:
965
966```
967kernel 6.8.0-134-generic (Ubuntu 24.04)   →   LANDLOCK ABI = 4
968CONFIG_SECURITY_LANDLOCK=y   CONFIG_AUDIT=y   sin auditd compitiendo
969```
970
971Landlock está compilado y el audit también, y no hay `auditd` peleando por el netlink — pero
972**ABI 4 < 7 ⇒ no hay audit de denegaciones**. Por D7 la granja correría la jaula y emitiría
973`SinEvidencia` en **todos** los builds: exactamente lo que el diseño manda hacer, y exactamente lo
974que no sirve para medir.
975
976**El arreglo es chico y está identificado:** `linux-image-generic-hwe-24.04` → **6.17.0-40** está
977en el archivo estándar de Ubuntu 24.04 (no hace falta PPA ni cambiar de distro). Un `apt install`
978+ reboot + verificar ABI + re-snapshot de la imagen golden (`IMAGE=405120842`). El barrido grande
979depende de ese bump, no de más diseño.
980
981Nota operativa: `hworker-4` está vivo con la campaña KDE; el bump conviene hacerlo en un worker
982efímero nuevo (`farm-up 1`) y re-snapshotear desde ahí, sin tocarlo.
983
984---
985
986**Fase 2 — `harkaq-policy` completo + medir la brecha.** Clausura real, `/src`, `/out`, `/tmp`,
987`/cache`, `/proc` y `/dev` mínimos, y el **runtime base** (sin `/bin/sh` ni la libc del rootfs no
988arranca nada — ver Q3). Correr contra N recetas del catálogo y medir la tasa de falsos positivos.
989*Métrica de éxito: <5% de recetas necesitan excepción.*
990El delta entre `política = clausura` y `política = clausura + base Alpine` **es** la medida
991cuantitativa de la deuda de de-Alpinización del catálogo. Ese número no existe hoy.
992
993**Fase 3 — El bucle epistémico.** `Verdict → willay → iniy`, como `kind` nuevo junto a la
994evidencia H1 (§0.1). Aquí el agente empieza a recibir su propio reflejo: una receta cuyo build
995denegó cosas es una receta de la que el sistema desconfía, y esa desconfianza es un número, no una
996vibra.
997
998*(La Fase 3 del Draft 1 —el fetcher mediado— se eliminó: ya existe. Ver §3.3.)*
999
1000---
1001
1002## 7. Riesgos y honestidades
1003
1004- **El kernel es la TCB.** harkaq no contiene un exploit de kernel. Landlock+seccomp reduce
1005  superficie; no la elimina. Si el modelo de amenaza incluye "la IA encuentra un 0-day de kernel",
1006  la respuesta correcta es microVM, no LSM. Decir esto en el README, no en letra chica.
1007- **La entrega de la evidencia es el único riesgo técnico serio que queda.** Ver Q1. Todo el
1008  proyecto cuelga de poder leer los registros `AUDIT_LANDLOCK_*` de forma confiable desde el
1009  proceso que orquesta el build. Si eso no se resuelve limpio, harkaq queda sin producto.
1010- **Falsos positivos matan proyectos de sandboxing.** Si el 30% de los builds necesita excepción
1011  manual, harkaq se desactiva y queda como decoración. La Fase 2 existe para descubrir esto
1012  temprano y barato. Atenuante: acá un "falso positivo" suele ser un **verdadero positivo de
1013  de-Alpinización**, y el catálogo tiene ~750 recetas para probarlo.
1014- **Domesticar el vocabulario.** harkaq no prueba que un build fue hermético. Prueba que *bajo el
1015  supuesto de que el kernel y la derivación de política son correctos*, el build no accedió fuera
1016  de su clausura declarada. Es una afirmación fuerte y defendible. La versión sin asteriscos es la
1017  que te van a cobrar. (Mismo criterio que SDD 15 §0: "verificado bajo supuestos nombrados".)
1018- **Competencia.** Como proyecto OSS genérico, "sandbox para agentes" es un espacio poblado (ya
1019  existe landrun) y la ventana no es infinita. Como el ejecutor de takana, no compite con nadie: el
1020  diferenciador no es la jaula, es que **la política sale del hash y la evidencia entra al grafo de
1021  confianza**. Nadie más tiene CAS + log monótono + grafo para cerrar ese bucle.
1022- **El rebuild del kernel cambia su hash.** Encender `SECURITY_LANDLOCK` invalida
1023  `060b34a6…-linux-metal` y lo que dependa de él. Es esperado y barato, pero hay que agendarlo, no
1024  descubrirlo.
1025
1026---
1027
1028## 8. Preguntas abiertas
1029
1030- **Q1-copy.fail — ✅ RESPONDIDA (2026-07-16) con fs-verity.** El SDD dejó abierto: *«¿el vector
1031  page-cache de `copy.fail` sobrevive a un ruleset que deniegue escritura a nivel de inode?
1032  Investigar antes de afirmar nada»*. **Investigado, en un fs aislado (loop, nunca el fs real):**
1033
1034  | ataque | resultado |
1035  |---|---|
1036  | escribir al artefacto sellado | `EPERM` |
1037  | `dd` como **root** | `Operation not permitted` — ni root |
1038  | **machacar los bytes POR DEBAJO** (en el device) y leer | **`Input/output error`** |
1039
1040  El tercero es el vector de `copy.fail`: se modifica el fichero por debajo del filesystem. **El
1041  kernel detecta la corrupción al leer** (el Merkle no cuadra) y rechaza la lectura. ⇒ **fs-verity
1042  mata el vector**; Landlock read-only no alcanzaba, fs-verity sí.
1043
1044  **Costo medido, contra lo que dice el SDD 17 §5:** *«hash = identidad»* es **falso** — fs-verity
1045  usa un Merkle **SHA-256** y takana direcciona con **BLAKE3**: son **dos** hashes, no uno. Y en
1046  ext4 exige `-O verity` **y blocksize = PAGE_SIZE** (con 1024 el mount falla:
1047  `Unsupported blocksize for fs-verity`). No es gratis, pero es barato para lo que da.
1048
1049- **Q1 — ✅ CERRADA (2026-07-15).** La evidencia llega, con `blockers`/`path`/`dev`/`ino`/`exe`, por
1050  el multicast `AUDIT_NLGRP_READLOG`. Precondiciones medidas: `audit_enabled=1` (no viene de
1051  fábrica) y `LOG_NEW_EXEC_ON` (sin él, cero registros tras el `exec`). No hay canario gratis ⇒
1052  D9. Detalle y mediciones en §3.4. **El gate del proyecto está pasado: harkaq es viable.**
1053- **Q1b — ✅ CERRADA (2026-07-15).** Los registros cruzan el userns/pidns/mountns de bwrap hasta un
1054  lector en el host, con builds **sin privilegio**. La atribución con la granja en paralelo se
1055  resuelve con el canario de nonce único (D9): revela el `domain=`, que ata el resto. Medido con 2
1056  builds concurrentes, cero cruces. Detalle en §3.5.
1057- **Q1c (Fase 1):** ¿quién corre `harkaq-audit` y con qué privilegio? Necesita `CAP_AUDIT_READ`
1058  (leer el multicast) y, salvo que se hornee `audit=1` en el cmdline, `CAP_AUDIT_CONTROL` (para el
1059  `AUDIT_SET`). En el laptop hizo falta root. Medido en §3.5: el lector privilegiado y los builds
1060  sin privilegio conviven bien. Falta decidir si es hammerd o un helper con capabilities acotadas
1061  — preferible lo segundo: `CAP_AUDIT_*` es superficie de más para el daemon entero.
1062- **Q1d (Fase 1, menor):** el `exe=` del registro trae la ruta **del host**. Con el sandbox real
1063  (`--bind <src> /src`) hay que confirmar qué ruta reporta para un builder que vive en `/src`, y si
1064  sirve para algo o basta con `domain=`.
1065- **Q2 — ✅ RESPONDIDA (2026-07-15), y el resultado es mejor de lo esperado.** Ver §4.2. El
1066  runtime base de Alpine son **5 paths**, medidos; la deuda queda aislada sin ruido; y apareció
1067  una tercera categoría que no estaba en el diseño: la *denegación esperada*.
1068- **Q3 (Fase 2):** ¿`/proc` y `/dev` mínimos rompen builds reales? (`/proc/self` suele hacer falta;
1069  `/proc/sys` casi nunca.) bwrap ya monta `--proc /proc --dev /dev`.
1070- **Q4:** ¿el `Verdict` entra en `Recipe::hash_inputs`? Por simetría con `Evidence` (H1), **no**:
1071  hermeticidad es comportamiento, no identidad. Confirmar con el mismo criterio de `recipe.rs:228`.
1072
1073*(Las Q1/Q4/Q5 del Draft 1 —el vector page-cache de `copy.fail`, la fracción de FODs, y si harkaq
1074envuelve o reemplaza a nix— se eliminan: eran todas sobre nix.)*

16-harkaq-jaula.md

SDD 16 — harkaq: la jaula de takana

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. takana 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: takana 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:

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 takana: el sandbox de bwrap asume que quien escribe la receta sabe lo que declaró. En takana, 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 takana-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:

PreguntaMecanismoEvidencia
H1¿lo declarado pasa?checks corridos en el sandboxpositiva (los checks pasan)
harkaq¿lo declarado basta?política = clausura, en el kernelnegativa (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


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óndeKernelABIVeredicto
Laptop de desarrollo7.2.0-rc3-1-cachyos-rc10techo; todo disponible
Kernel de takana6.16.12-metal (recipes/linux.toml)ausente# CONFIG_SECURITY_LANDLOCK is not set

Lo que hace falta de cada ABI, y desde dónde:

ABIKernelQué traePara harkaq
25.19FS_REFERnecesario — sin refer no compila nada real
36.2FS_TRUNCATEnecesario
46.7NET_BIND_TCP, NET_CONNECT_TCPinerte — no hay red que filtrar (§6)
56.10FS_IOCTL_DEVcierra ioctls a dispositivos
66.12SCOPE_ABSTRACT_UNIX_SOCKET, SCOPE_SIGNALcorta fuga lateral
76.15audit de denegacioneseste es el proyecto entero
87.0RESTRICT_SELF_TSYNCrobustez (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 takana: 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:

Eso es la jaula con ventanilla que D8 proponía construir. takana 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 takana es horneable, igual que el resto del cmdline metal.

Precondición 2 — LOG_NEW_EXEC_ON es obligatorio, y su ausencia es indetectable. Medido:

EscenarioRegistros 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 takana-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 takana 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íaQuéQué hacer
Contrato del sandbox/src, /out, /tmp, /dev/null (rw: es destino de escritura), /dev/urandom, /proc, /opt/zigconceder — 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-releaseconceder — irreducible, y son 5
Denegación esperada/usr/bin/gccdenegar 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):

RondaDeuda restante (paths únicos)
1/bin/bash, /bin/coreutils
2libacl, libattr, libcrypto, libreadline, libutmps
3libncursesw, 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:


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. takana 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 takana, 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 takana, 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 takana 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

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:

CanarioOtras denegacionesVeredicto
visto∅Hermetico — y ahora sí significa algo
visto≥1Impuro { denials } — el diagnóstico útil
no vistocualquieraSinEvidencia — 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 takana, 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 takana (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 takana. 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). takana 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:

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 2–4 ✅. Falta: la precondición 1 (sólo para que harkaq corra dentro de takana — 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%
declarableel store ya provee el path ⇒ una línea en [deps], arreglo mecánicono
irreduciblenadie lo provee ⇒ escribir receta, o aceptar como runtime basesí

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 takana 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):

Hermetico0
sólo deuda declarable5 — y la deuda es el mismo path en las 5: /usr/bin/make
con deuda irreducible1 (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 takana 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:

PasoVeredictoLo que dijo harkaqAcción
zlib tal cualImpuro/usr/bin/make → declarar dep: make[deps] build = ["make"]
+ makeImpuro/usr/bin/ranlib → declarar dep: binutilsbuild = ["make","binutils"]
+ binutilsImpuroliblto_plugin.so → irreducibleclasificar (ver abajo)
finalHermetico ×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 takana — 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 takana 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 takana 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:

recetacrudo en el workerreal
bashranlib, /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 takana 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
anteslibstdc++.so.6, libgcc_s.so.1, libc.soarrastra el runtime de gcc de Alpine
despuéslibc.musl-x86_64.so.1y 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 takana 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 takana 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

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


8. Preguntas abiertas

(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.)