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…

Fase 5: política de caps del bus desde agent-caps.toml

Sustituye la política hardcodeada por una declarativa (SDD 07 §4).

hammer-core/src/caps.rs:
- AgentCapsConfig { default, rule[] }; CapRule { uid?, gid?, caps }.
- caps_for(uid, gid): primera regla que casa (todos los campos
  declarados deben coincidir) o default. load(path) → Ok(None) si falta.
- 8 tests: orden de reglas, match uid+gid, fallback, regla vacía ignorada.

hammerd:
- bus::policy_from_config(cfg) construye la CapsPolicy desde la config.
- main: --agent-caps (default /etc/hammer/agent-caps.toml). Con fichero
  usa la config; sin fichero o con fichero inválido cae a default_policy
  (avisando). +1 test del policy_from_config.

examples/agent-caps.toml: plantilla comentada.

22 binarios de test verdes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

42288230f9560814e356586d8b9eeb36747b95fc · por Sergio · (hace 4 meses) · padre 5ec7a81e8 · ver el árbol en este commit

Delta semántico · 22

crates/hammer-core/src/caps.rs

crates/hammer-core/src/lib.rs

  • nueva mod caps pub

crates/hammerd/src/bus.rs

crates/hammerd/src/main.rs

  • cambió de firma struct Args
  • cambió por dentro fn main

6 archivos · +263 −3

diff --git a/crates/hammer-core/src/caps.rs b/crates/hammer-core/src/caps.rs
new file mode 100644
index 00000000..ada30fec
--- /dev/null
+++ b/crates/hammer-core/src/caps.rs
@@ -0,0 +1,179 @@
+//! Política de capacidades del bus de agente, declarativa. Ver `docs/07-agent-bus.md` §4 y
+//! `docs/09-trust-model.md`.
+//!
+//! El daemon concede caps a cada conexión según su `Peer` (UID/GID ya autenticados vía
+//! `SO_PEERCRED`). Hasta ahora esa política vivía hardcodeada en código; este módulo la lee de
+//! `/etc/hammer/agent-caps.toml` para que el operador la ajuste sin recompilar. El peer NUNCA
+//! elige sus caps: las decide el daemon a partir de esta config.
+//!
+//! ```toml
+//! # Caps para cualquier UID/GID no cubierto por una regla.
+//! default = ["query"]
+//!
+//! # Reglas evaluadas en orden; gana la PRIMERA que casa. Una regla casa si todos los campos
+//! # que declara (`uid` y/o `gid`) coinciden con el peer.
+//! [[rule]]
+//! uid = 0
+//! caps = ["query", "compile", "inject", "inject-real", "init"]
+//!
+//! [[rule]]
+//! gid = 994            # p. ej. grupo `hammer`
+//! caps = ["query", "compile", "inject", "init"]
+//! ```
+
+use serde::Deserialize;
+
+use crate::proto::Cap;
+
+/// Config declarativa de la política de capacidades del bus.
+#[derive(Debug, Clone, Deserialize)]
+pub struct AgentCapsConfig {
+    /// Caps para peers que no casan ninguna regla.
+    #[serde(default = "default_caps")]
+    pub default: Vec<Cap>,
+    /// Reglas, evaluadas en orden (la primera que casa gana).
+    #[serde(default)]
+    pub rule: Vec<CapRule>,
+}
+
+/// Una regla: a quién aplica (por `uid` y/o `gid`) y qué caps concede.
+#[derive(Debug, Clone, Deserialize)]
+pub struct CapRule {
+    #[serde(default)]
+    pub uid: Option<u32>,
+    #[serde(default)]
+    pub gid: Option<u32>,
+    pub caps: Vec<Cap>,
+}
+
+fn default_caps() -> Vec<Cap> {
+    vec![Cap::Query]
+}
+
+impl Default for AgentCapsConfig {
+    fn default() -> Self {
+        AgentCapsConfig {
+            default: default_caps(),
+            rule: Vec::new(),
+        }
+    }
+}
+
+impl CapRule {
+    /// ¿Casa esta regla con `(uid, gid)`? Casa si todos los campos declarados coinciden. Una
+    /// regla sin `uid` ni `gid` NO casa (sería un catch-all redundante con `default`, y casi
+    /// siempre un error de config); se ignora con cuidado en `caps_for`.
+    fn matches(&self, uid: u32, gid: u32) -> bool {
+        if self.uid.is_none() && self.gid.is_none() {
+            return false;
+        }
+        self.uid.map(|u| u == uid).unwrap_or(true) && self.gid.map(|g| g == gid).unwrap_or(true)
+    }
+}
+
+impl AgentCapsConfig {
+    pub fn from_toml(s: &str) -> crate::Result<AgentCapsConfig> {
+        toml::from_str(s).map_err(|e| crate::Error::Serde(e.to_string()))
+    }
+
+    /// Carga la config desde `path`. Devuelve `Ok(None)` si el fichero no existe — el daemon
+    /// decide entonces su política por defecto.
+    pub fn load(path: &std::path::Path) -> crate::Result<Option<AgentCapsConfig>> {
+        match std::fs::read_to_string(path) {
+            Ok(s) => Ok(Some(Self::from_toml(&s)?)),
+            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
+            Err(e) => Err(e.into()),
+        }
+    }
+
+    /// Caps concedidas a un peer `(uid, gid)`: la primera regla que casa, o `default`.
+    pub fn caps_for(&self, uid: u32, gid: u32) -> Vec<Cap> {
+        for r in &self.rule {
+            if r.matches(uid, gid) {
+                return r.caps.clone();
+            }
+        }
+        self.default.clone()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    const CFG: &str = r#"
+default = ["query"]
+
+[[rule]]
+uid = 0
+caps = ["query", "compile", "inject", "inject-real", "init"]
+
+[[rule]]
+gid = 994
+caps = ["query", "compile", "inject", "init"]
+
+[[rule]]
+uid = 1000
+gid = 1000
+caps = ["query", "compile"]
+"#;
+
+    #[test]
+    fn root_gets_everything() {
+        let c = AgentCapsConfig::from_toml(CFG).unwrap();
+        assert_eq!(
+            c.caps_for(0, 0),
+            vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::InjectReal, Cap::Init]
+        );
+    }
+
+    #[test]
+    fn first_matching_rule_wins() {
+        let c = AgentCapsConfig::from_toml(CFG).unwrap();
+        // uid 1000 + gid 994: la regla del gid 994 viene ANTES que la de uid+gid 1000,
+        // así que gana la del grupo.
+        assert_eq!(
+            c.caps_for(1000, 994),
+            vec![Cap::Query, Cap::Compile, Cap::Inject, Cap::Init]
+        );
+    }
+
+    #[test]
+    fn both_fields_must_match() {
+        let c = AgentCapsConfig::from_toml(CFG).unwrap();
+        // uid 1000 + gid 1000: no casa root, no casa gid 994; casa la regla uid=1000,gid=1000.
+        assert_eq!(c.caps_for(1000, 1000), vec![Cap::Query, Cap::Compile]);
+    }
+
+    #[test]
+    fn unmatched_falls_back_to_default() {
+        let c = AgentCapsConfig::from_toml(CFG).unwrap();
+        assert_eq!(c.caps_for(31337, 31337), vec![Cap::Query]);
+    }
+
+    #[test]
+    fn empty_config_defaults_to_query() {
+        let c = AgentCapsConfig::from_toml("").unwrap();
+        assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
+    }
+
+    #[test]
+    fn rule_without_uid_or_gid_is_ignored() {
+        let c = AgentCapsConfig::from_toml(
+            r#"
+default = ["query"]
+[[rule]]
+caps = ["init", "inject-real"]
+"#,
+        )
+        .unwrap();
+        // La regla vacía no casa a nadie ⇒ cae al default.
+        assert_eq!(c.caps_for(0, 0), vec![Cap::Query]);
+    }
+
+    #[test]
+    fn load_missing_file_is_none() {
+        let got = AgentCapsConfig::load(std::path::Path::new("/no/existe.toml")).unwrap();
+        assert!(got.is_none());
+    }
+}
diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs
index 06c0aa98..88fc4233 100644
--- a/crates/hammer-core/src/lib.rs
+++ b/crates/hammer-core/src/lib.rs
@@ -5,6 +5,7 @@
 //! crates y se irá rellenando por fase.
 
 pub mod apply;
+pub mod caps;
 pub mod hash;
 pub mod proto;
 pub mod query;
@@ -13,6 +14,7 @@ pub mod sign;
 pub mod store;
 pub mod swm;
 
+pub use caps::{AgentCapsConfig, CapRule};
 pub use hash::ArtifactHash;
 pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
 pub use sign::{KeyPair, SigStatus, TrustStore};
diff --git a/crates/hammerd/src/bus.rs b/crates/hammerd/src/bus.rs
index 14879515..25acdaa3 100644
--- a/crates/hammerd/src/bus.rs
+++ b/crates/hammerd/src/bus.rs
@@ -44,6 +44,12 @@ pub fn default_policy() -> CapsPolicy {
     })
 }
 
+/// Política derivada de un fichero `agent-caps.toml` (SDD 07 §4). El peer no elige sus caps;
+/// las resuelve la config por `(uid, gid)`.
+pub fn policy_from_config(cfg: hammer_core::AgentCapsConfig) -> CapsPolicy {
+    Arc::new(move |peer: &Peer| cfg.caps_for(peer.uid, peer.gid))
+}
+
 /// Contexto que necesita el dispatcher para resolver comandos. Compartido por todas las
 /// conexiones (clones baratos: paths + Arc).
 #[derive(Clone)]
@@ -455,6 +461,24 @@ mod tests {
         assert_eq!(p(&foreign), vec![Cap::Query]);
     }
 
+    #[test]
+    fn config_policy_resolves_by_uid_gid() {
+        let cfg = hammer_core::AgentCapsConfig::from_toml(
+            r#"
+default = ["query"]
+[[rule]]
+uid = 0
+caps = ["query", "compile", "inject", "inject-real", "init"]
+"#,
+        )
+        .unwrap();
+        let p = policy_from_config(cfg);
+        let root = Peer { uid: 0, gid: 0, pid: 1 };
+        let other = Peer { uid: 1234, gid: 1234, pid: 2 };
+        assert!(p(&root).contains(&Cap::InjectReal));
+        assert_eq!(p(&other), vec![Cap::Query]);
+    }
+
     #[test]
     fn t_of_matches_all_commands() {
         // No es exhaustivo de Cargo, pero verifica que no nos olvidemos del nombre del tag.
diff --git a/crates/hammerd/src/main.rs b/crates/hammerd/src/main.rs
index ec957e5f..f9a9a55f 100644
--- a/crates/hammerd/src/main.rs
+++ b/crates/hammerd/src/main.rs
@@ -43,6 +43,10 @@ struct Args {
     /// y sólo queremos probar el bus). Igual que arrancar sin permisos, pero explícito.
     #[arg(long)]
     no_watcher: bool,
+    /// Fichero de política de capacidades del bus. Si existe, sustituye a la política por
+    /// defecto (misma-UID ⇒ casi todo; otros ⇒ query). Ver `docs/07-agent-bus.md` §4.
+    #[arg(long, default_value = "/etc/hammer/agent-caps.toml")]
+    agent_caps: PathBuf,
 }
 
 fn main() -> anyhow::Result<()> {
@@ -121,7 +125,27 @@ fn main() -> anyhow::Result<()> {
     }
 
     // Bus de agente: bloqueante en este thread. Si falla, fin del daemon.
-    let policy = bus::default_policy();
+    // Política de caps: del fichero si existe, si no la heurística por-UID por defecto.
+    let policy = match hammer_core::AgentCapsConfig::load(&args.agent_caps) {
+        Ok(Some(cfg)) => {
+            tracing::info!(path = %args.agent_caps.display(), rules = cfg.rule.len(),
+                "bus: política de caps cargada de fichero");
+            bus::policy_from_config(cfg)
+        }
+        Ok(None) => {
+            tracing::info!(path = %args.agent_caps.display(),
+                "bus: sin fichero de caps; uso la política por defecto (por-UID)");
+            bus::default_policy()
+        }
+        Err(e) => {
+            // Config presente pero inválida: fallar cerrado sería más seguro, pero romper el
+            // arranque del daemon por un typo es peor en la práctica. Avisamos fuerte y caemos
+            // a la política por defecto (restrictiva para no-dueños).
+            tracing::error!(path = %args.agent_caps.display(), error = %e,
+                "bus: fichero de caps inválido; uso la política por defecto");
+            bus::default_policy()
+        }
+    };
     let ctx = bus::BusContext {
         store_root: args.store,
         init_control: if init_fifo_ok {
diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md
index c539bd45..d4650663 100644
--- a/docs/10-roadmap.md
+++ b/docs/10-roadmap.md
@@ -94,8 +94,11 @@ pre-requisito de validación.
 - [x] EventBus in-process: el watcher publica `Modified` y todas las conexiones lo reciben.
 - [x] Subsistemas independientes en `hammerd::main` (FIFO/watcher/bus en threads); si uno
       falla en init, los demás siguen.
-- [ ] Política expresiva: hoy es código (`default_policy`), pendiente leerla de
-      `/etc/hammer/agent-caps.toml`.
+- [x] Política expresiva leída de `/etc/hammer/agent-caps.toml` (`hammerd --agent-caps`).
+      `hammer_core::AgentCapsConfig`: `default` + `[[rule]]` por `uid`/`gid` (primera que casa
+      gana), `caps_for(uid,gid)`. `bus::policy_from_config` la enchufa; sin fichero o con
+      fichero inválido, cae a la política por-UID por defecto (avisando). Ejemplo en
+      `examples/agent-caps.toml`. El `default_policy` hardcoded queda como fallback.
 - [ ] `CRASHED` real (requiere supervisión de servicios, que llega con el init propio del
       track posterior).
 - [ ] `BuildFailed.log_tail` con cola real del lab (hoy es `None`; el `reason` viene del
diff --git a/examples/agent-caps.toml b/examples/agent-caps.toml
new file mode 100644
index 00000000..226034ef
--- /dev/null
+++ b/examples/agent-caps.toml
@@ -0,0 +1,28 @@
+# Política de capacidades del bus de agente (hammerd). Ver docs/07-agent-bus.md §4.
+#
+# Colócala en /etc/hammer/agent-caps.toml (o pásala con `hammerd --agent-caps RUTA`).
+# El daemon concede caps a cada conexión según el (uid, gid) del peer — autenticados por
+# SO_PEERCRED. El peer NUNCA elige sus caps. Caps válidas:
+#   query | compile | inject | inject-real | init
+#
+# Caps para cualquier peer que no case ninguna regla.
+default = ["query"]
+
+# Reglas: se evalúan en orden y gana la PRIMERA que casa. Una regla casa si TODOS los campos
+# que declara (`uid` y/o `gid`) coinciden con el peer.
+
+# root: todo, incluido inject-real (escribir en el FHS real, no sólo en overlay).
+[[rule]]
+uid = 0
+caps = ["query", "compile", "inject", "inject-real", "init"]
+
+# Miembros del grupo `hammer` (ajusta el gid a tu sistema): pueden compilar e inyectar en
+# overlay, pero no pisar el FHS real ni mandar al init.
+[[rule]]
+gid = 994
+caps = ["query", "compile", "inject"]
+
+# Una cuenta de servicio de la IA: sólo compilar y consultar.
+[[rule]]
+uid = 1100
+caps = ["query", "compile"]