dec-0117

Vista generada de dec-0117: Seguridad por diseño, parte 1: threat model y arquitectura del data plane Zig

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md

Enmienda a: r10, r48, dec-0103, dec-0110

Enmendado o sustituido por: dec-0126, dec-0127, dec-0129

Por qué importa (del frontmatter del ADR):

Threat model y arquitectura de seguridad del data plane Zig (styx-media-daemon, media-core, libav, parsers, ficheros). Fija los invariantes que impiden que un bug en el daemon escale (capabilities firmadas, sesiones sólo de playback-svc, presupuestos, ReleaseSafe en parsers, libav aislable, raíces fd-relativas, contenedor mínimo) y la capa reutilizable zkit.safety + los guards de build de styx que prohíben saltársela. Sin este ADR, cada lane de data plane decide su seguridad por su cuenta y el AppSec final no tiene contra qué auditar.

Nodos del roadmap que lo citan en refs: track/byte-runtime/sec, track/byte-runtime/zkit, track/byte-runtime/ingest, track/byte-runtime/egress, track/byte-runtime/tcp-edge, track/media-engine, track/media-engine/metadata-embed

Páginas de la documentación que lo citan: Evidencia y verificación adversarial (implementado), Guardas del repositorio (implementado), Arquitectura (implementado), Dispositivos, mando y handoff (especificado), Plugins y fuentes (implementado), Servidores conectados y Jellyfin (especificado), Metadatos en el fichero (especificado), Protocolos binarios (implementado), Comunicaciones entre procesos (implementado), Seguridad del data plane (implementado), Modelo de amenazas (implementado), Byte runtime (implementado), Capacidades SCT (implementado), Motor de medios y plan de reproducción (implementado), Transferencias (especificado), Activar los metadatos incrustados (especificado), Raíces y fuentes (especificado), Storage Box (especificado), Subir ficheros (especificado), Modos de reproducción (implementado), Desplegar styx en Coolify (especificado), Instalar (especificado), Seguir una reproducción por todos los servicios (especificado), Visión (especificado), Primera biblioteca (especificado), Primera película en Chrome (especificado)

Texto del ADR

Leído de docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md, el fichero canónico.

dec-0117 — Seguridad por diseño, parte 1: threat model y arquitectura del data plane Zig

  • Fecha: 2026-09-28
  • Estado: LOCKED. Decisiones de waxin del 2026-09-28 (sesión de coordinación, recogidas en las notas de entorno de la tanda 3): "SEGURIDAD POR DISEÑO (defensa en profundidad, que un bug no escalado sea imposible de escalar)" y "SEGURIDAD EN 2 PARTES. PARTE 1 ZIG = capa de seguridad REUTILIZABLE en zkit … DOBLE CAPA". Este texto convierte esas decisiones en invariantes verificables. Los detalles marcados como reversibles se pueden cambiar sin ADR.
  • Par: dec-0118 (parte 2, TS/web) y dec-0119 (spire, punto único de seguridad de comunicaciones). Los tres forman el threat model; el AppSec final los audita juntos.
  • Enmienda:
    • r10 (URL firmada del delivery plane): la firma pasa a ser una capability con forma y vinculaciones fijadas aquí (§4.1), no una URL con HMAC genérico.
    • r48 §3.1 (trust levels declarativos sin enforcement): los niveles sandboxed, remote y community no pueden ejecutar código dentro del proceso del daemon (§4.9).
    • dec-0103 §2: el trabajo de thread-safety de HandleSlab en zkit incluye generación de 32 bits y tipado (§5.1). zkit gana el módulo safety (§5), que sigue la regla de dec-0103 §1: styx no reimplementa esas primitivas.
    • dec-0110: el motor libav corre detrás de un contrato que permite moverlo a un worker sandbox (§4.6). No contradice dec-0110 §2 («FFmpeg nunca como proceso»): el worker es un binario de Styx que enlaza libav como librería. Nunca se ejecuta un binario de FFmpeg.
  • Enmendado por dec-0126 y dec-0127 (LOCKED juntos, 2026-10-02):
    • I3 y §4.1: dos scopes nuevos. annotate es escritura de metadatos de un solo uso, ligada a {rootId, relPath, dev, ino, size, mtimeNs, planDigest} (dec-0126 §3.4). art es lectura del almacén de artwork por sesión de usuario, ligada al actor y a su acv (lock de dec-0127, P4). El emisor sigue siendo sólo playback-svc (A4).
    • I8: las raíces de media siguen ro salvo las que el operador marca rw-metadata (dec-0126 §4.1). Las raíces que gestiona Styx nacen marcadas así.
  • Enmendado por dec-0129 (LOCKED, 2026-10-02): I3 y §4.1 ganan el scope download. resource = asset + perfil (o raíz de transferencia + perfil), range = el fichero entero, sid ligado por IPC como en dec-0121. Vive más que read porque un gestor de descargas reanuda con la misma URL; el tope es en horas, ligado a la duración estimada (parámetro reversible del lock de dec-0129, P7). La revocación sigue siendo inmediata: el daemon re-autoriza cada petición contra la sesión ligada (I2). El emisor sigue siendo sólo playback-svc.
  • Sustituye en la práctica a: docs/design/ThreatModel-v0.md (baseline F0, loopback-only). Ese documento queda como registro de F0. Cuando lo contradiga, gana este ADR.

Contexto: qué hay hoy (verificado en 54e997a)

El daemon nació como laboratorio de loopback. Su modelo de seguridad es «nadie más que yo llega aquí», y no aguanta la exposición que pide el producto (LAN, Internet vía delivery-edge, navegadores publicando por MoQT, ingesta con conduit):

HechoDóndeConsecuencia
El sessionId (128 bits CSPRNG) es el único secreto del camino WT: quien lo conoce lee los bytes. No caduca y no está ligado a usuario, asset ni rango.transport/quiczig_transport.zig:104-110 (parseSessionIdFromPath; sin prefijo /styx/ devuelve el path entero), :389 onConnectRequest acepta todo CONNECTUn sessionId filtrado (logs, Referer, historial) da acceso completo mientras la sesión viva
MoQT PUBLISH de cualquier peer se acepta y se reenvía al relay. No hay autenticación de clientes.moqt/moqt_server.zig:806 → handlePublish :827Escritura no autenticada al relay: cualquiera inyecta pistas en el fanout. Es el hueco crítico de las notas de la tanda 3
El socket de control IPC se crea en /tmp sin chmod, sin SO_PEERCRED y sin token. En dev se monta en el contenedor desde el /tmp del host.ipc/control.zig:82 (/tmp/styx-media-daemon.sock), :1102-1120 (unlink + bind + listen), deploy/docker-compose.apps.dev.yml:53Cualquier proceso local con acceso al path crea sesiones sobre cualquier fichero de la raíz
CreateSession{assetPath} recibe un path absoluto desde Bunipc/control.zig:10,134La frontera IPC transporta nombres de fichero, no identidades de recurso
Path guard basado en realpath + prefijo. Admite TOCTOU entre el realpath y open(2), y el open sigue symlinks. El propio código lo documenta.media-core/source/local_file.zig:163-192, :295Un symlink que se cambie entre la comprobación y el open saca la lectura de la raíz
La raíz permitida vive en un global mutable (setAllowedRoot("") = /)local_file.zig:195-248Un camino de test puede abrir el FS entero. No es un fallo de producción, pero sí una trampa
Build con standardOptimizeOption. Nada impide un binario de producción ReleaseFast (sin safety checks)native/zig/build.zig:14Un overflow en un parser pasa de pánico a corrupción de memoria
Recuento bruto (grep, incluye comentarios) sobre los 48 .zig no-test de media-daemon/ + media-core/: catch {} 42, catch unreachable 23, @ptrCast 58, std.Thread.Mutex 13, pthread_ 155, realpath 26comando en docs/track/byte-runtime/plans/security-part1-data-plane.plan.md §BaselineEs el punto de partida del trinquete de guards (§6)
Contenedores de servicios Bun: USER bun (non-root). El daemon no tiene Dockerfile. read_only sólo en algunos servicios de dev. Sin cap_drop, no-new-privileges ni seccomp en ninguno.docker/*.Dockerfile, deploy/docker-compose.apps*.ymlUn RCE en un servicio hereda las capabilities por defecto de Docker

Qué se decide

1. Activos

ActivoPropiedad que se protegeDónde vive
A1 Biblioteca del usuario (ficheros de media, subtítulos, metadatos locales)confidencialidad (biblioteca privada), integridadraíces montadas en el daemon
A2 Ficheros fuera de las raíces (host, secretos, /proc)confidencialidad e integridad absolutashost
A3 Proceso del daemon (memoria, fds, sockets, claves TLS)integridad (el daemon es el componente con FS y red a la vez)contenedor daemon
A4 Clave de firma de capabilitiesintegridad: quien la tiene emite accesossólo playback-svc (el daemon guarda claves públicas)
A5 Cache L1/L2 y spool de ingestaintegridad (poisoning) y cuota de discovolúmenes del daemon
A6 Disponibilidad (CPU, ancho de banda, memoria, fds)disponibilidad por usuario y globaldaemon
A7 Relay MoQT (pistas publicadas)integridad (inyección) y confidencialidad (suscripción ajena)daemon
A8 Telemetría (métricas, logs, trazas)no filtrar tokens, paths ni ids de actor en claroOTel

2. Fronteras de confianza

#FronteraLado no confiableQué cruza
B1Cliente ↔ daemon (WebTransport, MoQT sobre WT, HTTP/3, QUIC)todo el wirehandshakes, CONNECT paths, frames MoQT, rangos H3, datos publicados
B2playback-svc (Bun) ↔ daemon, socket unix de controlel peer local hasta autenticarseCreateSession, Seek, Cancel, Close, señales
B3daemon ↔ fuentes remotas (HTTP/S3/WebDAV/Torrent/Jellyfin/Xtream)respuestas y bytes remotoscabeceras, longitudes, redirecciones, contenido
B4bytes de un fichero → parsers (MP4/ISOBMFF, Matroska, subtítulos, codec conduit, codec spire)el contenido del fichero, aunque sea localestructuras anidadas con longitudes declaradas
B5plugins (source/metadata/scanner) ↔ coresegún trust level r48llamadas al contrato del plugin
B6daemon ↔ libav (código C con historial de CVEs)la entrada que libav parsea; el propio libav se trata como código no memory-safebuffers, callbacks de IO
B7daemon ↔ sistema de ficherosel namespace de paths (symlinks, renames, montajes)aperturas y metadatos
B8contenedor ↔ hostel propio proceso si ya está comprometidosyscalls, capabilities, montajes
B9daemon ↔ NATS (eventos vía spire-zig, dec-0119)el broker y los demás clientes del bussobres evt.*

3. STRIDE por frontera

S = spoofing, T = tampering, R = repudio, I = divulgación, D = denegación, E = elevación. La columna «mitigación» cita el invariante de §4 que la cierra.

FronteraAmenazaMitigación
B1S: suplantar a otro usuario presentando su sessionIdI1 (capability firmada ligada a actor+sesión+recurso), I2
B1T: publicar/inyectar pistas en el relay sin permisoI3 (scope publish obligatorio, un namespace por token)
B1R: negar haber publicado o leídoI12 (audit con jti y actor de la capability)
B1I: leer rangos o pistas de otra sesión, enumerar sesionesI1 (range+track binding), sesiones no enumerables, errores genéricos
B1D: flood de conexiones pre-auth, streams, objetos o buffersI4 (presupuesto pre-auth mínimo y presupuestos por sesión/actor)
B1E: bug de parser en frames MoQT/H3/WT → ejecución de códigoI5 (ReleaseSafe + BoundedReader + fuzz), I9, I10
B2S: un proceso local se hace pasar por playback-svcI2 (directorio 0750 + socket 0660 + SO_PEERCRED + token de conexión)
B2T: CreateSession sobre un path arbitrarioI7 (sólo {rootId, relPath}, nunca paths absolutos)
B2D: frames gigantes o sin finlímite de frame (1 MiB hoy, control.zig), timeouts, cupo de peers
B3T/E: respuesta remota maliciosa (longitudes, redirecciones a file:// o a IPs internas)I5, allowlist de esquemas, bloqueo de IPs privadas y link-local salvo fuente declarada LAN, límites de tamaño
B3I: SSRF usando el daemon como proxyídem, más I8 (egress del contenedor limitado)
B4E: fichero MP4/MKV malicioso → overflow en el parserI5, I6 (libav fuera del proceso para contenido no confiable)
B4D: bomba de estructura (anidación, millones de samples)presupuesto de parse (§5.4 Limits), I4
B5E: plugin community ejecuta código en el daemonI11 (trust level ≥ trusted-native para código in-process; el resto fuera de proceso)
B6E: CVE de libav explotado con un ficheroI6 (worker sandbox: seccomp, sin red, sólo fds pasados, rlimits)
B7T/I: symlink swap, .., montajes, hardlinks hacia fuera de la raízI7 (openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS, verificación dev/ino)
B8E: escapar del contenedor tras un RCEI8 (non-root, rootfs readonly, cap_drop: ALL, no-new-privileges, seccomp, raíces ro)
B9S/T: evento falso en el bus en nombre del daemondec-0119 (identidad NKey, sobre firmado, ACL por subject)

4. Invariantes de diseño (lo que impide escalar un bug)

Cada invariante lleva su test de rotura: un DoD que falla si alguien lo rompe.

  • I1 — Ninguna operación del daemon sin capability verificada. Leer, suscribir, hacer fetch, publicar o ingestar exigen una Styx Capability Token (SCT, §4.1). El sessionId deja de ser secreto: es un identificador. Las únicas operaciones pre-capability son el handshake QUIC/TLS y /health. Test: CONNECT con sessionId válido y sin SCT (o con SCT de otra sesión, de otro asset, caducada, con otro aud o con firma alterada) → rechazo y 0 bytes de media enviados.
  • I2 — El daemon sólo acepta sesiones emitidas por playback-svc. Las sesiones sólo nacen por el IPC autenticado (§4.2). Una SCT es válida sólo si su sid existe en la tabla de sesiones y esa sesión se creó por IPC con el mismo actor y el mismo resource. Test: SCT firmada correctamente para un sid inexistente → rechazo.
  • I3 — Escrituras sólo con scope de escritura. PUBLISH, PUBLISH_NAMESPACE y la ingesta (conduit) exigen scope publish o ingest y un namespace concreto en la SCT. El auto-publisher de laboratorio queda detrás de un flag de build de dev, nunca en el binario de producción. Este invariante se implementa antes de abrir ingesta (paso 2 de esta lane). Test: PUBLISH sin SCT, con scope read o sobre otro namespace → la sesión MoQT se cierra con error y el relay no registra el alias.
  • I4 — Presupuestos jerárquicos. Hay presupuestos daemon → actor → sesión → conexión: memoria (vía BudgetAllocator, §5.2), streams, suscripciones, objetos pendientes, bytes en vuelo, ancho de banda y sesiones concurrentes por actor. El presupuesto pre-auth de una conexión es mínimo (timeout de handshake, un stream de control y ningún buffer de media). Si se agota, falla la sesión y no el daemon. Test: una sesión que supera su presupuesto recibe error.BudgetExceeded, se cierra, y las demás sesiones siguen sirviendo bytes.
  • I5 — Los parsers de entrada no confiable son memory-safe por construcción. Aplica a MoQT/H3/WT/QUIC varints, ISOBMFF, Matroska, subtítulos, codec conduit y codec spire:
    1. El binario de producción del daemon se compila ReleaseSafe (o Debug). ReleaseFast y ReleaseSmall quedan prohibidos para el ejecutable del daemon por el build (§6). Si un bucle caliente necesita desactivar safety, se hace con @setRuntimeSafety(false) en un scope con comentario // SAFETY: y nunca en un módulo parser.
    2. Toda lectura pasa por BoundedReader y toda aritmética de longitudes/offsets por los helpers checked de zkit (§5.4). Una longitud declarada por la entrada nunca se usa sin compararla con el resto disponible.
    3. En código alcanzable desde la entrada están prohibidos unreachable, catch unreachable y catch {}. Los errores son tipados y se propagan.
    4. Cada parser tiene fuzz target (zig build test --fuzz) con corpus versionado. Corre como mínimo 10 min por target en el gate de la lane y en el AppSec final.
  • I6 — libav es aislable. El backend libav de MediaEngine (dec-0110) se habla con un contrato de mensajes y fds, no de punteros compartidos, para que el mismo backend corra en dos modos:
    • libav_isolation = worker (por defecto): un proceso worker de Styx enlaza libav como librería. Arranca sin red, con seccomp-bpf en allowlist (read/write/mmap/munmap/lseek/fstat sobre los fds recibidos, futex, exit; sin open*, sin socket, sin execve), rlimits de memoria y CPU, y un uid distinto. Recibe fds ya abiertos por el daemon (§I7). Si el worker cae, falla esa operación y el daemon sigue vivo.
    • libav_isolation = inproc: sólo para ficheros de raíces locales marcadas trusted y para el harness A/B de dec-0110. Es opt-in explícito por config y queda en el audit. Test: un fichero que provoca abort() en libav (fault injection en el worker) → la operación responde error y el daemon sigue sirviendo otras sesiones.
  • I7 — Raíces de fichero fd-relativas. Al arrancar, el daemon abre cada raíz permitida como directorio (O_DIRECTORY|O_CLOEXEC) y guarda el fd y su dev. Desde ahí:
    • Por el IPC viaja {rootId, relPath}, nunca un path absoluto. rootId se resuelve contra la tabla de raíces del daemon.
    • Toda apertura es openat2(root_fd, relPath, RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS|RESOLVE_NO_MAGICLINKS|RESOLVE_NO_XDEV) en Linux ≥ 5.6. Fuera de Linux (o si openat2 no existe) se recorre componente a componente con openat(..., O_NOFOLLOW). No hay realpath ni comprobaciones previas al open.
    • Tras el open, fstat exige fichero regular y dev de la raíz. (dev, ino) queda en la sesión: una reapertura (reconexión, seek tras cerrar) que vea otro (dev, ino) falla.
    • La raíz sin configurar significa cero raíces (fail-closed, como hoy). Desaparece el atajo «raíz vacía = /» (local_file.zig:231). Los tests usan una raíz temporal real. Test: symlink dentro de la raíz hacia /etc/passwd, swap de symlink entre dos aperturas, .., path absoluto y hardlink desde otro dispositivo → los cinco rechazados.
  • I8 — Contenedores con el mínimo privilegio. Para el daemon, el worker libav y todos los servicios: usuario non-root con uid fijo; read_only: true con tmpfs para /tmp; cap_drop: [ALL] (el daemon escucha en puertos > 1024, no necesita ninguna); security_opt: [no-new-privileges:true, seccomp=<perfil styx>]; límites de pids, memoria y CPU; raíces de media montadas ro; cache y spool de ingesta como volúmenes propios rw; socket de control en un volumen dedicado (§4.2), nunca en /tmp del host. Egress del daemon limitado a las fuentes declaradas. El worker libav va sin red. Cómo se cumple (DS5-01, 2026-10-01): hoy el daemon no declara ninguna fuente remota, así que no tiene salida. En producción la salida es deny-by-default por servicio (check:deploy: EGRESS_ALLOWED/INGRESS_ALLOWED); las redes internal llevan el gateway isolated (sin IP en el bridge, el host tampoco es alcanzable); la QUIC publicada va por una red de sólo entrada (sin NAT ni vecinos) cuyo tráfico nuevo descarta deploy/host-firewall.sh en el host. Una fuente remota futura del daemon entra como red de salida declarada para él, con su destino en la tabla del firewall; nunca reabriendo styx-edge. Test: test:deploy-egress (rojo con el compose anterior: el daemon llegaba al host por el bridge de styx-internal y el gateway de styx-edge).
  • I9 — Referencias entre hilos/sesiones por handle generacional. Una referencia que sobrevive a un salto asíncrono (callback QUIC, cola, timer, worker) es un Handle(T) de zkit (§5.1), no un puntero. Un handle obsoleto resuelve a error.StaleHandle, no a un use-after-free.
  • I10 — Un fallo de memoria es un pánico y no una primitiva. Con I5 (safety checks activos), un overflow o un acceso fuera de rango aborta el proceso y no corrompe memoria. El daemon corre supervisado (restart con backoff). Las sesiones son recuperables por el cliente: la SCT sigue valiendo mientras no caduque y playback-svc recrea la sesión. Un pánico es un DoS acotado y queda en el audit.
  • I11 — Plugins según trust level. Sólo builtin, official y trusted-native pueden ejecutar código en el proceso del daemon. sandboxed, remote y community corren fuera de proceso detrás de spire (dec-0119) con los mismos presupuestos. Un plugin nunca recibe fds de raíces completas, sólo el fd del recurso concreto.
  • I12 — Observabilidad sin fugas y con audit. Las SCT nunca se loguean: el query param cap= se redacta en el logger del daemon. Los paths van como rootId + hash salvo en debug local. Cada rechazo de SCT, cierre por presupuesto, pánico supervisado y apertura rechazada por I7 emite un evento de audit (evt.security.* vía spire-zig, dec-0119) con jti, sid, actor (hash) y motivo.

4.1 Styx Capability Token (SCT)

  • Emisor: playback-svc, al crear la sesión y en cada renovación. Firma con Ed25519 y la clave privada vive sólo en playback-svc (A4). El daemon tiene las claves públicas, con kid, y acepta la actual más la siguiente para poder rotar.
  • Formato: binario versionado y de longitud fija por versión, codificado base64url. No es JWT: así se evitan el parsing JSON y la negociación de alg en el data plane. Campos: v, kid, aud (id de nodo daemon), sid, actor (hash del actorId), resource (assetId o namespace MoQT), scope (read/publish/ingest/control), range (restricción de bytes o de pistas, opcional), nbf, exp, jti.
  • Vida: read ≤ 10 min, renovable por el canal de control del cliente. publish e ingest ≤ 5 min por token y de un solo uso por jti al abrir el stream. Reloj con skew máximo de 30 s. Reversible: las duraciones concretas.
  • Transporte: en MoQT, el parámetro AUTHORIZATION TOKEN del setup/SUBSCRIBE/PUBLISH (draft-17). En WT y en HTTP/3 desde navegador, el query cap= del CONNECT o de la URL firmada (la API WebTransport del navegador no deja poner cabeceras). En clientes nativos, Authorization.
  • Verificación: la hace el verificador de spire-zig (dec-0119 §5), no código ad hoc en cada transporte.

4.2 IPC de control Bun ↔ daemon

  • Socket en un directorio propio (/run/styx/ en contenedor, volumen compartido sólo entre playback-svc y el daemon), directorio 0750 y socket 0660, grupo styx-media.
  • En cada accept, SO_PEERCRED (Linux) / getpeereid (Darwin): el uid/gid del peer debe estar en la allowlist configurada.
  • Después, handshake de conexión de spire (dec-0119 §3.2): el peer firma un reto con su clave de servicio. Sin handshake válido no se procesa ningún frame.
  • Ese IPC lo sustituye el transporte unix-socket de spire (notas de la tanda 3: "reemplazando el IPC de control Bun↔daemon"). Hasta entonces, el paso 2 de esta lane implementa PEERCRED + token sobre el protocolo actual.

5. Capa zkit.safety (capa 1 de la doble capa: la librería impone)

Vive en MKS2508/zkit, bajo src/safety/. Sigue dec-0103 §1: styx la consume y no la reimplementa. Las APIs son las mínimas que cubren los invariantes de §4. Los nombres son reversibles, la semántica no.

  1. safety.Handle(T) / HandleSlab(T): handle tipado (T forma parte del tipo, así que un handle de sesión no se confunde con uno de stream) con índice u32 y generación u32. La generación de 16 bits de hoy (zkit/src/handle.zig: 65 535 reusos antes de dar la vuelta) deja una ventana ABA explotable por un cliente que abra y cierre en bucle. Hay variante thread-safe (dec-0103 §2), y resolve devuelve error.StaleHandle.
  2. safety.BudgetAllocator: envuelve un allocator padre con límite duro de bytes, conteo de allocs y pico, y se anida (daemon → actor → sesión). Agotarlo da error.OutOfMemory a quien pide, se contabiliza como BudgetExceeded y no afecta a otros presupuestos. deinit devuelve un informe de fugas, que en builds con safety y en tests es error. TrackingAllocator (ya en zkit) pasa a ser su capa de medición.
  3. safety.fs: Root (fd de directorio + dev), Root.openFile(rel, opts) con la estrategia de I7, FileIdentity{dev, ino} y FileIdentity.verifySame(fd). Ninguna función pública acepta un path absoluto. El path guard de local_file.zig sube aquí y styx borra el suyo (regla de extracción de las notas de la tanda 3). La std de Zig 0.17-dev.1893 sólo trae el número de syscall (lib/std/os/linux/syscalls.zig:431, openat2 = 437 en x86_64), sin wrapper ni struct open_how, así que zkit escribe ese wrapper. Si una raíz necesita cruzar montajes (bind mounts dentro de la biblioteca), RESOLVE_NO_XDEV se relaja por raíz en la config, y eso queda en el audit.
  4. safety.bounded: BoundedReader (cursor sobre un slice: readInt, readVarint QUIC, readBytes(n), sub(n) que devuelve un lector acotado, errores Truncated/Overflow), checked.add/mul/cast sobre std.math y Limits{max_depth, max_elements, max_total_bytes} que los parsers reciben y consumen.
  5. safety.sync: Mutex con owner-assert en builds con safety (re-lock desde el mismo hilo y unlock desde otro hilo → pánico), rango de lock en comptime (tomar un rango menor mientras se sostiene uno mayor → pánico en safe, así se detecta la inversión antes que TSAN) y Guarded(T, rank), que sólo da acceso a T con el lock tomado. Los atomics son los de std: TSAN los entiende sin anotaciones.
  6. safety.testing: expectNoLeaks, un wrapper de std.testing.checkAllAllocationFailures para inyectar OOM en cada allocation, helpers de fuzz (carga de corpus y minimización) y macros de test de seguridad (symlink swap, raíz temporal, cliente que agota presupuesto).

Cada módulo trae tests propios con testing.allocator, fuzz y TSAN (regla de calidad de zkit en las notas de la tanda 3).

6. Guards de build de styx (capa 2: styx impide saltarse la capa 1)

Un paso zig build audit:safety, hermano del C15 (native/zig/tools/c15_import_audit.zig), recorre el AST de Zig (std.zig.Ast, no grep) de todo el código de producción de native/zig y falla si encuentra lo de la tabla. Alcance (enmienda de 2026-09-29, SEC-Z06 ronda 3): las raíces auditadas se derivan de los .root_source_file de native/zig/build.zig, menos una lista non_production con motivo por entrada (tools/, zig-pkg/, tests/, zig-out/); un directorio con .zig que no es ni raíz ni non_production pone el paso en rojo. Hoy son libav-bridge/, media-core/, media-daemon/ y transmux/. Un .root_source_file que es un fichero de primer nivel (b.path("x.zig")) es raíz auditada salvo non_production_files (build.zig, test_all.zig, test_all_canary_tsan.zig, con motivo), y un .zig de primer nivel que no es ni una cosa ni otra también es rojo (enmienda SEC-Z06 ronda 4). Resolución: cada expresión que nombra algo se resuelve por ámbito léxico hasta su declaración (alias con cualquier anotación de tipo, @"x" normalizado, contenedores, @This(), ramas de if/switch/orelse, destructuring, .{…}, @import relativo entre ficheros del árbol), así que un alias no esconde una primitiva:

ReglaProhibido fuera de la allowlist
S1 paths crudoslas funciones de std 0.17 que abren o crean un path (open*, opendir, creat/create, fopen*, dlopen, shm_open…) y las que lo crean, enlazan, borran, cambian o resuelven (mkdir*, rmdir, symlink*, link*, rename*, unlink*, mknod*, mkfifo*, chmod/fchmodat, chown/fchownat, truncate, access/faccessat, chdir, chroot, stat/fstatat/statx, readlink*, execve*, utimensat, mount/umount*, *xattr con path, bind), bajo std.c/std.posix/std.os.linux/libav_c; std.Io.Dir.cwd() y *Absolute*, .cwd(), AT.FDCWD y las *at/statx/bind de IoUring con cualquier receptor, y lo mismo con prefijo prep_ en io_uring_sqe (prep_openat, prep_mkdirat, prep_statx, prep_bind…); std.DynLib.open* (y un decl literal .open*(…), cuyo tipo no se resuelve); std.Io.net.UnixAddress.listen y .listen sobre un UnixAddress (llamada a UnixAddress.init, declaración o parámetro con ese tipo) y netListenUnix del vtable de std.Io; realpath, syscalls y asm a mano → usar zkit.safety.fs
S2 sync crudostd.Io.{Mutex,Condition,RwLock,Semaphore}, std.Io.futex*, std.atomic.Mutex, pthread_* → usar zkit.safety.Mutex / zkit.sync (excepción: glue con quic-zig/libxev listada con motivo)
S3 errores tragadosun manejador de catch/catch |e| o del else |e|/else |_| de un if/while sobre un error union que no hace nada con el error: cuerpo vacío, sólo descartes de la captura (_ = e;, _ = &e;) o descartes seguidos de un salto sin valor (continue/break, con o sin etiqueta) — catch {}, catch continue, catch break :l, catch |e| { _ = &e; continue; }, else |_| {}, else |_| break —, o un switch (e) cuya rama else es así; y catch unreachable. En módulos parser (//! styx:parser) cuentan siempre; fuera, todos menos catch unreachable sólo con // SAFETY: que diga por qué es inocuo. El diagnóstico cita la forma real
S4 abortar sobre entradaen módulos parser, salvo en comptime y tests: unreachable, .?, @panic (también orelse/catch @panic), @trap, @breakpoint y las llamadas (directas o por alias) a std.debug.assert/panic/panicExtra, std.process.fatal/exit/abort, std.c.abort/exit/_exit/_Exit y std.os.linux.exit/exit_group (std 0.17 no tiene std.posix.abort ni std.posix.exit)
S5 casts sin justificar@ptrCast, @alignCast, @intCast y @truncate sin // SAFETY: en la línea anterior
S6 optimize del daemonel ejecutable del daemon con ReleaseFast/ReleaseSmall → error de configuración del build
S7 allocator global en sesiónstd.heap.{page,c,smp,wasm,brk}_allocator en session/, moqt/, transport/
S8 safety apagada por bloque@setRuntimeSafety(x) con x distinto del literal true: siempre en módulos parser; fuera, sin // SAFETY: en la línea o el bloque de comentario encima. Impone I5.1 por bloque (S6 lo impone por binario) y es la precondición de la exclusión de S4 de los panics con safety check
  • Escape: un namespace crudo (std, std.c, std.posix, std.os.linux, std.heap, libav_c, zkit.fs…) fuera del inicializador de una declaración (argumento, return, asignación, @field con nombre calculado) cuenta, porque ahí la resolución ya no lo sigue. Un alias pub de uno cuenta donde se declara.
  • Cobertura contra std: tests que recorren las declaraciones de std.c, std.posix, std.os.linux, IoUring, io_uring_sqe, std.DynLib, std.Io.net (y UnixAddress/ IpAddress), std.Io.Dir/File, std.process, std.Io, std.atomic y std.heap y se ponen en rojo si un nombre que abre un path, sincroniza o es un allocator global no está ni cubierto ni en una lista de inocuos con motivo; otro test exige que cada cadena de las tablas exista en la std del repo.
  • Fuera de alcance, declarado (cabecera del tool, test que lo fija, follow-up SEC-Z06-F2): un contenedor del árbol que guarda un alias crudo y viaja como valor comptime a una función genérica del mismo fichero; la resolución a través de módulos con nombre (sus alias pub crudos ya cuentan donde se declaran); un Dir construido desde un fd numérico y los métodos relativos de Dir con path absoluto (los cierra la capa 1); std.process (lo cierra el perfil seccomp). Ronda 4: las primitivas sobre un fd ya abierto (fstat, fchdir, fchmod, ftruncate…); connect (no crea ni cambia un path, sólo alcanza un socket que ya existe; qué sockets AF_UNIX hay a mano lo acota el contenedor, I8, no este guard; el árbol sólo lo usa para TCP); en S3, catch return/catch <valor>/catch |e| <manejo que usa e> y una rama con nombre (error.X => {}); en S4, los panics con safety check (índice, overflow), que S6 mantiene activos y cubren los fuzzers. Las variantes *at cuentan siempre: el audit no sabe de dónde viene el fd. Ronda 5: un UnixAddress que llega como campo (self.addr.listen) o anytype, y std.DynLib alcanzado por un valor de tipo (@TypeOf(lib).open): el tipo de un campo o de un anytype no se sigue (el daemon no usa UnixAddress; su único AF_UNIX es el IPC, en la allowlist; seccomp y rootfs ro lo acotan, I8); UnixAddress.connect es un connect; en S3, else |_| return y else |e| <manejo que usa e>; en S4, lo que se evalúa en comptime, que no llega a ejecución. Ronda 6: en S3, un salto con valor (catch break :l v, continue :sw v: entrega un valor, como catch <valor>) y un manejo que usa e antes del salto; en S4, un exit/abort alcanzado como valor (argumento, campo), que la resolución no sigue, igual que en S1. La exclusión de S4 de los panics con safety check vale sólo porque S6 (binario) y S8 (bloque) impiden apagarlos.
  • Especificación cerrada (SEC-Z06 ronda 6): las reglas del guard son exactamente S1..S8 y Z1 (reimplementación de zkit, dec-0103), con el alcance de la tabla, la cabecera de native/zig/tools/safety_audit.zig y los fuera de alcance de arriba, fijados por el test "out of scope". Una regla nueva o un alcance mayor no entra por la lane del guard: va a un ticket follow-up (SEC-Z06-F*) con su test, su mutante y su enmienda de este §6.
  • Dependencias SDK (nota 2026-09-30, sin cambiar la especificación): zig-pkg/ es non_production, así que el código de un SDK que corre en el daemon (conduit, spire) no pasa por este guard aunque haga lo que S1..S8 prohíben. Mover código de styx a un SDK no puede ser una salida: el SDK usa la capa 1 (zkit.safety) igual que styx, y se comprueba aplicando este mismo tool a su árbol (dec-0121 §5). Hacerlo parte del paso es el follow-up SEC-Z06-F4.
  • Allowlist razonada (cada entrada cubre algo o el paso falla): el bind/unlink del socket de control del IPC en SOCKET_PATH constante, además de las de la ronda 3.
  • Trinquete: el audit guarda en native/zig/tools/safety_baseline.zon los contadores por regla y por fichero (punto de partida: §Contexto). Un contador puede bajar y nunca subir. En un fichero nuevo el límite es cero. Bajar el baseline es un commit explícito.
  • Rama roja demostrada: el ticket que crea el audit incluye mutantes (un realpath nuevo, un catch {} en un parser, un @ptrCast sin comentario, el daemon en ReleaseFast), y cada uno debe poner el paso en rojo. La evidencia cruda va al repo, igual que en C15.
  • El paso entra en zig build test y en guard.yml. Un guard que no corre en CI no cuenta.

Qué fija el lock y qué queda reversible

  • Fija el lock: las fronteras y los activos (§1–§2); los invariantes I1–I12; que las SCT las emite sólo playback-svc y el daemon sólo verifica; que el IPC no transporta paths absolutos; ReleaseSafe o Debug obligatorio para el daemon; libav aislable con worker por defecto; la existencia de zkit.safety con la semántica de §5 y el audit con trinquete de §6.
  • Reversible sin ADR: nombres de módulos y funciones, duraciones de las SCT, cifras de los presupuestos, la lista exacta de syscalls del perfil seccomp (se ajusta con evidencia), el formato del fichero de baseline.

Lo que este ADR NO decide

  • El SDK spire en sí (framing, API, codegen): lo decide su propio ADR de la tanda SDK. Este ADR y dec-0119 fijan los requisitos de seguridad que ese SDK debe cumplir.
  • Identidad de usuarios, sesiones web, CORS, CSP: dec-0118.
  • El diseño de conduit (ingesta). Sólo fija que la ingesta exige I3 e I7 y que su codec es un parser bajo I5.
  • Cifrado de la biblioteca en reposo: no hay amenaza concreta con consumidor hoy (r28).

Plan

Tickets por nodo en docs/track/byte-runtime/plans/security-part1-data-plane.plan.md. El paso 2 de esta misma lane (w3/security) implementa I3 + la verificación de SCT en el daemon y la emisión en playback-svc.