gioser

sergio/takana

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

TOML 1389Shell 137Markdown 80Rust 802602 Definitions

qorpa D11: el proxy de Wayland — el socket deja de ser un cheque en blanco

Paso 8 del ADR 0015, el que el propio ADR llamaba el más valioso, y cierra su
§NO-resuelve 1.

Pasar el socket de Wayland crudo NO es abrir un caño: es un borde de privilegio.
El compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen
cosas que nadie concedió — screencopy captura la pantalla, virtual_keyboard
sintetiza teclas, data_control lee el portapapeles sin que nadie copie, y
layer_shell dibuja encima de todo. Una instancia ajena con el socket crudo podía
las cuatro, y lo único que teníamos era un aviso.

El proxy relaya todo salvo dos mensajes:
- `wl_registry.global` del compositor: si el interface no está en la lista, no se
  reenvía y el cliente nunca se entera de que existe.
- `wl_registry.bind` del cliente: si el `name` es uno de los ocultos, se manda un
  wl_display.error y se corta. Sin esto el filtro sería decorativo — el `name` es
  un número y se puede adivinar.

LISTA BLANCA, al revés que la denylist de syscalls de harkaq, y la asimetría
tiene razón: el conjunto PELIGROSO de Wayland crece —cada compositor inventa
protocolos privilegiados— mientras que el NECESARIO para dibujar una ventana es
corto y estable. Una denylist estaría desactualizada el día que alguien
actualice el compositor, y sin un solo error visible. La lista incluye a
propósito lo que los juegos piden y suele olvidarse: pointer_constraints,
relative_pointer, tearing_control, idle_inhibit.

Lo que hace esto viable en un fichero: ningún mensaje que se descarta lleva
descriptores, así que los fds —que Wayland pasa por SCM_RIGHTS para wl_shm y
dmabuf, y sin los cuales no se dibuja nada— se relayan en orden de llegada sin
tener que asociarlos a su mensaje.

PROBADO SIN COMPOSITOR, con uno falso que anuncia siete globals (tres permitidos
y cuatro peligrosos) y un cliente que cuenta lo que ve: pasan exactamente los
tres, y el intento de bindear por número el name que nunca se anunció corta la
conexión. Es la clase de prueba que no necesita GPU y responde la pregunta
entera. Un test más cubre el `size` menor que la cabecera, que haría avanzar 0
bytes y colgar el proxy en un bucle.

El socket crudo sigue disponible con `wayland_raw`, apagado por defecto y
avisando a gritos. Y si no hay compositor, `run` FALLA en vez de degradar.

Las features `socket`/`uio` de nix son aditivas: no cambian lo que compilan los
demás crates. 54/54.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U5cQtQrYNjWJVXVE6aEpQ2

ce76796b9b6d29be6e77f1949a439874e11b54ea · by Sergio · (1 month ago) · parent 1f415802f · browse the tree at this commit

Semantic delta · 37

crates/hammer-cli/src/main.rs

crates/hammer-cli/src/qorpa.rs

7 files · +553 −19

diff --git a/Cargo.lock b/Cargo.lock
index 200a35c0..8a36059c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -549,6 +549,7 @@ dependencies = [
  "hammer-overlay",
  "hammer-upgrade",
  "hex",
+ "nix",
  "serde",
  "serde_json",
  "sha2",
diff --git a/Cargo.toml b/Cargo.toml
index 5a041809..40205725 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,7 +49,10 @@ hex = "0.4"
 base64 = "0.22"
 ed25519-dalek = "2"
 getrandom = "0.2"
-nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] }
+# `socket`/`uio`: el proxy de Wayland de qorpa necesita sendmsg/recvmsg con SCM_RIGHTS —
+# Wayland pasa descriptores (wl_shm, dmabuf) por el mismo socket, y sin relayarlos no dibuja
+# nada. Las features de cargo son ADITIVAS: activarlas no cambia lo que compilan los demás.
+nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user", "socket", "uio"] }
 tempfile = "3"
 clap = { version = "4", features = ["derive"] }
 tracing = "0.1"
diff --git a/crates/hammer-cli/Cargo.toml b/crates/hammer-cli/Cargo.toml
index 4616266d..509300fa 100644
--- a/crates/hammer-cli/Cargo.toml
+++ b/crates/hammer-cli/Cargo.toml
@@ -29,6 +29,7 @@ hammer-agent.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 toml.workspace = true
+nix.workspace = true
 base64.workspace = true
 anyhow.workspace = true
 clap.workspace = true
diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 83053b99..e63c427f 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -11,6 +11,7 @@ use clap::{Parser, Subcommand};
 mod alpine_import;
 mod kernel_cmd;
 mod qorpa;
+mod qorpa_wl;
 mod nix_import;
 
 const DEFAULT_STORE: &str = "/store";
diff --git a/crates/hammer-cli/src/qorpa.rs b/crates/hammer-cli/src/qorpa.rs
index 740c8747..f044657e 100644
--- a/crates/hammer-cli/src/qorpa.rs
+++ b/crates/hammer-cli/src/qorpa.rs
@@ -608,6 +608,15 @@ struct Grants {
     /// La concesión más peligrosa, y por eso va sola en su línea. `dnf`/`pacman` la necesitan.
     #[serde(default)]
     network: bool,
+    /// Pasar el socket de Wayland **crudo**, sin el proxy filtrante. Apagado por defecto.
+    ///
+    /// Con esto la instancia recupera TODOS los globals del compositor, incluidos los que capturan
+    /// pantalla, sintetizan teclas y leen el portapapeles. Existe para diagnosticar, no para usar.
+    #[serde(default)]
+    wayland_raw: bool,
+    /// Globals de Wayland que el proxy deja ver. Vacío = la lista curada de `qorpa_wl`.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    wayland_globals: Vec<String>,
     /// Deja que la instancia monte y cree namespaces adentro. **Steam ya es un contenedor**: su
     /// pressure-vessel necesita esto para arrancar (D6). Es una concesión real — con ella la
     /// instancia recupera la familia `mount`/`setns` que la jaula deniega por defecto.
@@ -733,6 +742,7 @@ impl Default for Grants {
         Grants {
             sockets: Vec::new(), devices: Vec::new(), dirs: Vec::new(),
             network: false, nesting: false, root: true, seal_image: false,
+            wayland_raw: false, wayland_globals: Vec::new(),
         }
     }
 }
@@ -843,7 +853,7 @@ fn recreate(root: &Path, id: &str) -> Result<()> {
 
 /// Traduce las concesiones a argumentos de bwrap. Devuelve también las advertencias que el usuario
 /// tiene que LEER, no descubrir: el ADR pide que el socket de Wayland se diga en la cara.
-fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec<String>, Vec<String>) {
+fn grants_to_args(g: &Grants, destino_xdg: &str, wl: Option<&Path>) -> (Vec<String>, Vec<String>) {
     let mut a: Vec<String> = Vec::new();
     let mut warn: Vec<String> = Vec::new();
     // Sin dep de libc: el dueño de /proc/self ES el uid efectivo.
@@ -855,13 +865,19 @@ fn grants_to_args(g: &Grants, destino_xdg: &str) -> (Vec<String>, Vec<String>) {
     for s in &g.sockets {
         let (host, dentro) = match s.as_str() {
             "wayland" => {
-                warn.push(
-                    "wayland: la instancia podrá CAPTURAR LA PANTALLA y SINTETIZAR TECLAS — el \
-                     socket es un borde de privilegio y todavía no hay proxy filtrante \
-                     (ADR §NO-resuelve 1)".into(),
-                );
                 let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
-                (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
+                match wl {
+                    Some(proxy) => (proxy.display().to_string(), format!("{destino_xdg}/wayland-0")),
+                    None => {
+                        warn.push(
+                            "wayland CRUDO (`wayland_raw`): la instancia ve TODOS los globals del \
+                             compositor y podrá CAPTURAR LA PANTALLA, SINTETIZAR TECLAS y LEER EL \
+                             PORTAPAPELES. El proxy filtrante existe; esto lo saltea a propósito."
+                                .into(),
+                        );
+                        (format!("{xdg}/{d}"), format!("{destino_xdg}/wayland-0"))
+                    }
+                }
             }
             "pipewire" => (format!("{xdg}/pipewire-0"), format!("{destino_xdg}/pipewire-0")),
             "dbus" => (format!("{xdg}/bus"), format!("{destino_xdg}/bus")),
@@ -1058,7 +1074,34 @@ fn run_instance(
         None => uid_dentro,
     };
     let runtime_dir = format!("/run/user/{uid_corre}");
-    let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir);
+
+    // El proxy vive mientras viva este proceso, que es exactamente lo que dura la instancia.
+    // `_proxy` se mantiene en scope a propósito.
+    let mut proxy_sock: Option<PathBuf> = None;
+    let _proxy = if inst.grants.sockets.iter().any(|s| s == "wayland") && !inst.grants.wayland_raw {
+        let xdg = std::env::var("XDG_RUNTIME_DIR")
+            .unwrap_or_else(|_| format!("/run/user/{}", uid_corre));
+        let d = std::env::var("WAYLAND_DISPLAY").unwrap_or_else(|_| "wayland-0".into());
+        let real = PathBuf::from(format!("{xdg}/{d}"));
+        if !real.exists() {
+            bail!("la instancia pide `wayland` pero no hay compositor en {}", real.display());
+        }
+        let permitidos: std::collections::HashSet<String> = if inst.grants.wayland_globals.is_empty() {
+            crate::qorpa_wl::GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect()
+        } else {
+            inst.grants.wayland_globals.iter().cloned().collect()
+        };
+        let n = permitidos.len();
+        let p = crate::qorpa_wl::arrancar(&dir.join("wl"), &real, permitidos)
+            .context("no pude arrancar el proxy de Wayland")?;
+        eprintln!("  wayland: proxy filtrante puesto — {n} global(s) visibles, el resto no existe \
+                   para la instancia");
+        proxy_sock = Some(p.socket.clone());
+        Some(p)
+    } else {
+        None
+    };
+    let (grant_args, warns) = grants_to_args(&inst.grants, &runtime_dir, proxy_sock.as_deref());
 
     // El orden importa: `--overlay-src` describe la capa BAJA del `--overlay` que viene justo
     // después. Y `--clearenv` va primero porque el entorno del host también es una concesión: lo
@@ -1934,12 +1977,12 @@ mod tests {
 
     #[test]
     fn sin_concesiones_la_red_queda_fuera_y_con_red_entra_el_resolv_conf() {
-        let (a, w) = grants_to_args(&Grants::default(), "/run/user/0");
+        let (a, w) = grants_to_args(&Grants::default(), "/run/user/0", None);
         assert!(a.contains(&"--unshare-net".to_string()));
         assert!(w.is_empty());
 
         let g = Grants { network: true, ..Default::default() };
-        let (a, _) = grants_to_args(&g, "/run/user/0");
+        let (a, _) = grants_to_args(&g, "/run/user/0", None);
         assert!(!a.contains(&"--unshare-net".to_string()));
         // El segundo fallo clásico de la primera corrida, según el ADR.
         assert!(a.iter().any(|x| x == "/etc/resolv.conf"));
@@ -1948,7 +1991,7 @@ mod tests {
     #[test]
     fn el_socket_de_wayland_avisa_que_es_un_borde_de_privilegio() {
         let g = Grants { sockets: vec!["wayland".into()], ..Default::default() };
-        let (_, w) = grants_to_args(&g, "/run/user/0");
+        let (_, w) = grants_to_args(&g, "/run/user/0", None);
         assert_eq!(w.len(), 1);
         assert!(w[0].contains("CAPTURAR LA PANTALLA"), "el aviso no puede ser tibio: {}", w[0]);
     }
diff --git a/crates/hammer-cli/src/qorpa_wl.rs b/crates/hammer-cli/src/qorpa_wl.rs
new file mode 100644
index 00000000..0f012fca
--- /dev/null
+++ b/crates/hammer-cli/src/qorpa_wl.rs
@@ -0,0 +1,458 @@
+//! Proxy filtrante de Wayland para qorpa — [ADR 0015](../../../docs/adr/0015-imagenes-ajenas.md)
+//! §NO-resuelve 1 y §Orden de trabajo 8.
+//!
+//! ── EL AGUJERO QUE CIERRA ───────────────────────────────────────────────────────────────────────
+//! Pasar el socket de Wayland crudo **no es abrir un caño: es un borde de privilegio**. El
+//! compositor anuncia TODOS sus globals, y entre ellos hay protocolos que hacen cosas que nadie
+//! concedió: `zwlr_screencopy_manager_v1` captura la pantalla, `zwp_virtual_keyboard_manager_v1`
+//! sintetiza teclas, `zwlr_data_control_manager_v1` lee el portapapeles sin que nadie copie,
+//! `zwlr_layer_shell_v1` dibuja encima de todo. Una instancia ajena con el socket crudo puede las
+//! cuatro. Flatpak no pasa el socket: pasa un proxy que filtra. Esto es ese proxy.
+//!
+//! ── POR QUÉ LISTA BLANCA Y NO NEGRA ─────────────────────────────────────────────────────────────
+//! Al revés que la denylist de syscalls de harkaq, acá manda la lista blanca, y la asimetría tiene
+//! razón: el conjunto PELIGROSO de Wayland **crece** —cada compositor inventa protocolos
+//! privilegiados y aparecen versiones nuevas—, mientras que el conjunto NECESARIO para dibujar una
+//! ventana es corto y estable. Una denylist estaría desactualizada el día que alguien actualice el
+//! compositor, y sin un solo error visible.
+//!
+//! ── CÓMO FILTRA, Y POR QUÉ ALCANZA CON MIRAR TAN POCO ───────────────────────────────────────────
+//! El proxy relaya bytes salvo dos mensajes:
+//!   · `wl_registry.global` (evento del compositor): si el interface no está en la lista, **no se
+//!     reenvía**. El cliente nunca se entera de que existe.
+//!   · `wl_registry.bind` (petición del cliente): un cliente malicioso puede intentar bindear un
+//!     `name` numérico que nunca se le anunció. Se corta con un `wl_display.error` y se cierra.
+//! Nada más hace falta inspeccionar, y eso es lo que hace esto viable en un fichero: **los mensajes
+//! que se descartan no llevan descriptores**, así que los fds se pueden relayar en orden de llegada
+//! sin asociarlos a su mensaje.
+
+use std::collections::HashSet;
+use std::io;
+use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex};
+
+use nix::sys::socket::{recvmsg, sendmsg, ControlMessage, ControlMessageOwned, MsgFlags};
+
+/// Lo que una app necesita para dibujar, decorarse, recibir entrada y presentar frames.
+///
+/// Incluye a propósito lo que los JUEGOS piden y suele olvidarse: `pointer_constraints` y
+/// `relative_pointer` (mirar con el mouse sin que el cursor se escape), `tearing_control`,
+/// `idle_inhibit` (que no se apague la pantalla a media partida).
+pub const GLOBALS_PERMITIDOS: &[&str] = &[
+    "wl_compositor", "wl_subcompositor", "wl_shm", "wl_seat", "wl_output",
+    "wl_data_device_manager", "wl_drm",
+    "xdg_wm_base", "zxdg_decoration_manager_v1", "xdg_activation_v1",
+    "wp_viewporter", "wp_presentation", "wp_single_pixel_buffer_manager_v1",
+    "wp_fractional_scale_manager_v1", "wp_cursor_shape_manager_v1",
+    "wp_tearing_control_manager_v1", "wp_content_type_manager_v1",
+    "zwp_linux_dmabuf_v1", "zwp_relative_pointer_manager_v1",
+    "zwp_pointer_constraints_v1", "zwp_pointer_gestures_v1",
+    "zwp_idle_inhibit_manager_v1", "zwp_text_input_manager_v3",
+    "zwp_primary_selection_device_manager_v1", "zwp_tablet_manager_v2",
+    "xdg_toplevel_icon_manager_v1",
+];
+
+const OBJ_DISPLAY: u32 = 1;
+const OP_DISPLAY_GET_REGISTRY: u16 = 1;
+const OP_DISPLAY_ERROR: u16 = 0;
+const OP_REGISTRY_GLOBAL: u16 = 0;
+const OP_REGISTRY_BIND: u16 = 0;
+/// `wl_display.error` con `code = 1` es «objeto/global inválido» — el más cercano a la verdad:
+/// para este cliente ese global no existe.
+const ERR_INVALID_OBJECT: u32 = 1;
+
+/// Estado compartido por las dos direcciones de una conexión.
+#[derive(Default)]
+struct Estado {
+    /// Los `wl_registry` que este cliente creó. Puede haber más de uno.
+    registries: HashSet<u32>,
+    /// Los `name` numéricos que le ocultamos. Bindearlos es un intento de saltarse el filtro.
+    ocultos: HashSet<u32>,
+}
+
+pub struct Proxy {
+    /// El socket que se bindea DENTRO de la instancia, en vez del real.
+    pub socket: PathBuf,
+    _dir: PathBuf,
+}
+
+/// Arranca el proxy en segundo plano. Devuelve el socket a bindear en la jaula.
+///
+/// Los hilos son detached a propósito: viven lo que viva el proceso, que es exactamente lo que dura
+/// la instancia (`hammer qorpa run` bloquea en bwrap).
+pub fn arrancar(dir: &Path, socket_real: &Path, permitidos: HashSet<String>) -> io::Result<Proxy> {
+    std::fs::create_dir_all(dir)?;
+    let socket = dir.join("wayland-0");
+    let _ = std::fs::remove_file(&socket);
+    let escucha = UnixListener::bind(&socket)?;
+    let real = socket_real.to_path_buf();
+    let permitidos = Arc::new(permitidos);
+
+    std::thread::spawn(move || {
+        for c in escucha.incoming() {
+            let Ok(cliente) = c else { continue };
+            let real = real.clone();
+            let permitidos = permitidos.clone();
+            std::thread::spawn(move || {
+                if let Err(e) = atender(cliente, &real, permitidos) {
+                    eprintln!("[qorpa-wl] conexión terminada: {e}");
+                }
+            });
+        }
+    });
+    Ok(Proxy { socket, _dir: dir.to_path_buf() })
+}
+
+fn atender(cliente: UnixStream, real: &Path, permitidos: Arc<HashSet<String>>) -> io::Result<()> {
+    let servidor = UnixStream::connect(real)?;
+    let estado = Arc::new(Mutex::new(Estado::default()));
+
+    let (c2, s2, e2, p2) = (cliente.try_clone()?, servidor.try_clone()?, estado.clone(), permitidos.clone());
+    let subida = std::thread::spawn(move || bombear(&c2, &s2, Sentido::HaciaServidor, e2, p2));
+    let r = bombear(&servidor, &cliente, Sentido::HaciaCliente, estado, permitidos);
+    let _ = cliente.shutdown(std::net::Shutdown::Both);
+    let _ = servidor.shutdown(std::net::Shutdown::Both);
+    let _ = subida.join();
+    r
+}
+
+#[derive(PartialEq, Clone, Copy)]
+enum Sentido { HaciaServidor, HaciaCliente }
+
+fn bombear(
+    de: &UnixStream, a: &UnixStream, sentido: Sentido,
+    estado: Arc<Mutex<Estado>>, permitidos: Arc<HashSet<String>>,
+) -> io::Result<()> {
+    let mut pendiente: Vec<u8> = Vec::new();
+    loop {
+        let (datos, fds) = leer(de)?;
+        if datos.is_empty() && fds.is_empty() {
+            return Ok(()); // el otro extremo cerró
+        }
+        pendiente.extend_from_slice(&datos);
+
+        let mut salida: Vec<u8> = Vec::new();
+        let mut consumido = 0usize;
+        while let Some((obj, op, tam)) = cabecera(&pendiente[consumido..]) {
+            let msg = &pendiente[consumido..consumido + tam];
+            if filtrar(msg, obj, op, sentido, &estado, &permitidos, a)? {
+                salida.extend_from_slice(msg);
+            }
+            consumido += tam;
+        }
+        pendiente.drain(..consumido);
+
+        // Los fds van con lo que haya: ningún mensaje que descartamos lleva descriptores, así que
+        // el orden se conserva sin necesidad de saber a qué mensaje pertenece cada uno.
+        if !salida.is_empty() || !fds.is_empty() {
+            escribir(a, &salida, &fds)?;
+        }
+    }
+}
+
+/// Devuelve `true` si el mensaje se reenvía.
+fn filtrar(
+    msg: &[u8], obj: u32, op: u16, sentido: Sentido,
+    estado: &Arc<Mutex<Estado>>, permitidos: &HashSet<String>, otro: &UnixStream,
+) -> io::Result<bool> {
+    let cuerpo = &msg[8..];
+    match sentido {
+        Sentido::HaciaServidor => {
+            if obj == OBJ_DISPLAY && op == OP_DISPLAY_GET_REGISTRY {
+                if let Some(id) = leer_u32(cuerpo, 0) {
+                    estado.lock().unwrap().registries.insert(id);
+                }
+                return Ok(true);
+            }
+            let es_registry = estado.lock().unwrap().registries.contains(&obj);
+            if es_registry && op == OP_REGISTRY_BIND {
+                if let Some(name) = leer_u32(cuerpo, 0) {
+                    if estado.lock().unwrap().ocultos.contains(&name) {
+                        // Bindear un global que nunca se anunció es un intento de saltarse el
+                        // filtro, no un accidente: se corta la conexión diciendo por qué.
+                        let _ = escribir(otro, &error_display(obj, ERR_INVALID_OBJECT,
+                            "qorpa: ese global no existe para esta instancia"), &[]);
+                        return Err(io::Error::other("bind a un global filtrado"));
+                    }
+                }
+            }
+            Ok(true)
+        }
+        Sentido::HaciaCliente => {
+            let es_registry = estado.lock().unwrap().registries.contains(&obj);
+            if es_registry && op == OP_REGISTRY_GLOBAL {
+                let name = leer_u32(cuerpo, 0);
+                let interface = leer_str(cuerpo, 4);
+                if let (Some(name), Some(iface)) = (name, interface) {
+                    if !permitidos.contains(&iface) {
+                        estado.lock().unwrap().ocultos.insert(name);
+                        return Ok(false); // el cliente nunca se entera
+                    }
+                }
+            }
+            Ok(true)
+        }
+    }
+}
+
+// ── el cable ────────────────────────────────────────────────────────────────────────────────────
+
+/// Cabecera de un mensaje de Wayland: `object_id:u32`, luego `u32` con el tamaño en los 16 bits
+/// altos y el opcode en los bajos. Devuelve `None` si todavía no llegó el mensaje entero.
+fn cabecera(b: &[u8]) -> Option<(u32, u16, usize)> {
+    if b.len() < 8 {
+        return None;
+    }
+    let obj = u32::from_ne_bytes(b[0..4].try_into().ok()?);
+    let w = u32::from_ne_bytes(b[4..8].try_into().ok()?);
+    let tam = (w >> 16) as usize;
+    let op = (w & 0xffff) as u16;
+    // Un tamaño menor que la cabecera es un stream corrupto; tratarlo como "falta" colgaría el
+    // proxy en un bucle, así que se corta.
+    if tam < 8 || b.len() < tam {
+        return None;
+    }
+    Some((obj, op, tam))
+}
+
+fn leer_u32(cuerpo: &[u8], off: usize) -> Option<u32> {
+    cuerpo.get(off..off + 4).map(|s| u32::from_ne_bytes(s.try_into().unwrap()))
+}
+
+/// Un string de Wayland: `len:u32` INCLUYENDO el NUL, y luego el texto alineado a 4 bytes.
+fn leer_str(cuerpo: &[u8], off: usize) -> Option<String> {
+    let len = leer_u32(cuerpo, off)? as usize;
+    if len == 0 {
+        return Some(String::new());
+    }
+    let ini = off + 4;
+    let txt = cuerpo.get(ini..ini + len - 1)?;
+    Some(String::from_utf8_lossy(txt).into_owned())
+}
+
+fn escribir_str(out: &mut Vec<u8>, s: &str) {
+    let bytes = s.as_bytes();
+    let len = bytes.len() + 1;
+    out.extend_from_slice(&(len as u32).to_ne_bytes());
+    out.extend_from_slice(bytes);
+    out.push(0);
+    while out.len() % 4 != 0 {
+        out.push(0);
+    }
+}
+
+fn error_display(obj: u32, code: u32, msg: &str) -> Vec<u8> {
+    let mut cuerpo = Vec::new();
+    cuerpo.extend_from_slice(&obj.to_ne_bytes());
+    cuerpo.extend_from_slice(&code.to_ne_bytes());
+    escribir_str(&mut cuerpo, msg);
+    let tam = 8 + cuerpo.len();
+    let mut out = Vec::with_capacity(tam);
+    out.extend_from_slice(&OBJ_DISPLAY.to_ne_bytes());
+    out.extend_from_slice(&(((tam as u32) << 16) | OP_DISPLAY_ERROR as u32).to_ne_bytes());
+    out.extend_from_slice(&cuerpo);
+    out
+}
+
+fn leer(s: &UnixStream) -> io::Result<(Vec<u8>, Vec<OwnedFd>)> {
+    let mut buf = [0u8; 8192];
+    let mut espacio = nix::cmsg_space!([RawFd; 32]);
+    let mut iov = [io::IoSliceMut::new(&mut buf)];
+    let r = recvmsg::<()>(s.as_raw_fd(), &mut iov, Some(&mut espacio), MsgFlags::empty())
+        .map_err(io::Error::from)?;
+    let mut fds = Vec::new();
+    for c in r.cmsgs().map_err(io::Error::from)? {
+        if let ControlMessageOwned::ScmRights(rs) = c {
+            for fd in rs {
+                fds.push(unsafe { OwnedFd::from_raw_fd(fd) });
+            }
+        }
+    }
+    let n = r.bytes;
+    Ok((buf[..n].to_vec(), fds))
+}
+
+fn escribir(s: &UnixStream, datos: &[u8], fds: &[OwnedFd]) -> io::Result<()> {
+    let crudos: Vec<RawFd> = fds.iter().map(|f| f.as_raw_fd()).collect();
+    let cmsgs: Vec<ControlMessage> = if crudos.is_empty() {
+        vec![]
+    } else {
+        vec![ControlMessage::ScmRights(&crudos)]
+    };
+    // Un socket de dominio unix puede aceptar menos bytes de los que se le dan: hay que insistir,
+    // y los fds viajan sólo con el primer envío para no duplicarlos.
+    let mut off = 0;
+    loop {
+        let resto = &datos[off..];
+        let iov = [io::IoSlice::new(if resto.is_empty() { b"" } else { resto })];
+        let usar = if off == 0 { &cmsgs[..] } else { &[][..] };
+        let n = sendmsg::<()>(s.as_raw_fd(), &iov, usar, MsgFlags::empty(), None)
+            .map_err(io::Error::from)?;
+        off += n;
+        if off >= datos.len() {
+            return Ok(());
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::io::{Read, Write};
+
+    fn msg(obj: u32, op: u16, cuerpo: &[u8]) -> Vec<u8> {
+        let tam = 8 + cuerpo.len();
+        let mut v = Vec::with_capacity(tam);
+        v.extend_from_slice(&obj.to_ne_bytes());
+        v.extend_from_slice(&(((tam as u32) << 16) | op as u32).to_ne_bytes());
+        v.extend_from_slice(cuerpo);
+        v
+    }
+
+    fn global(registry: u32, name: u32, iface: &str, version: u32) -> Vec<u8> {
+        let mut c = Vec::new();
+        c.extend_from_slice(&name.to_ne_bytes());
+        escribir_str(&mut c, iface);
+        c.extend_from_slice(&version.to_ne_bytes());
+        msg(registry, OP_REGISTRY_GLOBAL, &c)
+    }
+
+    /// Un compositor de mentira: espera el `get_registry`, y anuncia lo que se le diga.
+    fn compositor_falso(sock: &Path, anuncios: Vec<(u32, String)>) -> std::thread::JoinHandle<()> {
+        let l = UnixListener::bind(sock).unwrap();
+        std::thread::spawn(move || {
+            let (mut c, _) = l.accept().unwrap();
+            let mut buf = [0u8; 4096];
+            let n = c.read(&mut buf).unwrap();
+            // Lo único que se parsea: el id de registry que pidió el cliente.
+            let (obj, op, _) = cabecera(&buf[..n]).unwrap();
+            assert_eq!((obj, op), (OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY));
+            let reg = leer_u32(&buf[8..n], 0).unwrap();
+            let mut out = Vec::new();
+            for (name, iface) in &anuncios {
+                out.extend_from_slice(&global(reg, *name, iface, 1));
+            }
+            c.write_all(&out).unwrap();
+            // Se queda viva para que el proxy no vea EOF antes de tiempo.
+            let _ = c.read(&mut buf);
+        })
+    }
+
+    fn interfaces_vistas(datos: &[u8], reg: u32) -> Vec<String> {
+        let mut v = Vec::new();
+        let mut off = 0;
+        while let Some((obj, op, tam)) = cabecera(&datos[off..]) {
+            if obj == reg && op == OP_REGISTRY_GLOBAL {
+                if let Some(i) = leer_str(&datos[off + 8..off + tam], 4) {
+                    v.push(i);
+                }
+            }
+            off += tam;
+        }
+        v
+    }
+
+    #[test]
+    fn el_cliente_no_se_entera_de_los_globals_peligrosos() {
+        let d = tempfile::tempdir().unwrap();
+        let real = d.path().join("compositor.sock");
+        let h = compositor_falso(&real, vec![
+            (1, "wl_compositor".into()),
+            (2, "zwlr_screencopy_manager_v1".into()),      // captura de pantalla
+            (3, "wl_seat".into()),
+            (4, "zwp_virtual_keyboard_manager_v1".into()), // sintetiza teclas
+            (5, "zwlr_data_control_manager_v1".into()),    // lee el portapapeles
+            (6, "xdg_wm_base".into()),
+            (7, "zwlr_layer_shell_v1".into()),             // dibuja encima de todo
+        ]);
+
+        let permitidos: HashSet<String> =
+            GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
+        let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
+
+        let mut c = UnixStream::connect(&p.socket).unwrap();
+        let mut cuerpo = Vec::new();
+        cuerpo.extend_from_slice(&2u32.to_ne_bytes()); // new_id del registry
+        c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
+
+        c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
+        let mut buf = [0u8; 8192];
+        let n = c.read(&mut buf).unwrap();
+        let vistas = interfaces_vistas(&buf[..n], 2);
+
+        assert!(vistas.contains(&"wl_compositor".to_string()), "{vistas:?}");
+        assert!(vistas.contains(&"wl_seat".to_string()), "{vistas:?}");
+        assert!(vistas.contains(&"xdg_wm_base".to_string()), "{vistas:?}");
+        for peligroso in ["zwlr_screencopy_manager_v1", "zwp_virtual_keyboard_manager_v1",
+                          "zwlr_data_control_manager_v1", "zwlr_layer_shell_v1"] {
+            assert!(!vistas.iter().any(|v| v == peligroso), "se coló {peligroso}: {vistas:?}");
+        }
+        assert_eq!(vistas.len(), 3, "ni más ni menos que lo permitido: {vistas:?}");
+        // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
+        // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
+        drop(c);
+        drop(h);
+    }
+
+    #[test]
+    fn bindear_un_global_oculto_corta_la_conexion() {
+        let d = tempfile::tempdir().unwrap();
+        let real = d.path().join("c.sock");
+        let h = compositor_falso(&real, vec![
+            (1, "wl_compositor".into()),
+            (99, "zwlr_screencopy_manager_v1".into()),
+        ]);
+        let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
+        let p = arrancar(&d.path().join("wl"), &real, permitidos).unwrap();
+
+        let mut c = UnixStream::connect(&p.socket).unwrap();
+        let mut cuerpo = Vec::new();
+        cuerpo.extend_from_slice(&2u32.to_ne_bytes());
+        c.write_all(&msg(OBJ_DISPLAY, OP_DISPLAY_GET_REGISTRY, &cuerpo)).unwrap();
+        c.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap();
+        let mut buf = [0u8; 8192];
+        let _ = c.read(&mut buf).unwrap();
+
+        // Un cliente malicioso adivina el `name` que nunca se le anunció.
+        let mut b = Vec::new();
+        b.extend_from_slice(&99u32.to_ne_bytes());
+        escribir_str(&mut b, "zwlr_screencopy_manager_v1");
+        b.extend_from_slice(&1u32.to_ne_bytes());
+        b.extend_from_slice(&3u32.to_ne_bytes());
+        c.write_all(&msg(2, OP_REGISTRY_BIND, &b)).unwrap();
+
+        // Tiene que llegar un wl_display.error y cerrarse. Ocultar el global sin cortar el bind
+        // dejaría el filtro en decorativo: el `name` es sólo un número que se puede adivinar.
+        let n = c.read(&mut buf).unwrap_or(0);
+        let hubo_error = n > 0 && cabecera(&buf[..n]).map(|(o, op, _)| (o, op))
+            == Some((OBJ_DISPLAY, OP_DISPLAY_ERROR));
+        let cerro = { let n2 = c.read(&mut buf).unwrap_or(0); n2 == 0 };
+        assert!(hubo_error || cerro, "el bind al global oculto no fue cortado");
+        // NO se hace `join` del compositor falso: sigue bloqueado leyendo, y la otra dirección del
+        // proxy tampoco se entera hasta que intente escribir. Es un hilo de test, muere con él.
+        drop(c);
+        drop(h);
+    }
+
+    #[test]
+    fn la_lista_es_BLANCA_no_negra() {
+        // Un protocolo privilegiado que todavía no existe tampoco pasa: es la propiedad que una
+        // denylist no puede dar, y la razón de elegir lista blanca acá y denylist en las syscalls.
+        let permitidos: HashSet<String> = GLOBALS_PERMITIDOS.iter().map(|s| s.to_string()).collect();
+        assert!(!permitidos.contains("zwlr_screencopy_manager_v1"));
+        assert!(!permitidos.contains("protocolo_privilegiado_del_futuro_v9"));
+        assert!(permitidos.contains("wl_compositor"));
+    }
+
+    #[test]
+    fn el_parser_no_se_cuelga_con_un_stream_corrupto() {
+        // Un `size` menor que la cabecera haría avanzar 0 bytes ⇒ bucle infinito dentro del proxy.
+        let mut malo = Vec::new();
+        malo.extend_from_slice(&1u32.to_ne_bytes());
+        malo.extend_from_slice(&((4u32 << 16) | 1).to_ne_bytes()); // size=4, imposible
+        assert!(cabecera(&malo).is_none());
+        assert!(cabecera(&[0u8; 3]).is_none()); // mensaje incompleto
+    }
+}
diff --git a/docs/adr/0015-imagenes-ajenas.md b/docs/adr/0015-imagenes-ajenas.md
index b6e6be43..275e62a7 100644
--- a/docs/adr/0015-imagenes-ajenas.md
+++ b/docs/adr/0015-imagenes-ajenas.md
@@ -366,18 +366,45 @@ andar sin explicar por qué.
 bits completa corre enjaulado sobre nuestro kernel, con seccomp y `no_new_privs` puestos, y **un
 contenedor anidado funciona adentro** — que es la forma exacta en que Valve prueba Proton.
 
+### D11 — El proxy de Wayland: lista BLANCA, y por qué al revés que las syscalls
+
+El compositor anuncia **todos** sus globals a quien se conecte. El proxy se mete en el medio, relaya
+todo salvo dos mensajes y con eso alcanza:
+
+| mensaje | qué se hace | por qué |
+|---|---|---|
+| `wl_registry.global` (del compositor) | si el interface no está en la lista, **no se reenvía** | el cliente nunca se entera de que existe |
+| `wl_registry.bind` (del cliente) | si el `name` es uno de los ocultos, `wl_display.error` y se corta | el `name` es **un número que se puede adivinar**: ocultar sin cortar el bind dejaría el filtro en decorativo |
+
+**Lista blanca, al revés que la denylist de syscalls de harkaq, y la asimetría tiene razón:** el
+conjunto *peligroso* de Wayland **crece** —cada compositor inventa protocolos privilegiados— mientras
+que el conjunto *necesario para dibujar una ventana* es corto y estable. Una denylist estaría
+desactualizada el día que alguien actualice el compositor, **y sin un solo error visible**. La lista
+incluye a propósito lo que los juegos piden y suele olvidarse: `pointer_constraints`,
+`relative_pointer`, `tearing_control`, `idle_inhibit`.
+
+**Lo que hace esto viable en un fichero:** ningún mensaje que se descarta lleva descriptores, así que
+los fds (que Wayland pasa por `SCM_RIGHTS` para `wl_shm` y dmabuf, y sin los cuales no se dibuja
+nada) se relayan en orden de llegada **sin tener que asociarlos a su mensaje**.
+
+**Probado sin compositor**, con uno FALSO que anuncia siete globals —tres permitidos y cuatro
+peligrosos— y un cliente que cuenta lo que ve: pasan exactamente los tres, y el intento de bindear
+por número el `name` que nunca se anunció corta la conexión. Es la clase de prueba que no necesita
+GPU y aun así responde la pregunta entera. Un quinto test cubre el `size` menor que la cabecera, que
+haría avanzar 0 bytes y colgar el proxy en un bucle.
+
 ---
 
 ## Lo que este ADR admite que NO resuelve
 
 Se escriben acá para que no se descubran en producción.
 
-1. **El socket de Wayland es un borde de privilegio, no un caño.** Pasarlo crudo le da a la imagen
-   ajena todos los protocolos privilegiados que exponga el compositor: `screencopy`,
-   `virtual-keyboard`, `input-method`. Flatpak no pasa el socket: pasa un **proxy que filtra
-   globals**. No lo tenemos. Mientras no exista, **una instancia con socket de Wayland puede
-   capturar la pantalla y sintetizar teclas**, y el manifiesto debe decirlo en la cara del usuario.
-   Cerrarlo es tarea propia y probablemente el ticket más valioso que sale de este ADR.
+1. **El socket de Wayland — ~~el agujero~~ CERRADO 2026-09-03.** Se escribió el proxy que faltaba
+   (D11): la instancia ya no ve el registro entero del compositor sino una **lista blanca**. Lo que
+   queda del párrafo original es la razón por la que se hizo: pasar el socket crudo **no es abrir un
+   caño, es un borde de privilegio** — `screencopy` captura la pantalla, `virtual_keyboard`
+   sintetiza teclas, `data_control` lee el portapapeles sin que nadie copie y `layer_shell` dibuja
+   encima de todo. Sigue disponible con `wayland_raw`, apagado por defecto y avisando a gritos.
 
 2. **UID mapping — ~~el muro~~ RESUELTO 2026-09-03.** Este punto ya no describe un hueco; queda
    por lo que enseñó. La instancia veía `uid_map: 0 1001 1` y `setgroups: deny`, y eso rompía `apt`
@@ -488,7 +515,7 @@ Se escriben acá para que no se descubran en producción.
    tras borrar **comprueba que el directorio se fue**: la cicatriz de `store-gc`, que reportaba
    borrados que no ocurrían. Los tamaños de un árbol con directorios ilegibles se marcan con `≥`,
    porque un número silenciosamente bajo es peor que ninguno cuando con él se decide borrar.
-8. Proxy filtrante de Wayland — ticket propio, el más valioso de la lista.
+8. ✅ **HECHO 2026-09-03.** Proxy filtrante de Wayland. Ver D11.
 
 ## Nombre — ADOPTADO 2026-09-03