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:
co-authored by
Claude Opus 4.8
parent
5ec7a81e87
commit
42288230f9
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
Reference in New Issue
Block a user