Vista generada de dec-0117: Seguridad por diseño, parte 1: threat model y arquitectura del data plane Zig
docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.mdVista generada desde
docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | LOCKED |
| Fecha | 2026-09-28 |
| Fichero | docs/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)
Leído de docs/decisions/dec-0117-seguridad-parte1-data-plane-zig.md, el fichero canónico.
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.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.dec-0126 y dec-0127 (LOCKED juntos, 2026-10-02):
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).ro salvo las que el operador marca rw-metadata
(dec-0126 §4.1). Las raíces que gestiona Styx nacen marcadas así.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.docs/design/ThreatModel-v0.md (baseline F0, loopback-only).
Ese documento queda como registro de F0. Cuando lo contradiga, gana este ADR.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):
| Hecho | Dónde | Consecuencia |
|---|---|---|
| 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 CONNECT | Un 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 :827 | Escritura 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:53 | Cualquier proceso local con acceso al path crea sesiones sobre cualquier fichero de la raíz |
CreateSession{assetPath} recibe un path absoluto desde Bun | ipc/control.zig:10,134 | La 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, :295 | Un 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-248 | Un 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:14 | Un 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 26 | comando en docs/track/byte-runtime/plans/security-part1-data-plane.plan.md §Baseline | Es 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*.yml | Un RCE en un servicio hereda las capabilities por defecto de Docker |
| Activo | Propiedad que se protege | Dónde vive |
|---|---|---|
| A1 Biblioteca del usuario (ficheros de media, subtítulos, metadatos locales) | confidencialidad (biblioteca privada), integridad | raíces montadas en el daemon |
A2 Ficheros fuera de las raíces (host, secretos, /proc) | confidencialidad e integridad absolutas | host |
| 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 capabilities | integridad: quien la tiene emite accesos | sólo playback-svc (el daemon guarda claves públicas) |
| A5 Cache L1/L2 y spool de ingesta | integridad (poisoning) y cuota de disco | volúmenes del daemon |
| A6 Disponibilidad (CPU, ancho de banda, memoria, fds) | disponibilidad por usuario y global | daemon |
| 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 claro | OTel |
| # | Frontera | Lado no confiable | Qué cruza |
|---|---|---|---|
| B1 | Cliente ↔ daemon (WebTransport, MoQT sobre WT, HTTP/3, QUIC) | todo el wire | handshakes, CONNECT paths, frames MoQT, rangos H3, datos publicados |
| B2 | playback-svc (Bun) ↔ daemon, socket unix de control | el peer local hasta autenticarse | CreateSession, Seek, Cancel, Close, señales |
| B3 | daemon ↔ fuentes remotas (HTTP/S3/WebDAV/Torrent/Jellyfin/Xtream) | respuestas y bytes remotos | cabeceras, longitudes, redirecciones, contenido |
| B4 | bytes de un fichero → parsers (MP4/ISOBMFF, Matroska, subtítulos, codec conduit, codec spire) | el contenido del fichero, aunque sea local | estructuras anidadas con longitudes declaradas |
| B5 | plugins (source/metadata/scanner) ↔ core | según trust level r48 | llamadas al contrato del plugin |
| B6 | daemon ↔ libav (código C con historial de CVEs) | la entrada que libav parsea; el propio libav se trata como código no memory-safe | buffers, callbacks de IO |
| B7 | daemon ↔ sistema de ficheros | el namespace de paths (symlinks, renames, montajes) | aperturas y metadatos |
| B8 | contenedor ↔ host | el propio proceso si ya está comprometido | syscalls, capabilities, montajes |
| B9 | daemon ↔ NATS (eventos vía spire-zig, dec-0119) | el broker y los demás clientes del bus | sobres evt.* |
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.
| Frontera | Amenaza | Mitigación |
|---|---|---|
| B1 | S: suplantar a otro usuario presentando su sessionId | I1 (capability firmada ligada a actor+sesión+recurso), I2 |
| B1 | T: publicar/inyectar pistas en el relay sin permiso | I3 (scope publish obligatorio, un namespace por token) |
| B1 | R: negar haber publicado o leído | I12 (audit con jti y actor de la capability) |
| B1 | I: leer rangos o pistas de otra sesión, enumerar sesiones | I1 (range+track binding), sesiones no enumerables, errores genéricos |
| B1 | D: flood de conexiones pre-auth, streams, objetos o buffers | I4 (presupuesto pre-auth mínimo y presupuestos por sesión/actor) |
| B1 | E: bug de parser en frames MoQT/H3/WT → ejecución de código | I5 (ReleaseSafe + BoundedReader + fuzz), I9, I10 |
| B2 | S: un proceso local se hace pasar por playback-svc | I2 (directorio 0750 + socket 0660 + SO_PEERCRED + token de conexión) |
| B2 | T: CreateSession sobre un path arbitrario | I7 (sólo {rootId, relPath}, nunca paths absolutos) |
| B2 | D: frames gigantes o sin fin | límite de frame (1 MiB hoy, control.zig), timeouts, cupo de peers |
| B3 | T/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 |
| B3 | I: SSRF usando el daemon como proxy | ídem, más I8 (egress del contenedor limitado) |
| B4 | E: fichero MP4/MKV malicioso → overflow en el parser | I5, I6 (libav fuera del proceso para contenido no confiable) |
| B4 | D: bomba de estructura (anidación, millones de samples) | presupuesto de parse (§5.4 Limits), I4 |
| B5 | E: plugin community ejecuta código en el daemon | I11 (trust level ≥ trusted-native para código in-process; el resto fuera de proceso) |
| B6 | E: CVE de libav explotado con un fichero | I6 (worker sandbox: seccomp, sin red, sólo fds pasados, rlimits) |
| B7 | T/I: symlink swap, .., montajes, hardlinks hacia fuera de la raíz | I7 (openat2 RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS, verificación dev/ino) |
| B8 | E: escapar del contenedor tras un RCE | I8 (non-root, rootfs readonly, cap_drop: ALL, no-new-privileges, seccomp, raíces ro) |
| B9 | S/T: evento falso en el bus en nombre del daemon | dec-0119 (identidad NKey, sobre firmado, ACL por subject) |
Cada invariante lleva su test de rotura: un DoD que falla si alguien lo rompe.
/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.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.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.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.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.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.unreachable, catch unreachable y
catch {}. Los errores son tipados y se propagan.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.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.O_DIRECTORY|O_CLOEXEC) y guarda el fd y su dev. Desde ahí:
{rootId, relPath}, nunca un path absoluto. rootId se resuelve contra la
tabla de raíces del daemon.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.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./» (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.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).Handle(T) de zkit
(§5.1), no un puntero. Un handle obsoleto resuelve a error.StaleHandle, no a un
use-after-free.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.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.kid, y
acepta la actual más la siguiente para poder rotar.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.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.cap= del CONNECT o de la URL firmada
(la API WebTransport del navegador no deja poner cabeceras). En clientes nativos,
Authorization./run/styx/ en contenedor, volumen compartido sólo entre
playback-svc y el daemon), directorio 0750 y socket 0660, grupo styx-media.accept, SO_PEERCRED (Linux) / getpeereid (Darwin): el uid/gid del peer debe
estar en la allowlist configurada.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.
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.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.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.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.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.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).
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:
| Regla | Prohibido fuera de la allowlist |
|---|---|
| S1 paths crudos | las 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 crudo | std.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 tragados | un 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 entrada | en 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 daemon | el ejecutable del daemon con ReleaseFast/ReleaseSmall → error de configuración del build |
| S7 allocator global en sesión | std.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 |
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.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.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.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.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.bind/unlink del socket
de control del IPC en SOCKET_PATH constante, además de las de la ronda 3.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.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.zig build test y en guard.yml. Un guard que no corre en CI no cuenta.worker por defecto; la
existencia de zkit.safety con la semántica de §5 y el audit con trinquete de §6.dec-0119 fijan los requisitos de seguridad que ese SDK debe cumplir.dec-0118.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.