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>
This commit is contained in:
Sergio
2026-06-10 19:32:38 +00:00
co-authored by Claude Opus 4.8
parent 5ec7a81e87
commit 42288230f9
6 changed files with 263 additions and 3 deletions
+179
View File
@@ -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());
}
}
+2
View File
@@ -5,6 +5,7 @@
//! crates y se irá rellenando por fase. //! crates y se irá rellenando por fase.
pub mod apply; pub mod apply;
pub mod caps;
pub mod hash; pub mod hash;
pub mod proto; pub mod proto;
pub mod query; pub mod query;
@@ -13,6 +14,7 @@ pub mod sign;
pub mod store; pub mod store;
pub mod swm; pub mod swm;
pub use caps::{AgentCapsConfig, CapRule};
pub use hash::ArtifactHash; pub use hash::ArtifactHash;
pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind};
pub use sign::{KeyPair, SigStatus, TrustStore}; pub use sign::{KeyPair, SigStatus, TrustStore};
+24
View File
@@ -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 /// Contexto que necesita el dispatcher para resolver comandos. Compartido por todas las
/// conexiones (clones baratos: paths + Arc). /// conexiones (clones baratos: paths + Arc).
#[derive(Clone)] #[derive(Clone)]
@@ -455,6 +461,24 @@ mod tests {
assert_eq!(p(&foreign), vec![Cap::Query]); 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] #[test]
fn t_of_matches_all_commands() { fn t_of_matches_all_commands() {
// No es exhaustivo de Cargo, pero verifica que no nos olvidemos del nombre del tag. // No es exhaustivo de Cargo, pero verifica que no nos olvidemos del nombre del tag.
+25 -1
View File
@@ -43,6 +43,10 @@ struct Args {
/// y sólo queremos probar el bus). Igual que arrancar sin permisos, pero explícito. /// y sólo queremos probar el bus). Igual que arrancar sin permisos, pero explícito.
#[arg(long)] #[arg(long)]
no_watcher: bool, 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<()> { fn main() -> anyhow::Result<()> {
@@ -121,7 +125,27 @@ fn main() -> anyhow::Result<()> {
} }
// Bus de agente: bloqueante en este thread. Si falla, fin del daemon. // 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 { let ctx = bus::BusContext {
store_root: args.store, store_root: args.store,
init_control: if init_fifo_ok { init_control: if init_fifo_ok {
+5 -2
View File
@@ -94,8 +94,11 @@ pre-requisito de validación.
- [x] EventBus in-process: el watcher publica `Modified` y todas las conexiones lo reciben. - [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 - [x] Subsistemas independientes en `hammerd::main` (FIFO/watcher/bus en threads); si uno
falla en init, los demás siguen. falla en init, los demás siguen.
- [ ] Política expresiva: hoy es código (`default_policy`), pendiente leerla de - [x] Política expresiva leída de `/etc/hammer/agent-caps.toml` (`hammerd --agent-caps`).
`/etc/hammer/agent-caps.toml`. `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 - [ ] `CRASHED` real (requiere supervisión de servicios, que llega con el init propio del
track posterior). track posterior).
- [ ] `BuildFailed.log_tail` con cola real del lab (hoy es `None`; el `reason` viene del - [ ] `BuildFailed.log_tail` con cola real del lab (hoy es `None`; el `reason` viene del
+28
View File
@@ -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"]