From 42288230f9560814e356586d8b9eeb36747b95fc Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 19:32:38 +0000 Subject: [PATCH] =?UTF-8?q?Fase=205:=20pol=C3=ADtica=20de=20caps=20del=20b?= =?UTF-8?q?us=20desde=20agent-caps.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/hammer-core/src/caps.rs | 179 +++++++++++++++++++++++++++++++++ crates/hammer-core/src/lib.rs | 2 + crates/hammerd/src/bus.rs | 24 +++++ crates/hammerd/src/main.rs | 26 ++++- docs/10-roadmap.md | 7 +- examples/agent-caps.toml | 28 ++++++ 6 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 crates/hammer-core/src/caps.rs create mode 100644 examples/agent-caps.toml 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, + /// Reglas, evaluadas en orden (la primera que casa gana). + #[serde(default)] + pub rule: Vec, +} + +/// 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, + #[serde(default)] + pub gid: Option, + pub caps: Vec, +} + +fn default_caps() -> Vec { + 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 { + 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> { + 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 { + 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"]