Files
hammer/docs/16-harkaq-jaula.md
T
sergioandClaude Opus 4.8 24c6ab455e harkaq: §4 corregido (política POR FICHERO) + Q2 con método y primeros datos reales
CORRECCIÓN ESTRUCTURAL del §4 (otra herencia del marco nix). El Draft 2 decía
`fs_ro: [store paths de la clausura]`. Falso: esos paths NO existen dentro del
sandbox. `Sandbox::bwrap_args` apila las deps con --overlay-src y las FUNDE en
el mismo /usr que el rootfs Alpine, a propósito, para que el compilador las
encuentre sin plumbing de flags. Medido:

  /usr/include/zlib.h   (dep DECLARADA)        dev=63 ino=13641973
  /usr/include/stdio.h  (Alpine, NO declarado) dev=63 ino=4196788
  /usr/include          (el directorio)        dev=61 ino=15  ← overlayfs, uno

Una regla sobre /usr concede las dos ⇒ la evidencia no valdría nada. Ni `dev`
distingue. ⇒ La clausura se enumera FICHERO A FICHERO (computable: en el host
sabemos qué aporta cada dep). Dos consecuencias: `list` ≠ `ro` (pkgconf NECESITA
escanear /usr/lib/pkgconfig, pero listar no es leer; Landlock separa READ_DIR de
READ_FILE) y máscara por tipo (derechos de-sólo-dir sobre un fichero ⇒ EINVAL y
la regla entera se cae).

Piezas nuevas: harkaq-policy.sh (deriva la clausura, D1) + q2-runtime-base.sh
(responde Q2 midiendo, no adivinando) + harkaq-exec con `list`/máscara por tipo.

Q2, primeros datos con un build REAL en el sandbox REAL contra la dep zlib:
  estado: Impuro | canario: True | contador kernel: 3
      fs.read_file /usr/bin/env
      fs.read_file /usr/lib/libz.so.1.3.2   ← la zlib de ALPINE!

El build se linkaba contra la zlib de Alpine en vez de la dep declarada (que
aporta libz.a). Sin harkaq eso pasa en verde y el artefacto queda dependiendo de
Alpine. ES EXACTAMENTE EL BUG DEL §0, cazado en un build real.
Piso duro del runtime base: /bin/sh → /bin/busybox → /lib/ld-musl (sin eso ni el
canario corre ⇒ el mínimo es "lo que hace falta para que el canario pueda correr").

Dos rastrillos: el canario no puede depender de un binario (`cat` moría en
/bin/cat ⇒ ahora `read _ < $CANARY`, sólo builtins) ni vivir en /tmp (la política
concede `rw /tmp` ⇒ el canario caía DENTRO de la clausura y no denegaba nada).
El segundo lo cazó D9 MISMO: el lector se negó a certificar en vez de decir
Hermetico. El canario funcionando como se diseñó, sobre un bug de quien lo diseñó.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:20:39 -04:00

39 KiB
Raw Blame History

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 RecipePolicySpec, 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. listro. 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.

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

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. scripts/config -e SECURITY_LANDLOCK en recipes/linux.toml (+ rebuild, ~35min, cambia el hash).
  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.

Estado (2026-07-15): 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 24 . Falta: la precondición 1 (sólo para que harkaq corra dentro de hammer — el hito se demuestra en el laptop, que ya está en ABI 10), harkaq-policy derivando de la clausura real, y el contraste receta-de-Alpinizada vs. receta-que-no.

Sutileza medida: lo que DAC ya bloquea es invisible. Un acceso que los permisos Unix rechazan no genera registro de Landlock (el hook del LSM no llega a correr). No rompe D2 — si DAC lo bloqueó, el build tampoco lo usó, así que Hermetico sigue siendo cierto — pero acota el diagnóstico: harkaq no reporta el intento. Para la de-Alpinización es inocuo (el rootfs Alpine es world-readable); para escribir tests es una trampa (un escenario "impuro" sobre un path que DAC ya prohíbe da Hermetico y el control no controla nada). Luego correr una receta que sepamos no de-Alpinizada y mostrar que la jaula nombra exactamente qué usó sin declarar, con path/dev/ino. Ese par de corridas es el argumento entero del proyecto, y se enseña en dos minutos.

Cambio respecto del Draft 1: el segundo acto era una derivación de juguete que leía /etc/passwd. Una receta real no de-Alpinizada es mejor demo (es el problema que de verdad tenemos) y de paso le entrega valor inmediato a una campaña en curso.

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 — 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 (Fase 2) — EN CURSO, con método y primeros datos. ¿Qué es el "runtime base" que toda receta necesita y ninguna declara? Sigue siendo la decisión de diseño de verdad — determina si la métrica de la Fase 2 mide deuda real o ruido. El método está resuelto: no se adivina, se mide. scripts/harkaq/q2-runtime-base.sh corre un build real en el sandbox real con política = clausura declarada y deja que las denegaciones nombren el resto.

    Medido (2026-07-15), compilando contra la dep zlib del store:

    • Piso duro: /bin/sh/bin/busybox/lib/ld-musl-x86_64.so.1. Sin esto ni el canario corre (el execvp de /bin/sh rebota) ⇒ SinEvidencia y cero diagnóstico. El runtime base mínimo es, literalmente, lo que hace falta para que el canario pueda correr.
    • Candidato: /usr/bin/env — legítimo, ninguna receta lo declara ni debería.
    • Y el hallazgo que justifica el proyecto: fs.read_file /usr/lib/libz.so.1.3.2. El build se linkaba contra la zlib de Alpine en vez de la dep declarada (que aporta libz.a). Sin harkaq eso pasa en verde y el artefacto queda dependiendo de Alpine. Con harkaq el kernel lo nombra, con path e inode. Es exactamente el bug de §0, cazado en un build real.

    Falta: correrlo sobre el catálogo y separar el runtime base (pocos, estables, en todas las recetas) de la deuda real (variable, por receta). Esa separación es la respuesta a Q2.

  • Q3 (Fase 2): ¿/proc y /dev mínimos rompen builds reales? (/proc/self suele hacer falta; /proc/sys casi nunca.) bwrap ya monta --proc /proc --dev /dev.

  • Q4: ¿el Verdict entra en Recipe::hash_inputs? Por simetría con Evidence (H1), no: 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.)