> 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…
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.
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"}