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 188Rust 55Shell 30Markdown 281211 Definiciones

paso 2: el bit de privilegio se declara y se verifica — y `doas` queda solo (SDD 28 §5.11)

26 binarios de las imágenes son setuid en cualquier otra distro y acá salían TODOS `r-xr-xr-x`:
`grep -rn S_ISUID crates/` daba cero. El más caro no era sudo, era `unix_chkpwd` — sin él PAM no
puede verificar una contraseña y los greeters de los 4 escritorios no autentican a nadie. Latente
porque el instalador sólo pone contraseña de root.

Las tres preguntas que el §5.11 dejó abiertas:
  1. QUIÉN LO PONE: el ensamblado, no el artefacto, y por dos hechos — `of_tree` sólo hashea el bit
     de ejecución (⇒ el hash no lo cubriría) y la hidratación es por hardlink (⇒ el chmod viajaría
     al artefacto compartido). Se declara `[[setuid]]` en la receta con `why` OBLIGATORIO y lo
     aplica `takana setuid --apply`, que rompe el enlace duro antes del chmod. La copia queda a
     nombre de quien ensambla y el `unshare -r` de install-image.sh lo mapea a 0 ⇒ setuid root sin
     un solo chown.
  2. QUIÉN LO VERIFICA: las dos direcciones, y sale ≠0 en cualquiera. Que lo declarado esté, y que
     NADA sin declarar tenga el bit — eso convierte la lista en invariante, y es barato porque el
     rootfs sale de artefactos inmutables.
  3. QUÉ SIGNIFICA QUE EL HASH NO LO CUBRA: que el privilegio es propiedad de la IMAGEN, no del
     artefacto. El ArtifactHash sigue afirmando lo mismo; el privilegio gana su propio registro
     (git) y su propia verificación.

Fuera de `hash_inputs` como `license`/`[[service]]`/`[[user]]` ⇒ CERO reconstrucciones, verificado:
el hash de doas sigue `b3:28bc42c2…` sellado.

La elección, con lo medido: `sudo` 13 M y su sudoers sólo autoriza a root; `doas` 134 KB. Sudo sale
de los 7 perfiles (su receta se queda en el catálogo, que no es la imagen) y doas lleva el bit.
Falta el /etc/doas.conf y está anotado: sin él doas rechaza todo, y quién puede escalar es decisión
de la imagen — que hoy no tiene usuario humano.

Probado con roturas a propósito sobre un rootfs real (setuid colado ⇒ rc=1; ruta inexistente ⇒
rc=1) y 6 tests, incluido el que justifica el módulo: tras aplicar, el artefacto del store sigue
0555 y el del rootfs es 4755.

Y el guardián destapó algo que no era del setuid: el store tiene artefactos de DOS uid (1000 del
worker, 1001 del hub) ⇒ 1.159 de los 7.143 ficheros del rootfs de `base` salen a la imagen con
dueño 65534. Anotado como su propia unidad.

e76d90cc60eca9ff499aeeb82333e5403604b351 · por Sergio · (hace 29 d) · padre 6c356ae8d · ver el árbol en este commit

Delta semántico · 21

crates/takana-cli/src/main.rs

  • cambió de firma enum Cmd
  • cambió por dentro fn main

crates/takana-core/src/lib.rs

crates/takana-core/src/recipe.rs

  • cambió de firma struct Recipe pub

crates/takana-core/src/setuid.rs

scripts/hydrate-profile.py

  • cambió por dentro fn main

9 archivos · +694 −4

diff --git a/crates/takana-cli/src/main.rs b/crates/takana-cli/src/main.rs
index 2e482527..05521ee7 100644
--- a/crates/takana-cli/src/main.rs
+++ b/crates/takana-cli/src/main.rs
@@ -88,6 +88,29 @@ enum Cmd {
         #[arg(long)]
         merge: Option<String>,
     },
+    /// [SDD 28 §5.11 respondido] Los binarios que necesitan **bit de privilegio** (`[[setuid]]`) y,
+    /// con `--apply`, se lo pone a un rootfs ya hidratado.
+    ///
+    /// El bit no puede vivir en el artefacto: `of_tree` sólo hashea el bit de ejecución, así que el
+    /// hash no lo cubriría, y la hidratación es por HARDLINK, así que ponerlo en una imagen lo
+    /// pondría en el artefacto que comparten todas. `--apply` **rompe el enlace duro** y después
+    /// hace el `chmod`, dejando el store intacto.
+    ///
+    /// Verifica en las DOS direcciones y sale ≠0 en cualquiera: que lo declarado esté, y que **nada
+    /// sin declarar** tenga el bit. La segunda es la que convierte la lista en un invariante.
+    ///
+    /// No hace `chown`: la propiedad `root:root` la produce el `unshare -r` del ensamblado, que
+    /// mapea a 0 el uid QUE CORRE — por eso avisa si un fichero es de otro dueño.
+    Setuid {
+        /// Rutas de recetas TOML. `scripts/hydrate-profile.py` pasa las del cierre de la imagen.
+        recipes: Vec<String>,
+        /// Rootfs donde APLICAR el bit en vez de sólo listar las declaraciones.
+        #[arg(long)]
+        apply: Option<String>,
+        /// Listar en JSON en vez de en tabla.
+        #[arg(long)]
+        json: bool,
+    },
     /// [Fase 0] Calcula el `ArtifactHash` de una receta SIN construir — puro sobre las recetas
     /// (source_id + compiler/target/link + patches + flags + fases + hashes de deps). No baja
     /// fuentes ni compila. Responde "¿cuál es el hash VIGENTE de esta receta?", que el store no
@@ -1027,6 +1050,85 @@ fn main() -> anyhow::Result<()> {
             }
             println!("{}", serde_json::to_string_pretty(&cards)?);
         }
+        Cmd::Setuid { recipes, apply, json } => {
+            let mut decls: Vec<(String, takana_core::setuid::Setuid)> = Vec::new();
+            for ruta in &recipes {
+                let r = takana_core::Recipe::load_from_path(ruta)?;
+                for s in &r.setuid {
+                    decls.push((r.name.clone(), s.clone()));
+                }
+            }
+            // Igual que `users`: las recetas se validan una por una al cargar; esto caza lo que sólo
+            // se ve al juntarlas (dos paquetes reclamando la misma ruta).
+            takana_core::setuid::validate_all(&decls)?;
+
+            let Some(rootfs) = apply else {
+                if json {
+                    let mut filas = Vec::new();
+                    for (r, s) in &decls {
+                        filas.push(serde_json::json!({
+                            "recipe": r, "path": s.path, "mode": format!("{:o}", s.mode_octal()?),
+                            "owner": s.owner(), "why": s.why,
+                        }));
+                    }
+                    println!("{}", serde_json::to_string_pretty(&filas)?);
+                } else {
+                    for (r, s) in &decls {
+                        println!("{:o}  {:<12} {:<28} {}", s.mode_octal()?, s.owner(), s.path, r);
+                        println!("        {}", s.why);
+                    }
+                    println!("== {} declaración(es) de privilegio", decls.len());
+                }
+                return Ok(());
+            };
+
+            let root = std::path::Path::new(&rootfs);
+            if !root.is_dir() {
+                anyhow::bail!("no es un directorio: {rootfs}");
+            }
+            let yo = nix::unistd::geteuid().as_raw();
+            let informe = takana_core::setuid::aplicar(root, &decls)?;
+            let mut mal = 0;
+            for (receta, s, res, uid) in &informe {
+                use takana_core::setuid::Resultado::*;
+                match res {
+                    Puesto { rompio_hardlink } => println!(
+                        "   ✓ {:o} {:<26} ({receta}){}", s.mode_octal()?, s.path,
+                        if *rompio_hardlink { "  [enlace duro roto: el store queda intacto]" } else { "" }),
+                    YaEstaba => println!("   = {:o} {:<26} ({receta}) ya estaba", s.mode_octal()?, s.path),
+                    NoExiste => {
+                        // Una declaración que apunta a algo que la imagen no trae es la trampa de
+                        // catálogo-no-es-imagen: falla, no avisa.
+                        println!("   ✗ {:<32} NO EXISTE en el rootfs ({receta})", s.path);
+                        mal += 1;
+                    }
+                    NoEsFichero => {
+                        println!("   ✗ {:<32} no es un fichero regular ({receta})", s.path);
+                        mal += 1;
+                    }
+                }
+                if matches!(res, Puesto { .. } | YaEstaba) && *uid != yo {
+                    println!("     ⚠ el fichero quedó del uid {uid} y acá corre el {yo}: el `unshare -r` \
+del ensamblado mapea al que CORRE ⇒ este bit no saldría root en la imagen");
+                    mal += 1;
+                }
+            }
+            let colados = takana_core::setuid::sin_declarar(root, &decls)?;
+            if colados.is_empty() {
+                println!("   ✓ ningún setuid/setgid sin declarar en {rootfs}");
+            } else {
+                println!("\n   ✗ {} fichero(s) con el bit y SIN declaración:", colados.len());
+                for (ruta, modo) in &colados {
+                    println!("     {modo:o}  {ruta}");
+                }
+                println!("   Un bit que nadie declaró, en un rootfs hecho de artefactos inmutables, es");
+                println!("   alguien pisando la imagen: o entra a un `[[setuid]]` con su motivo, o sale.");
+                mal += colados.len();
+            }
+            if mal > 0 {
+                anyhow::bail!("{mal} problema(s) de privilegio en {rootfs}");
+            }
+        }
         Cmd::Users { recipes, format, merge } => {
             let mut users: Vec<takana_core::user::User> = Vec::new();
             for ruta in &recipes {
diff --git a/crates/takana-core/src/lib.rs b/crates/takana-core/src/lib.rs
index c1ceb510..ed31c666 100644
--- a/crates/takana-core/src/lib.rs
+++ b/crates/takana-core/src/lib.rs
@@ -18,6 +18,7 @@ pub mod query;
 pub mod recipe;
 pub mod repo;
 pub mod service;
+pub mod setuid;
 pub mod sign;
 pub mod store;
 pub mod swm;
diff --git a/crates/takana-core/src/recipe.rs b/crates/takana-core/src/recipe.rs
index 3cefe448..8d6798b6 100644
--- a/crates/takana-core/src/recipe.rs
+++ b/crates/takana-core/src/recipe.rs
@@ -90,6 +90,23 @@ pub struct Recipe {
     /// el artefacto para correr, no qué bytes tiene.
     #[serde(default, rename = "user", skip_serializing_if = "Vec::is_empty")]
     pub users: Vec<crate::user::User>,
+    /// **Los binarios de este paquete que necesitan el bit de privilegio** (`[[setuid]]`).
+    ///
+    /// Es la respuesta al SDD 28 §5.11, que quedó «anotado, sin decidir»: 26 binarios de las
+    /// imágenes son setuid en cualquier otra distro y acá salen todos `r-xr-xr-x`, así que
+    /// `passwd`, `mount`, FUSE, `ping` y —lo que más duele— el `unix_chkpwd` con el que PAM
+    /// verifica contraseñas no pueden funcionar para nadie que no sea root.
+    ///
+    /// El bit **no puede vivir en el artefacto**, y por dos razones medidas: `of_tree` sólo hashea
+    /// el bit de ejecución (`mode & 0o111`) ⇒ el hash no lo cubriría, y la hidratación es por
+    /// hardlink ⇒ ponerlo en una imagen lo pondría en el artefacto que comparten todas. Lo aplica
+    /// el ensamblado, rompiendo el enlace, desde esta declaración. Ver [`crate::setuid`].
+    ///
+    /// **NO entra en `hash_inputs`**, como `license`, `[[service]]` y `[[user]]`: declara un permiso
+    /// de la IMAGEN, no los bytes del artefacto ⇒ se puebla en recetas ya selladas sin mover un solo
+    /// `ArtifactHash`.
+    #[serde(default, rename = "setuid", skip_serializing_if = "Vec::is_empty")]
+    pub setuid: Vec<crate::setuid::Setuid>,
     /// Directorio base contra el que se resuelven rutas relativas de la receta
     /// (típicamente, `patches`). Lo fija `load_from_path`; al deserializar puro queda vacío.
     #[serde(skip, default)]
diff --git a/crates/takana-core/src/setuid.rs b/crates/takana-core/src/setuid.rs
new file mode 100644
index 00000000..5ec5e903
--- /dev/null
+++ b/crates/takana-core/src/setuid.rs
@@ -0,0 +1,382 @@
+//! `[[setuid]]` — **el bit de privilegio: quién lo pone, quién lo verifica, y qué significa que el
+//! hash no lo cubra.** Es la respuesta a las tres preguntas que el SDD 28 §5.11 dejó «anotado, sin
+//! decidir» el 2026-09-21.
+//!
+//! ## El hueco, medido (2026-09-22)
+//!
+//! **26 binarios** de las imágenes son setuid o setgid en cualquier otra distro y acá salen todos
+//! `r-xr-xr-x`: `sudo`, `doas`, `su`, `login`, `passwd`, `chage`, `chfn`, `chsh`, `expiry`,
+//! `gpasswd`, `newgrp`, `sg`, `newuidmap`, `newgidmap`, `mount`, `umount`, `sulogin`, `wall`,
+//! `unix_chkpwd`, `dbus-daemon-launch-helper`, `fusermount3`, `ping`, `ssh-agent`, `bwrap`… El
+//! síntoma no menciona nunca el bit (`Cannot change ID to root.`, §5.11) y **hoy es LATENTE**: el
+//! instalador sólo pone contraseña de root, así que no hay un humano no-root que lo choque. El día
+//! que lo haya, fallan a la vez el cambio de contraseña, el `mount` de usuario, FUSE, `ping` y
+//! —peor— `unix_chkpwd`, que es con lo que PAM verifica una contraseña: los greeters de los cuatro
+//! escritorios no podrían autenticar a nadie.
+//!
+//! ## 1 · Quién lo pone: **el ensamblado de la imagen, no el artefacto**
+//!
+//! Y no es una preferencia, son dos hechos que lo cierran:
+//!
+//! - **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
+//!   (`mode & 0o111`, `hash.rs:79`). Un artefacto con setuid y otro sin él tienen el MISMO hash ⇒
+//!   dejarlo ahí es privilegio no cubierto por el contrato que protege todo lo demás.
+//! - **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store:
+//!   poner el bit en la imagen lo pondría en el artefacto compartido, y al revés. Un `chmod u+s` en
+//!   un rootfs de pruebas dejaría setuid el artefacto que usan todas las demás imágenes.
+//!
+//! ⇒ El bit se aplica al ARMAR la imagen, **rompiendo el enlace duro** del fichero afectado
+//! ([`aplicar`]), desde una declaración que vive en la receta y está versionada en git. La
+//! declaración es metadato —fuera de `hash_inputs`, como `license`, `slots`, `[[service]]` y
+//! `[[user]]`—, así que se puebla en recetas YA SELLADAS sin mover un solo `ArtifactHash`.
+//!
+//! ## 2 · Quién lo verifica: las DOS direcciones, y la segunda es la que importa
+//!
+//! - *lo declarado está*: cada ruta existe en el rootfs y queda con el modo pedido.
+//! - **lo que está, está declarado**: ningún fichero del rootfs tiene setuid/setgid sin declararlo
+//!   ([`sin_declarar`]). Esta dirección convierte una lista en un INVARIANTE, y es la que ninguna
+//!   distro clásica puede sostener barata: acá el rootfs sale de artefactos inmutables, así que un
+//!   bit que aparece sin que nadie lo declare es, por construcción, alguien pisando la imagen.
+//!
+//! ## 3 · Qué significa que el hash no lo cubra
+//!
+//! Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
+//! sigue afirmando exactamente lo que afirmaba —estos bytes, con este bit de ejecución— y no se
+//! toca. Lo que cambia es que el privilegio pasa a tener su propio registro (la receta, en git) y su
+//! propia verificación (las dos direcciones de arriba). Lo que NO se puede hacer es guardarlo en el
+//! store y creer que el hash lo protege.
+//!
+//! ## Lo que este módulo NO hace
+//!
+//! **No hace `chown`.** No puede: el ensamblado corre sin root. La propiedad `root:root` la produce
+//! `install-image.sh` con `unshare -r`, que mapea el uid del constructor a 0 ⇒ un fichero nuestro
+//! con `u+s` sale setuid ROOT dentro de la imagen. Por eso [`aplicar`] **devuelve el uid de cada
+//! fichero** y quien llama lo compara con el suyo: un setuid de otro dueño no se convierte en root
+//! con ese mapeo, y sería un bit inútil con forma de bit puesto. El uid lo reporta este módulo y el
+//! aviso lo da el llamador, para no meterle `libc` a la caja de tipos compartidos por un `geteuid`.
+
+use serde::{Deserialize, Serialize};
+
+/// Una ruta del rootfs que necesita el bit, con su justificación.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
+pub struct Setuid {
+    /// Ruta RELATIVA a la raíz de la imagen, sin `/` inicial: `usr/bin/doas`.
+    pub path: String,
+    /// Modo octal completo, `"4755"` por defecto. Tiene que llevar setuid (`4000`) o setgid
+    /// (`2000`): si no, esta declaración no es lo que dice ser y se rechaza.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub mode: Option<String>,
+    /// Dueño esperado DENTRO de la imagen, `"root:root"` por defecto. Es documentación verificable,
+    /// no una acción: el `chown` lo hace el mapeo de `unshare -r` (ver el encabezado del módulo).
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub owner: Option<String>,
+    /// **Por qué este binario necesita privilegio.** Obligatorio a propósito: un setuid root que no
+    /// sabe explicarse no se lo gana. Es lo que un auditor lee, y lo que hace revisable la lista.
+    pub why: String,
+}
+
+impl Setuid {
+    pub fn mode_octal(&self) -> crate::Result<u32> {
+        let m = self.mode.as_deref().unwrap_or("4755");
+        let v = u32::from_str_radix(m, 8)
+            .map_err(|_| crate::Error::Recipe(format!("setuid {}: modo `{m}` no es octal", self.path)))?;
+        Ok(v)
+    }
+
+    pub fn owner(&self) -> &str {
+        self.owner.as_deref().unwrap_or("root:root")
+    }
+
+    pub fn validate(&self) -> crate::Result<()> {
+        let bad = |m: String| Err(crate::Error::Recipe(m));
+        if self.path.starts_with('/') || self.path.is_empty() {
+            return bad(format!(
+                "setuid `{}`: la ruta es RELATIVA a la imagen (`usr/bin/doas`, no `/usr/bin/doas`)",
+                self.path
+            ));
+        }
+        // `..` en una ruta de instalación sale del rootfs. No se normaliza: se rechaza.
+        if self.path.split('/').any(|c| c == ".." || c == ".") {
+            return bad(format!("setuid `{}`: la ruta no puede llevar `.` ni `..`", self.path));
+        }
+        let m = self.mode_octal()?;
+        if m & 0o6000 == 0 {
+            return bad(format!(
+                "setuid `{}`: el modo {:o} no lleva setuid (4000) ni setgid (2000) — entonces esta \
+                 declaración no hace falta",
+                self.path, m
+            ));
+        }
+        // Un setuid root escribible por cualquiera es una puerta abierta, no un permiso.
+        if m & 0o022 != 0 {
+            return bad(format!(
+                "setuid `{}`: modo {:o} escribible por grupo u otros — con el bit puesto eso es \
+                 root para cualquiera",
+                self.path, m
+            ));
+        }
+        if m & 0o111 == 0 {
+            return bad(format!("setuid `{}`: modo {:o} sin bit de ejecución", self.path, m));
+        }
+        if self.why.trim().is_empty() {
+            return bad(format!("setuid `{}`: falta `why` — un setuid sin motivo no se pone", self.path));
+        }
+        if !self.owner().contains(':') {
+            return bad(format!("setuid `{}`: `owner` va como `usuario:grupo`", self.path));
+        }
+        Ok(())
+    }
+}
+
+/// Lo que sólo se ve al juntar varias recetas: dos paquetes que reclaman la MISMA ruta. En una
+/// imagen eso es una carrera —gana el último que hidrate— y el modo final no lo decide nadie.
+pub fn validate_all(decls: &[(String, Setuid)]) -> crate::Result<()> {
+    for (_, s) in decls {
+        s.validate()?;
+    }
+    let mut vistas: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
+    for (receta, s) in decls {
+        if let Some(otra) = vistas.insert(&s.path, receta) {
+            if otra != receta {
+                return Err(crate::Error::Recipe(format!(
+                    "setuid `{}`: lo declaran DOS recetas ({otra} y {receta}) — el modo final lo \
+                     decidiría el orden de hidratación",
+                    s.path
+                )));
+            }
+        }
+    }
+    Ok(())
+}
+
+/// Qué pasó con cada declaración al aplicarla.
+#[derive(Debug, PartialEq)]
+pub enum Resultado {
+    /// Se puso el bit (y se rompió el enlace duro si hacía falta).
+    Puesto { rompio_hardlink: bool },
+    /// Ya estaba con el modo pedido: aplicar dos veces no hace nada (idempotente).
+    YaEstaba,
+    /// La ruta no existe en el rootfs. Es un ERROR: una declaración que apunta a un binario que la
+    /// imagen no trae es exactamente la trampa de [[catalogo-no-es-imagen]].
+    NoExiste,
+    /// Existe pero no es un fichero regular (symlink a otro artefacto, directorio…).
+    NoEsFichero,
+}
+
+/// Aplica las declaraciones sobre `rootfs`. Devuelve `(receta, decl, resultado, uid del fichero)`.
+///
+/// **Rompe el enlace duro** antes de tocar el modo: sin eso el `chmod` viajaría al artefacto del
+/// store, que es compartido por todas las imágenes (ver el encabezado del módulo). La copia se hace
+/// al mismo directorio y se `rename`a encima, que es atómico y deja el artefacto intacto con un link
+/// menos — el mismo patrón que [`crate::user::escribir_rompiendo_hardlink`].
+pub fn aplicar(
+    rootfs: &std::path::Path,
+    decls: &[(String, Setuid)],
+) -> crate::Result<Vec<(String, Setuid, Resultado, u32)>> {
+    #[cfg(unix)]
+    use std::os::unix::fs::{MetadataExt, PermissionsExt};
+    let mut out = Vec::new();
+    for (receta, s) in decls {
+        let p = rootfs.join(&s.path);
+        let meta = match std::fs::symlink_metadata(&p) {
+            Err(_) => {
+                out.push((receta.clone(), s.clone(), Resultado::NoExiste, 0));
+                continue;
+            }
+            Ok(m) => m,
+        };
+        if !meta.is_file() {
+            out.push((receta.clone(), s.clone(), Resultado::NoEsFichero, 0));
+            continue;
+        }
+        let modo_pedido = s.mode_octal()?;
+        #[cfg(unix)]
+        {
+            // El `chown` lo hace el mapeo de `unshare -r` del ensamblado, y ese mapeo sólo convierte
+            // en root al uid QUE CORRE ⇒ el llamador compara el uid FINAL con el suyo. Se reporta el
+            // de DESPUÉS de aplicar, no el de antes: romper el enlace duro crea un inodo nuevo cuyo
+            // dueño es quien corre, y ahí está la mitad buena del diseño — el bit sale utilizable
+            // incluso partiendo de un artefacto de otro dueño (en este store hay artefactos del uid
+            // 1000, del worker, que dentro de la jaula se ven `nobody`).
+            if meta.permissions().mode() & 0o7777 == modo_pedido {
+                out.push((receta.clone(), s.clone(), Resultado::YaEstaba, meta.uid()));
+                continue;
+            }
+            let rompe = meta.nlink() > 1;
+            if rompe {
+                let dir = p.parent().unwrap_or(std::path::Path::new("."));
+                let tmp = dir.join(format!(
+                    ".{}.setuid-tmp",
+                    p.file_name().and_then(|s| s.to_str()).unwrap_or("fichero")
+                ));
+                std::fs::copy(&p, &tmp)?;
+                std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(modo_pedido))?;
+                std::fs::rename(&tmp, &p)?;
+            } else {
+                std::fs::set_permissions(&p, std::fs::Permissions::from_mode(modo_pedido))?;
+            }
+            let uid_final = std::fs::symlink_metadata(&p).map(|m| m.uid()).unwrap_or(0);
+            out.push((receta.clone(), s.clone(), Resultado::Puesto { rompio_hardlink: rompe }, uid_final));
+        }
+        #[cfg(not(unix))]
+        out.push((receta.clone(), s.clone(), Resultado::NoEsFichero, 0));
+    }
+    Ok(out)
+}
+
+/// La segunda dirección de la verificación: ficheros del rootfs con setuid/setgid que NADIE declaró.
+/// Devuelve `(ruta relativa, modo)`. Un rootfs recién armado tiene que dar la lista vacía.
+pub fn sin_declarar(
+    rootfs: &std::path::Path,
+    decls: &[(String, Setuid)],
+) -> crate::Result<Vec<(String, u32)>> {
+    #[cfg(unix)]
+    use std::os::unix::fs::PermissionsExt;
+    let declaradas: std::collections::HashSet<&str> = decls.iter().map(|(_, s)| s.path.as_str()).collect();
+    let mut out = Vec::new();
+    let mut pila = vec![rootfs.to_path_buf()];
+    while let Some(d) = pila.pop() {
+        let it = match std::fs::read_dir(&d) {
+            Ok(i) => i,
+            Err(_) => continue,
+        };
+        for e in it.flatten() {
+            let p = e.path();
+            let meta = match std::fs::symlink_metadata(&p) {
+                Ok(m) => m,
+                Err(_) => continue,
+            };
+            if meta.file_type().is_symlink() {
+                continue;
+            }
+            if meta.is_dir() {
+                pila.push(p);
+                continue;
+            }
+            #[cfg(unix)]
+            {
+                let m = meta.permissions().mode() & 0o7777;
+                if m & 0o6000 != 0 {
+                    let rel = p.strip_prefix(rootfs).unwrap_or(&p).to_string_lossy().to_string();
+                    if !declaradas.contains(rel.as_str()) {
+                        out.push((rel, m));
+                    }
+                }
+            }
+        }
+    }
+    out.sort();
+    Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn doas() -> Setuid {
+        Setuid {
+            path: "usr/bin/doas".into(),
+            mode: None,
+            owner: None,
+            why: "es el único camino de escalada de la imagen".into(),
+        }
+    }
+
+    #[test]
+    fn defaults_4755_root_root() {
+        let s = doas();
+        assert_eq!(s.mode_octal().unwrap(), 0o4755);
+        assert_eq!(s.owner(), "root:root");
+        s.validate().unwrap();
+    }
+
+    #[test]
+    fn rechaza_lo_que_no_es_una_declaracion_de_privilegio() {
+        // sin el bit: la declaración no hace falta y engaña al que la lee
+        let mut s = doas();
+        s.mode = Some("0755".into());
+        assert!(s.validate().unwrap_err().to_string().contains("no lleva setuid"));
+        // escribible por otros CON el bit puesto = root para cualquiera
+        let mut s = doas();
+        s.mode = Some("4757".into());
+        assert!(s.validate().unwrap_err().to_string().contains("escribible"));
+        // ruta absoluta y `..`
+        let mut s = doas();
+        s.path = "/usr/bin/doas".into();
+        assert!(s.validate().is_err());
+        let mut s = doas();
+        s.path = "usr/../etc/shadow".into();
+        assert!(s.validate().is_err());
+        // sin motivo
+        let mut s = doas();
+        s.why = "  ".into();
+        assert!(s.validate().unwrap_err().to_string().contains("why"));
+    }
+
+    #[test]
+    fn dos_recetas_la_misma_ruta_es_error() {
+        let d = vec![("doas".to_string(), doas()), ("otra".to_string(), doas())];
+        assert!(validate_all(&d).unwrap_err().to_string().contains("DOS recetas"));
+        // la misma receta declarándolo una vez, no.
+        assert!(validate_all(&d[..1]).is_ok());
+    }
+
+    /// El test que justifica el módulo entero: aplicar el bit **no debe tocar el artefacto del
+    /// store**. Se monta un store de juguete, se «hidrata» por hardlink y se comprueba que después
+    /// del `aplicar` el original sigue `0555` y el del rootfs es `4755`.
+    #[test]
+    #[cfg(unix)]
+    fn no_contamina_el_artefacto_del_store() {
+        use std::os::unix::fs::PermissionsExt;
+        let t = tempfile::tempdir().unwrap();
+        let store = t.path().join("store/usr/bin");
+        let rootfs = t.path().join("rootfs/usr/bin");
+        std::fs::create_dir_all(&store).unwrap();
+        std::fs::create_dir_all(&rootfs).unwrap();
+        let orig = store.join("doas");
+        std::fs::write(&orig, b"binario").unwrap();
+        std::fs::set_permissions(&orig, std::fs::Permissions::from_mode(0o555)).unwrap();
+        std::fs::hard_link(&orig, rootfs.join("doas")).unwrap();
+
+        let decls = vec![("doas".to_string(), doas())];
+        let r = aplicar(&t.path().join("rootfs"), &decls).unwrap();
+        assert_eq!(r[0].2, Resultado::Puesto { rompio_hardlink: true });
+
+        let m_store = std::fs::metadata(&orig).unwrap().permissions().mode() & 0o7777;
+        let m_root = std::fs::metadata(rootfs.join("doas")).unwrap().permissions().mode() & 0o7777;
+        assert_eq!(m_store, 0o555, "el artefacto del store quedó con {m_store:o}");
+        assert_eq!(m_root, 0o4755);
+        // y el contenido es el mismo: se rompió el enlace, no se perdió el binario
+        assert_eq!(std::fs::read(rootfs.join("doas")).unwrap(), b"binario");
+        // idempotente: aplicar otra vez no hace nada
+        let r2 = aplicar(&t.path().join("rootfs"), &decls).unwrap();
+        assert_eq!(r2[0].2, Resultado::YaEstaba);
+    }
+
+    #[test]
+    #[cfg(unix)]
+    fn una_ruta_que_no_existe_no_pasa_por_buena() {
+        let t = tempfile::tempdir().unwrap();
+        std::fs::create_dir_all(t.path().join("usr/bin")).unwrap();
+        let r = aplicar(t.path(), &[("doas".into(), doas())]).unwrap();
+        assert_eq!(r[0].2, Resultado::NoExiste);
+    }
+
+    #[test]
+    #[cfg(unix)]
+    fn caza_el_setuid_que_nadie_declaro() {
+        use std::os::unix::fs::PermissionsExt;
+        let t = tempfile::tempdir().unwrap();
+        std::fs::create_dir_all(t.path().join("usr/bin")).unwrap();
+        for (n, m) in [("doas", 0o4755u32), ("colado", 0o4755), ("normal", 0o755), ("wall", 0o2755)] {
+            let p = t.path().join("usr/bin").join(n);
+            std::fs::write(&p, b"x").unwrap();
+            std::fs::set_permissions(&p, std::fs::Permissions::from_mode(m)).unwrap();
+        }
+        let s = sin_declarar(t.path(), &[("doas".into(), doas())]).unwrap();
+        assert_eq!(
+            s,
+            vec![("usr/bin/colado".to_string(), 0o4755), ("usr/bin/wall".to_string(), 0o2755)]
+        );
+    }
+}
diff --git a/docs/plan-que-sigue-tras-busybox.md b/docs/plan-que-sigue-tras-busybox.md
index e56ad768..5ffdeae6 100644
--- a/docs/plan-que-sigue-tras-busybox.md
+++ b/docs/plan-que-sigue-tras-busybox.md
@@ -444,3 +444,125 @@ poda, poda transitiva, la válvula, podar algo inalcanzable, y que ninguna entra
 raíz de todos los perfiles). Y se corrigió un comentario de `vigia-sonames.py` que afirmaba que «la
 hidratación de la imagen NO las instala: va por lista explícita» — era del OTRO linaje
 (`bootstrap product`), y por creerlo nadie miró los 1.527 MiB de LLVM que sí se instalaban.
+
+---
+
+# Paso 2 — HECHO: el bit de privilegio, y `doas` como único camino (2026-09-22)
+
+El SDD 28 §5.11 dejó esto «anotado, sin decidir» el 21/09 con tres preguntas. Están contestadas, y
+la respuesta es un mecanismo con guardián, no un `chmod` en un script.
+
+## El hueco medido: 26 binarios, todos inertes
+
+Cruzando el cierre de IMAGEN de los 8 perfiles contra los nombres que en cualquier distro son setuid
+o setgid, y mirando el modo real del artefacto:
+
+| lo trae | binarios | modo hoy | perfiles |
+|---|---|---|---|
+| `shadow` | `passwd chage chfn chsh expiry gpasswd newgrp sg su login newuidmap newgidmap` | `555` | 7 |
+| `util-linux` | `mount umount sulogin wall` | `555` | 7 |
+| `linux-pam` | **`unix_chkpwd`** | `555` | 5 (los escritorios) |
+| `dbus` | `dbus-daemon-launch-helper` | `555` | 7 |
+| `fuse3` | `fusermount3` | `555` | 3 |
+| `iputils` | `ping` | `555` | 7 |
+| `openssh` | `ssh-agent` | `555` | 1 |
+| `sudo` / `doas` | `sudo sudoedit` / `doas` | `555` | 7 |
+
+**Ninguno tiene el bit** y `grep -rn S_ISUID crates/` daba **cero**: no había nadie que lo pusiera.
+El más caro de la lista no es `sudo`: es **`unix_chkpwd`**, que es con lo que PAM verifica una
+contraseña — sin él los greeters de los cuatro escritorios no pueden autenticar a nadie.
+
+Sigue siendo **LATENTE**, y por eso nadie lo chocó: el instalador sólo pone contraseña de root y los
+`[[user]]` de las recetas son cuentas de servicio, que no escalan.
+
+## 1 · ¿Quién lo pone? El ensamblado, no el artefacto — y son dos hechos, no una preferencia
+
+- **El store no puede hashearlo.** `ArtifactHash::of_tree` sólo mira el bit de EJECUCIÓN
+  (`mode & 0o111`, `hash.rs:79`). Dos artefactos que difieran sólo en el setuid tienen el MISMO
+  hash ⇒ dejarlo ahí es privilegio no cubierto por el contrato que cubre todo lo demás.
+- **La hidratación es por HARDLINK** (ADR 0005). El fichero del rootfs *es* el inodo del store: un
+  `chmod u+s` en una imagen de pruebas dejaría setuid el artefacto que comparten todas las demás.
+
+⇒ Se declara en la receta (`[[setuid]]`, con `why` **obligatorio**) y lo aplica `takana setuid
+--apply <rootfs>`, que **rompe el enlace duro** antes del `chmod`. Y la mitad buena del diseño sale
+de ahí: la copia la crea quien ensambla, así que **queda a su nombre** — y el `unshare -r` de
+`install-image.sh` mapea ese uid a 0, o sea setuid ROOT en la imagen, sin un `chown` en ningún lado.
+
+**Cero reconstrucciones**, verificado: el campo está fuera de `hash_inputs` (como `license`,
+`[[service]]` y `[[user]]`) y el hash de `doas` sigue siendo `b3:28bc42c2…`, sellado.
+
+## 2 · ¿Quién lo verifica? Las dos direcciones, y la segunda es la que vale
+
+```
+✓ 4755 usr/bin/doas               (doas)  [enlace duro roto: el store queda intacto]
+✓ ningún setuid/setgid sin declarar en <rootfs>
+```
+
+- *lo declarado está*: cada ruta existe y queda con el modo pedido (una declaración que apunta a un
+  binario que la imagen no trae **falla**; es la trampa de [[catalogo-no-es-imagen]]).
+- **lo que está, está declarado**: ningún fichero del rootfs tiene el bit sin declararlo. Eso
+  convierte la lista en un invariante, y acá es barato justamente porque el rootfs sale de artefactos
+  inmutables: un bit que aparece sin declaración es, por construcción, alguien pisando la imagen.
+
+Probado con roturas a propósito sobre un rootfs de verdad, no sólo en tests: un setuid colado ⇒
+`rc=1`; una declaración que apunta a una ruta inexistente ⇒ `rc=1`. Más 6 tests en
+`crates/takana-core/src/setuid.rs`, incluido el que justifica el módulo entero: tras aplicar el bit
+**el artefacto del store sigue en `0555`** y el del rootfs es `4755`.
+
+## 3 · ¿Qué significa que el hash no lo cubra?
+
+Que **el privilegio no es propiedad del artefacto: es propiedad de la imagen.** El `ArtifactHash`
+sigue afirmando lo mismo que antes —estos bytes, con este bit de ejecución— y no se mueve. Lo que
+cambia es que el privilegio gana su propio registro (la receta, versionada en git) y su propia
+verificación (las dos direcciones de arriba). Lo que no se puede hacer es guardarlo en el store y
+creer que el hash lo protege.
+
+## La elección: `doas`, y `sudo` sale de las imágenes
+
+Decisión del usuario con lo medido delante:
+
+| | `sudo` | `doas` |
+|---|---|---|
+| tamaño | **13 M** | **134 KB** |
+| config que instala | `/etc/sudoers` + `/etc/sudo.conf` | ninguna |
+| lo que autoriza tal cual | `root ALL=(ALL:ALL) ALL` — **a nadie más** | nada hasta que exista `doas.conf` |
+
+Viajaban **los dos** en los 7 perfiles y ninguno podía funcionar. Ahora `sudo` no está en ninguna
+imagen (su receta se queda en el catálogo, que no es la imagen) y `doas` lleva su `[[setuid]]`.
+
+⚠ **Falta el `/etc/doas.conf`: sin él, `doas` rechaza todo.** No se inventó una regla acá — quién
+puede escalar es una decisión de la imagen (¿grupo `wheel`?, ¿qué usuario humano?) y **hoy las
+imágenes no tienen usuario humano**. Es el mismo hueco que el `/etc/shells` del §5.11 y es la unidad
+siguiente; declarar el bit ahora es lo que no depende de esa decisión.
+
+## Dos comentarios que afirmaban algo falso, corregidos
+
+| receta | decía | realidad |
+|---|---|---|
+| `recipes/sudo.toml:57` | «el paquete lo re-aplica al hidratar» | nadie lo re-aplicaba |
+| `recipes/doas.toml:38` | «el binario setuid es cosa de la hidratación» | no había hidratación que lo hiciera |
+| `recipes/shadow.toml:15` | «el modelo no puede ni expresar ni garantizar un setuid» | **la única que lo decía bien** |
+
+## Lo que el guardián encontró en su PRIMERA corrida, y no era del setuid
+
+El aviso de dueño saltó con `doas` y destapó otra cosa: **el store tiene artefactos de dos uid
+distintos.** Visto desde el anfitrión, `…-doas/usr/bin/doas` es del **uid 1000** (el usuario del
+worker, que llega con la cosecha) y `…-bash/bin/bash` es del **1001** (el hub). Dentro de la jaula el
+1000 no está mapeado y se ve `nobody`.
+
+Medido sobre el rootfs de `base` recién hidratado: **5.984 ficheros de `sergio` y 1.159 de `nobody`**.
+Como `unshare -r` sólo mapea a 0 el uid que corre, **esos 1.159 salen a la imagen con dueño 65534** —
+y nadie lo había medido. Para el setuid no importa (romper el enlace normaliza el dueño), pero es su
+propia unidad de trabajo y queda anotada acá.
+
+## Lo que queda, dicho para que no se lea como cubierto
+
+- **Los otros 25 binarios del inventario.** El mecanismo está; declararlos es una decisión de
+  superficie de ataque, una línea por binario, y **no se tomó de paso**. El orden por lo que
+  desbloquea: `unix_chkpwd` (sin él no hay login gráfico), `passwd`+`chage`+`chfn`+`chsh`+`gpasswd`,
+  `mount`/`umount`, `fusermount3`, `ping`, `newuidmap`/`newgidmap`.
+- **El camino del `.swm`**: `Swm::from_recipe` lleva `deps`, `evidence` y `slots`, pero **no**
+  `[[service]]`, `[[user]]` ni `[[setuid]]` ⇒ un `takana install` de un paquete no pone el bit. Es el
+  mismo hueco que las otras dos declaraciones ya tenían; no lo empeora, pero hay que nombrarlo.
+- **`product-rootfs`**: el otro linaje (`takana bootstrap product`) no pasa por `hydrate-profile.py`,
+  así que tampoco aplica el bit. Cuando el frente C de busybox lo toque, la llamada es una línea.
diff --git a/docs/state/targets.toml b/docs/state/targets.toml
index 87ad77dc..ce8f35d2 100644
--- a/docs/state/targets.toml
+++ b/docs/state/targets.toml
@@ -144,7 +144,18 @@ paquetes = [
   # porque NADIE lo alcanza por deps de build — los paquetes que instalan completados sólo preguntan
   # por su `.pc` y, si no está, se saltan la instalación en silencio. Es un hueco de RUNTIME puro,
   # de los que ninguna métrica de deuda puede ver. Salió del triaje de la frontera con 5 consumidores.
-  "bash", "bash-completion", "git", "sudo", "doas", "util-linux", "shadow", "kbd", "procps-ng",
+  # ── `sudo` SALIÓ de acá el 2026-09-22, y `doas` se queda como único camino de escalada ───────
+  # Viajaban LOS DOS en los 7 perfiles (base lo hereda todo el mundo) y **ninguno de los dos podía
+  # funcionar**: los dos artefactos salen `r-xr-xr-x` y no había nadie que pusiera el bit setuid
+  # (`grep -rn S_ISUID crates/` ⇒ CERO). Dos herramientas para lo mismo, las dos inertes, 13 M de
+  # una de ellas en cada imagen.
+  # Lo medido que decidió cuál se queda: `sudo` 13 M contra `doas` 134 KB, y el `/etc/sudoers` que
+  # sudo instala sólo trae `root ALL=(ALL:ALL) ALL` — o sea que ni con el bit habría servido a un
+  # usuario no-root sin añadirle una regla. La receta de sudo se queda en el CATÁLOGO, que no es la
+  # imagen; el bit se declara en `recipes/doas.toml` (`[[setuid]]`) y lo pone el ensamblado.
+  # ⚠ Falta el `/etc/doas.conf`: sin él doas rechaza todo. Es la unidad siguiente y está anotada en
+  # la receta — no se inventa acá una regla de quién puede escalar.
+  "bash", "bash-completion", "git", "doas", "util-linux", "shadow", "kbd", "procps-ng",
   "iproute2", "iputils", "dhcpcd", "e2fsprogs", "dosfstools", "parted", "rsync",
   "sed", "tzdata", "vim", "dbus", "ca-certificates", "gnupg", "lsof", "strace",
   "pciutils", "usbutils", "mandoc",
diff --git a/recipes/doas.toml b/recipes/doas.toml
index 13d9cd55..56854111 100644
--- a/recipes/doas.toml
+++ b/recipes/doas.toml
@@ -35,7 +35,12 @@ _abuild_phase() {
 _abuild_phase
 '''
 # package(): copia manual (make install hace chown root:root → falla en el sandbox;
-# doas.conf de aports no viaja). El binario setuid es cosa de la hidratación.
+# doas.conf de aports no viaja).
+#
+# ⚠ Acá decía «el binario setuid es cosa de la hidratación», y era FALSO: no había nadie que lo
+# pusiera. Medido el 2026-09-22 — `grep -rn S_ISUID crates/` daba CERO, el artefacto sale `r-xr-xr-x`
+# y `doas` en las 7 imágenes no podía funcionar. Desde hoy el bit se DECLARA abajo (`[[setuid]]`) y
+# lo aplica el ensamblado rompiendo el enlace duro (`takana setuid --apply`, SDD 28 §5.11).
 install = '''
 _abuild_phase() {
 	install -D -m 755 doas "/out"/usr/bin/doas
@@ -45,5 +50,19 @@ _abuild_phase() {
 _abuild_phase
 '''
 
+# ══ EL BIT DE PRIVILEGIO ═══════════════════════════════════════════════════════════════════════
+# Fuera de `hash_inputs` (como `license`, `[[service]]` y `[[user]]`) ⇒ declararlo NO re-sella nada.
+# No puede vivir en el artefacto: `of_tree` sólo hashea el bit de ejecución, y la hidratación es por
+# hardlink ⇒ el `chmod` viajaría al artefacto que comparten todas las imágenes. Ver `crate::setuid`.
+#
+# ⚠ **Con el bit puesto todavía falta el `doas.conf`**, y sin él doas RECHAZA todo («no rule
+# matched»). No se inventa acá una regla: quién puede escalar es decisión de la imagen (¿grupo
+# `wheel`?, ¿qué usuario humano?), y hoy las imágenes no tienen usuario humano — el instalador sólo
+# pone contraseña de root. Es el mismo hueco que el `/etc/shells` del SDD 28 §5.11, y es la unidad de
+# trabajo siguiente. Declarar el bit ahora es correcto igual: es lo que no depende de esa decisión.
+[[setuid]]
+path = "usr/bin/doas"
+why  = "es el ÚNICO camino de escalada de la imagen desde que `sudo` salió de los perfiles (2026-09-22): 134 KB contra 13 M y una superficie mínima. Sin el bit, un usuario no-root no puede administrar la caja ni con la contraseña correcta, y el error no menciona el privilegio."
+
 [deps]
 build = ["binutils", "bison", "make"]
diff --git a/recipes/sudo.toml b/recipes/sudo.toml
index 85cdeb7e..907f3dc4 100644
--- a/recipes/sudo.toml
+++ b/recipes/sudo.toml
@@ -54,7 +54,18 @@ compile = '''
 make LDFLAGS="-all-static -no-pie"
 '''
 # de package() de Alpine (traducido $pkgdir→/out; el make install de sudo intenta chown/chmod
-# setuid — el paquete lo re-aplica al hidratar; para el build alcanza con -o/-g neutralizados):
+# setuid — para el build alcanza con -o/-g neutralizados).
+#
+# ⚠ Acá decía «el paquete lo re-aplica al hidratar», y era FALSO: nadie lo re-aplicaba. Medido el
+# 2026-09-22: `grep -rn S_ISUID crates/` daba CERO y el artefacto sale `r-xr-xr-x`, así que el `sudo`
+# de las 7 imágenes no podía funcionar. Mismo error de bulto que `recipes/doas.toml`; la única de las
+# tres que lo decía bien era `recipes/shadow.toml`.
+#
+# ⚠ **Y desde el 2026-09-22 `sudo` NO VIAJA en ninguna imagen**: se eligió `doas` como único camino
+# de escalada (134 KB contra 13 M, y el `sudoers` que viaja sólo autoriza a root, así que tampoco
+# servía de nada tal cual). La receta se queda en el catálogo —que no es la imagen— y por eso NO
+# declara `[[setuid]]`: el bit se declara donde se usa. Si algún día vuelve a un perfil, vuelve con
+# su `[[setuid]]` y con una regla de `sudoers` que autorice a alguien.
 install = '''
 # the sudo's mkinstalldir script miscreates the leading
 # path components with bad permissions. fix this.
diff --git a/scripts/hydrate-profile.py b/scripts/hydrate-profile.py
index 66a179df..af6f7d50 100755
--- a/scripts/hydrate-profile.py
+++ b/scripts/hydrate-profile.py
@@ -356,10 +356,35 @@ def main():
             muestra = ", ".join(extra[:3]) + (", …" if len(extra) > 3 else "")
             print("     %-28s %4d  (%s)" % (nombre, len(extra), muestra))
 
+    # ── EL BIT DE PRIVILEGIO (SDD 28 §5.11, respondido el 2026-09-22) ──────────────────────────
+    # Se aplica ACÁ y no en la receta porque no puede vivir en el artefacto: `of_tree` sólo hashea el
+    # bit de EJECUCIÓN (⇒ el hash no lo cubriría) y la hidratación es por HARDLINK (⇒ el `chmod`
+    # viajaría al artefacto que comparten todas las imágenes). `takana setuid --apply` rompe el
+    # enlace duro del fichero afectado y deja el store intacto; verifica en las dos direcciones —lo
+    # declarado está, y nada sin declarar tiene el bit— y sale ≠0 si algo no cuadra.
+    #
+    # Va en este script, que es por donde pasan TODAS las imágenes de perfil, y no en cada script de
+    # imagen: un paso de seguridad que hay que acordarse de invocar se olvida. Y se imprime siempre,
+    # incluso cuando no hay declaraciones: «0 declaraciones» es una respuesta, y el silencio no.
+    rutas_recetas = [str(ROOT / ("recipes/%s.toml" % n if q == "corpus" else "recipes/%s/%s.toml" % (q, n)))
+                     for q, n in nodos]
+    print("\n== bit de privilegio (`[[setuid]]`) → %s" % into)
+    r = subprocess.run([HAMMER, "--store", store, "setuid", *rutas_recetas, "--apply", into],
+                       capture_output=True, text=True)
+    print((r.stdout or "").rstrip() or "   (sin salida)")
+    if r.returncode != 0:
+        # No se aborta el rootfs ya hidratado —borrarlo sería peor— pero se sale ≠0: un rootfs con el
+        # privilegio mal puesto NO puede pasar por bueno en un script de imagen que encadena.
+        print((r.stderr or "").rstrip())
+        print("   ✗ el bit de privilegio no quedó bien; el rootfs está hidratado pero NO es apto.")
+        salida_setuid = 1
+    else:
+        salida_setuid = 0
+
     n_fich = sum(len(f) for _, _, f in os.walk(into))
     du = subprocess.run(["du", "-sh", into], capture_output=True, text=True).stdout.split()
     print("\n== %s: %d ficheros, %s" % (into, n_fich, du[0] if du else "?"))
-    sys.exit(1 if (fallidos or (faltan and not permitir_faltantes)) else 0)
+    sys.exit(1 if (fallidos or salida_setuid or (faltan and not permitir_faltantes)) else 0)
 
 
 FLAGS_CON_VALOR = {"--into", "--store", "--link", "--closure"}