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