Files
takana/crates/hammer-core/src/caps.rs
T
SergioandClaude Opus 4.8 42288230f9 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>
2026-06-10 19:32:38 +00:00

180 lines
5.3 KiB
Rust

//! 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());
}
}