swarm-logs covers every host-tier unit this tool could reach, so the second, capability-gated path into host journald earns nothing and is removed outright rather than disabled behind a flag. Removed end to end: the MCP tool definition + handler, the GetHostJournal/HostJournal wire variants, hive-c0re's dispatch_host_journal handler, the ReadHostJournal capability, and the harness-side capability->--allowedTools gate. get_host_journal was the only capability that mapped to an MCP tool, so allowed_capability_tools could only ever return an empty vec; it goes too rather than linger as a function that provably does nothing. capabilities::has_cap/caps_for stay: #4624 gave ManageRootAgent's bind-mount enforcement (hive-c0re/src/lifecycle/host_config.rs) a second caller of has_cap, so they're no longer callerless once this lands on top of it. hive-sh4re's journal module (JournalPriority) had no consumer outside this tool and is deleted. An existing capabilities.json still naming read_host_journal does not error: capabilities::prune_unknown drops unrecognised names with a warn!, and an agent left with no capabilities has its entry removed. No migration step is needed. Untouched: hive-c0re/src/dashboard/journal.rs's read_host_journal_response, which matches the name but is the private helper behind the operator-only GET /api/journal-host dashboard route and carries no capability check.
218 lines
7.8 KiB
Rust
218 lines
7.8 KiB
Rust
//! Per-agent capability configuration. Stored at
|
|
//! `/var/lib/hyperhive/meta/capabilities.json` alongside `topology.json`
|
|
//! and `tool-groups.json`.
|
|
//!
|
|
//! Format: a JSON object mapping agent name to an array of
|
|
//! `hive_sh4re::permissions::Capability` `snake_case` strings:
|
|
//!
|
|
//! ```json
|
|
//! {
|
|
//! "atlas": ["manage_root_agent"]
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! An absent entry (or an absent file) means "no extra capabilities".
|
|
//! `render_flake` in `meta.rs` reads this file and injects
|
|
//! `HIVE_CAPABILITIES` into each agent's systemd service env; absent
|
|
//! entries emit no env var so agents without capabilities don't trigger
|
|
//! a spurious rebuild.
|
|
//!
|
|
//! Write path: `set_caps` is called from the dashboard action handler
|
|
//! that the operator uses to grant/revoke capabilities per agent.
|
|
|
|
use hive_sh4re::permissions::Capability;
|
|
use std::collections::BTreeMap;
|
|
use std::path::PathBuf;
|
|
|
|
const CAPABILITIES_FILE: &str = "capabilities.json";
|
|
|
|
#[must_use]
|
|
pub fn capabilities_path() -> PathBuf {
|
|
crate::paths::meta_root().join(CAPABILITIES_FILE)
|
|
}
|
|
|
|
/// True if `name` matches a recognised [`Capability`], case-insensitive
|
|
/// (same comparison [`has_cap`] uses).
|
|
#[must_use]
|
|
fn is_known(name: &str) -> bool {
|
|
Capability::ALL
|
|
.iter()
|
|
.any(|cap| name.eq_ignore_ascii_case(<&str>::from(*cap)))
|
|
}
|
|
|
|
/// Drop any capability string in `map` that isn't a recognised
|
|
/// [`Capability`], warning per dropped string so a typo'd or stale
|
|
/// grant doesn't look like it worked. An agent left with no
|
|
/// capabilities is removed entirely, matching [`set_caps`]'s
|
|
/// empty-vec behaviour. Returns whether anything was dropped.
|
|
fn prune_unknown(map: &mut BTreeMap<String, Vec<String>>) -> bool {
|
|
let mut dropped_any = false;
|
|
map.retain(|agent, caps| {
|
|
caps.retain(|cap| {
|
|
let known = is_known(cap);
|
|
if !known {
|
|
dropped_any = true;
|
|
tracing::warn!(agent, capability = %cap, "dropping unrecognised capability");
|
|
}
|
|
known
|
|
});
|
|
!caps.is_empty()
|
|
});
|
|
dropped_any
|
|
}
|
|
|
|
/// Read the per-agent capability map. Returns an empty map when the
|
|
/// file is absent or unparsable — callers treat a missing entry as
|
|
/// "no extra capabilities". Any entry that isn't a recognised
|
|
/// [`Capability`] is dropped (with a `warn!`) from what's returned —
|
|
/// a stale or typo'd name is never honoured — but `read` itself never
|
|
/// writes; the on-disk file only gets repaired the next time something
|
|
/// calls `write`/`set_caps` anyway.
|
|
#[must_use]
|
|
pub fn read() -> BTreeMap<String, Vec<String>> {
|
|
let path = capabilities_path();
|
|
let Ok(raw) = std::fs::read_to_string(&path) else {
|
|
return BTreeMap::new();
|
|
};
|
|
let mut map: BTreeMap<String, Vec<String>> = serde_json::from_str(&raw).unwrap_or_default();
|
|
prune_unknown(&mut map);
|
|
map
|
|
}
|
|
|
|
/// Look up the configured capabilities for one agent. Returns an empty
|
|
/// vec when the agent has no entry.
|
|
#[must_use]
|
|
pub fn caps_for(name: &str) -> Vec<String> {
|
|
read().get(name).cloned().unwrap_or_default()
|
|
}
|
|
|
|
/// Check whether an agent holds a specific capability.
|
|
#[must_use]
|
|
pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> bool {
|
|
caps_for(name)
|
|
.iter()
|
|
.any(|s| s.eq_ignore_ascii_case(<&str>::from(cap)))
|
|
}
|
|
|
|
/// Persist the full capability map. Sorted JSON output keeps diffs
|
|
/// minimal. Best-effort — returns `io::Error` so callers decide
|
|
/// whether to abort or log.
|
|
pub fn write(map: &BTreeMap<String, Vec<String>>) -> std::io::Result<()> {
|
|
let path = capabilities_path();
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
let text = serde_json::to_string_pretty(map)
|
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
|
std::fs::write(&path, format!("{text}\n"))
|
|
}
|
|
|
|
/// Filter `caps` down to recognised [`Capability`] names (warning per
|
|
/// drop) and apply the result to `current` for `name`: an empty
|
|
/// result — whether `caps` started empty or every name in it was
|
|
/// unknown — removes the entry, matching `read`'s empty-entry
|
|
/// convention. Split out from [`set_caps`] so the decision logic is
|
|
/// testable without touching the real capabilities file.
|
|
fn apply_known_caps(current: &mut BTreeMap<String, Vec<String>>, name: &str, caps: &[String]) {
|
|
let known: Vec<String> = caps
|
|
.iter()
|
|
.filter(|cap| {
|
|
let ok = is_known(cap);
|
|
if !ok {
|
|
tracing::warn!(agent = name, capability = %cap, "dropping unrecognised capability");
|
|
}
|
|
ok
|
|
})
|
|
.cloned()
|
|
.collect();
|
|
if known.is_empty() {
|
|
current.remove(name);
|
|
} else {
|
|
current.insert(name.to_owned(), known);
|
|
}
|
|
}
|
|
|
|
/// Set the capabilities for one agent and persist the map. Any name
|
|
/// that isn't a recognised [`Capability`] is dropped (with a `warn!`)
|
|
/// rather than written — an unknown grant should never look like it
|
|
/// took effect. An empty `caps` vec, or one that becomes empty after
|
|
/// dropping unknown names, removes the entry (agent has no
|
|
/// capabilities).
|
|
pub fn set_caps(name: &str, caps: &[String]) -> std::io::Result<()> {
|
|
let mut current = read();
|
|
apply_known_caps(&mut current, name, caps);
|
|
write(¤t)
|
|
}
|
|
|
|
/// Remove an agent from the capability map entirely. Called by
|
|
/// `meta::sync_agents` when an agent is deprovisioned so stale entries
|
|
/// don't accumulate. No-op if the agent has no entry.
|
|
pub fn remove_agent(name: &str) -> std::io::Result<()> {
|
|
let mut current = read();
|
|
if current.remove(name).is_some() {
|
|
write(¤t)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// `read`/`write`/`set_caps` shell out to `crate::paths::meta_root`,
|
|
// which is hardcoded to `/var/lib/hyperhive` (no test override) — so
|
|
// these tests pin the pure decision logic (`prune_unknown`,
|
|
// `apply_known_caps`) directly against in-memory maps rather than
|
|
// round-tripping through the real capabilities file.
|
|
|
|
#[test]
|
|
fn prune_unknown_keeps_known_name() {
|
|
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
|
|
assert!(!prune_unknown(&mut map));
|
|
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
|
|
}
|
|
|
|
#[test]
|
|
fn prune_unknown_drops_unrecognised_name() {
|
|
let mut map = BTreeMap::from([("atlas".to_owned(), vec!["fly_to_the_moon".to_owned()])]);
|
|
assert!(prune_unknown(&mut map));
|
|
assert!(!map.contains_key("atlas"));
|
|
}
|
|
|
|
#[test]
|
|
fn prune_unknown_keeps_known_and_drops_unknown_in_same_entry() {
|
|
let mut map = BTreeMap::from([(
|
|
"atlas".to_owned(),
|
|
vec!["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
|
|
)]);
|
|
assert!(prune_unknown(&mut map));
|
|
assert_eq!(map["atlas"], vec!["manage_root_agent".to_owned()]);
|
|
}
|
|
|
|
#[test]
|
|
fn apply_known_caps_removes_entry_on_empty_input() {
|
|
let mut current =
|
|
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
|
|
apply_known_caps(&mut current, "atlas", &[]);
|
|
assert!(!current.contains_key("atlas"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_known_caps_removes_entry_when_only_unknown_names_given() {
|
|
let mut current =
|
|
BTreeMap::from([("atlas".to_owned(), vec!["manage_root_agent".to_owned()])]);
|
|
apply_known_caps(&mut current, "atlas", &["fly_to_the_moon".to_owned()]);
|
|
assert!(!current.contains_key("atlas"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_known_caps_keeps_only_the_known_name() {
|
|
let mut current = BTreeMap::new();
|
|
apply_known_caps(
|
|
&mut current,
|
|
"atlas",
|
|
&["manage_root_agent".to_owned(), "fly_to_the_moon".to_owned()],
|
|
);
|
|
assert_eq!(current["atlas"], vec!["manage_root_agent".to_owned()]);
|
|
}
|
|
}
|