> 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…
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
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