//! Per-agent tool-group configuration. Stored at //! `/var/lib/hyperhive/meta/tool-groups.json` alongside `topology.json` //! and the meta `flake.nix`. //! //! Format: a JSON object mapping agent name to an array of //! `hive_sh4re::permissions::ToolGroup` `snake_case` strings: //! //! ```json //! { //! "alice": ["messaging", "meta", "inbox", "lifecycle"], //! "bob": ["messaging", "meta", "inbox"] //! } //! ``` //! //! An absent entry (or an absent file) means "use the harness default" //! (`AGENT_DEFAULT`: `messaging + meta + inbox + execution`). `render_flake` //! in `meta.rs` reads this file and injects `HIVE_TOOL_GROUPS` into each //! agent's systemd service env; agents with no entry get no env var and the //! harness falls back to `AGENT_DEFAULT`. //! //! Write path: `set_groups` is called from the dashboard action handler //! that the operator uses to grant/revoke tool groups per agent. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use anyhow::Context as _; const TOOL_GROUPS_FILE: &str = "tool-groups.json"; #[must_use] pub fn tool_groups_path() -> PathBuf { crate::paths::meta_root().join(TOOL_GROUPS_FILE) } /// Read the per-agent tool-group map. An absent file is the empty map — /// callers treat a missing entry as "use role default". A file that /// exists but can't be read or parsed is an error. pub fn read() -> std::io::Result>> { super::read_map(&tool_groups_path()) } /// Look up the configured tool groups for one agent. Returns an empty /// vec when the agent has no entry — callers should treat this as /// "use the harness role default." Errors as [`read`] does. pub fn groups_for(name: &str) -> std::io::Result> { Ok(read()?.get(name).cloned().unwrap_or_default()) } /// Validate a slice of group name strings against `ToolGroup::ALL`. /// Returns `Ok(())` when all names are known, or `Err` listing the /// unrecognised names so callers can surface a useful error message. pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { let valid: std::collections::BTreeSet<&str> = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| <&str>::from(*g)) .collect(); let unknown: Vec<&str> = groups .iter() .map(String::as_str) .filter(|s| !valid.contains(s)) .collect(); if unknown.is_empty() { Ok(()) } else { anyhow::bail!( "unknown tool group(s): {}; valid names are: {}", unknown.join(", "), hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| <&str>::from(*g)) .collect::>() .join(", ") ) } } /// Set the tool groups for one agent and persist the map. An empty /// `groups` vec removes the entry (agent reverts to role default). /// Returns an error if any name is not in `ToolGroup::ALL`, or if the /// existing file can't be read or parsed — the file is then left /// untouched. pub fn set_groups(name: &str, groups: &[String]) -> anyhow::Result<()> { set_groups_at(&tool_groups_path(), name, groups) } fn set_groups_at(path: &Path, name: &str, groups: &[String]) -> anyhow::Result<()> { if !groups.is_empty() { validate_groups(groups)?; } let mut current: BTreeMap> = super::read_map(path)?; if groups.is_empty() { current.remove(name); } else { current.insert(name.to_owned(), groups.to_vec()); } super::write_map(path, ¤t).with_context(|| format!("write tool-groups for {name}")) } /// Drop the entry for an agent that is being destroyed. Idempotent. /// Fails without writing if the existing file can't be read or parsed. pub fn remove_agent(name: &str) -> std::io::Result<()> { remove_agent_at(&tool_groups_path(), name) } fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> { let mut current: BTreeMap> = super::read_map(path)?; if current.remove(name).is_some() { super::write_map(path, ¤t)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; const TRUNCATED: &str = "{\n \"alice\": [\"messaging\", \"meta\"],\n \"bob\": [\"mess"; fn corrupt_file() -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(TOOL_GROUPS_FILE); std::fs::write(&path, TRUNCATED).expect("seed"); (dir, path) } #[test] fn set_groups_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); let err = set_groups_at(&path, "ruth", &["messaging".to_owned()]) .expect_err("a corrupt file must not be overwritten"); let kind = err .downcast_ref::() .map(std::io::Error::kind); assert_eq!(kind, Some(std::io::ErrorKind::InvalidData)); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn remove_agent_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); let err = remove_agent_at(&path, "alice").expect_err("a corrupt file must not be overwritten"); assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn set_groups_keeps_other_agents_entries() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join(TOOL_GROUPS_FILE); set_groups_at(&path, "alice", &["inbox".to_owned()]).expect("set on missing file"); set_groups_at(&path, "ruth", &["messaging".to_owned()]).expect("set"); let map: BTreeMap> = crate::agent_config::read_map(&path).expect("read"); assert_eq!(map["alice"], vec!["inbox".to_owned()]); assert_eq!(map["ruth"], vec!["messaging".to_owned()]); } }