> 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>
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"]