//! The [`Permission`] set an agent may hold. //! //! Lives in this leaf crate so every crate that names a permission, hive and //! swarm side alike, can do so without depending on `hive-sh4re` (and through //! it `hive-priv-sock`). The built-in tool resolution that consumes it is //! `hive_sh4re::permissions`. use serde::{Deserialize, Serialize}; /// One thing an agent may be permitted. Stored by its `snake_case` name. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Permission { /// Grants no tool any more: agents message each other over matrix, and /// the hyperhive `send` / `recv` / `ack_until` tools are gone. Kept as an /// accepted value because it is stored across the swarm (permission sets, /// imports, agent configs). Messaging, /// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`) Meta, /// `get_loose_ends`, `cancel_loose_end`, `remind` Inbox, /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/ /// `kill`) is rendered into the agent's config at all — see /// `extra_server_required_permission` in `hive-agent/src/mcp_config.rs`. /// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*` /// allowlist entry, the gate lives at config-render time instead. Execution, /// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and /// `WebSearch` (search the web). Both are omitted from `--tools` /// without it; holding it enables them in the session and in /// `--allowedTools` so they run without a confirmation prompt. WebTools, /// Agent may manage *any* agent in the hive. Concretely, hive-c0re /// bind-mounts every agent's state dir (RW) and config dir (RO) into the /// holder's container, plus `/applied` and `/meta` read-only, so the /// holder can recover an agent that is down. See /// `lifecycle::host_config::set_nspawn_flags`. /// /// The name is historical, from when "the root agent" was the widest /// thing to manage. Kept under that spelling because every stored grant /// carries it. ManageRootAgent, /// Read access to the log store: what `swarm-logs` queries. Checked by /// the log gateway's `swarm-logs-auth` against the grant swarm-controller /// mirrors into the secret store, so `tools()` is empty. Not in /// [`Permission::DEFAULT`]. Logs, /// Read access to the metrics store: what `swarm-metrics` queries. /// Checked like [`Permission::Logs`], for the metrics gateway. Not in /// [`Permission::DEFAULT`]. Metrics, } impl Permission { /// Every permission in a stable order. pub const ALL: &'static [Self] = &[ Self::Messaging, Self::Meta, Self::Inbox, Self::Execution, Self::WebTools, Self::ManageRootAgent, Self::Logs, Self::Metrics, ]; /// What an agent the swarm sets nothing for holds, whatever its name. pub const DEFAULT: &'static [Self] = &[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; /// The MCP tool names (without the `mcp__hyperhive__` prefix) this /// permission grants. Empty for the permissions that grant something /// else; see each variant's doc comment. #[must_use] pub fn tools(self) -> &'static [&'static str] { match self { Self::Meta => &["get_agent_meta"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], // `Messaging` grants nothing now (agents message over matrix). // `Execution` grants the out-of-process `bash` MCP server, // gated at config-render time in `hive-agent/src/mcp_config.rs` // — an out-of-process server has no later enforcement point, so // that gate is the actual security boundary. `WebTools` grants // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants // bind mounts; `Logs` and `Metrics` are checked by the store // gateways, not by a tool allowlist. Self::Messaging | Self::Execution | Self::WebTools | Self::ManageRootAgent | Self::Logs | Self::Metrics => &[], } } /// MCP tools that are always exposed regardless of which permissions an /// agent holds. `set_status` lives here because the operator /// dashboard depends on every agent being able to report its status /// chip — gating it would let a misconfigured agent go dark on the /// dashboard. The server-side `SetStatus` handler has no permission /// check either (only length validation), so listing it here keeps the /// `--allowedTools` list honest with that reality. /// /// `compact` lives here too: it's pure self-management (no cross-agent /// effect, no privilege), gated server-side on context usage, and every /// agent should be able to reach for it — same reasoning as `set_status`. /// /// `mark_todos_done` too: todos are pushed to an agent whether or not it /// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/ /// `remind`), so an agent without `Inbox` must still be able to clear /// them. pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status", "compact", "mark_todos_done"]; /// The Claude built-in tool names this permission enables. Only /// `WebTools` returns a non-empty slice. #[must_use] pub fn builtin_tools(self) -> &'static [&'static str] { match self { Self::WebTools => &["WebFetch", "WebSearch"], _ => &[], } } } impl Permission { /// The stored `snake_case` name; identical to the serde form. #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::Messaging => "messaging", Self::Meta => "meta", Self::Inbox => "inbox", Self::Execution => "execution", Self::WebTools => "web_tools", Self::ManageRootAgent => "manage_root_agent", Self::Logs => "logs", Self::Metrics => "metrics", } } /// The permission stored under `name`; `None` for a name this build does /// not know. #[must_use] pub fn from_name(name: &str) -> Option { Self::ALL.iter().copied().find(|p| p.as_str() == name) } } /// Decodes a stored permission list, skipping names this build does not know. /// For `#[serde(deserialize_with)]` on a stored record: a permission a newer /// swarm adds must not cost an older reader the rest of the record. /// /// # Errors /// /// When the value is not a list of strings. pub fn deserialize_known<'de, D>(d: D) -> Result>, D::Error> where D: serde::Deserializer<'de>, { let names: Option> = Option::deserialize(d)?; Ok(names.map(|names| { names .iter() .filter_map(|name| { let known = Permission::from_name(name); if known.is_none() { tracing::debug!(name, "skipping a permission this build does not know"); } known }) .collect() })) } impl From for &'static str { fn from(perm: Permission) -> Self { perm.as_str() } } #[cfg(test)] mod tests { use super::*; /// Agents message each other over matrix, so no permission grants a /// hyperhive messaging tool any more. `Messaging` stays a valid stored /// value, and it grants nothing. #[test] fn messaging_is_an_accepted_value_that_grants_no_tool() { assert!(Permission::Messaging.tools().is_empty()); assert_eq!( serde_json::from_str::("\"messaging\"").expect("stored value decodes"), Permission::Messaging ); for p in Permission::ALL { for tool in p.tools() { assert!( !["send", "recv", "ack_until"].contains(tool), "{p:?} still grants the removed tool {tool}" ); } } } /// The `as_str` spelling of every variant. `ALL` is stable-ordered, so /// a variant added to the enum but not to `ALL` or `as_str` shows here. #[test] fn as_str_pins_every_variants_name() { let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect(); assert_eq!( names, [ "messaging", "meta", "inbox", "execution", "web_tools", "manage_root_agent", "logs", "metrics" ] ); } /// Reading stores a log or metric grant is never a default: an agent the /// swarm sets nothing for does not hold them. #[test] fn logs_and_metrics_are_off_by_default() { assert!(!Permission::DEFAULT.contains(&Permission::Logs)); assert!(!Permission::DEFAULT.contains(&Permission::Metrics)); assert!(Permission::ALL.contains(&Permission::Logs)); assert!(Permission::ALL.contains(&Permission::Metrics)); } #[derive(serde::Deserialize)] struct Record { #[serde(default, deserialize_with = "deserialize_known")] permissions: Option>, } #[test] fn an_unknown_name_is_skipped_not_a_decode_failure() { let r: Record = serde_json::from_str(r#"{"permissions":["messaging","telepathy","logs"]}"#).unwrap(); assert_eq!( r.permissions, Some(vec![Permission::Messaging, Permission::Logs]) ); // Presence control: an unset field stays unset, `[]` stays empty. let r: Record = serde_json::from_str("{}").unwrap(); assert_eq!(r.permissions, None); let r: Record = serde_json::from_str(r#"{"permissions":[]}"#).unwrap(); assert_eq!(r.permissions, Some(vec![])); // A list of non-strings is still a decode failure. assert!(serde_json::from_str::(r#"{"permissions":[1]}"#).is_err()); } /// The serde form of every variant, pinned independently of `as_str`: /// stored grants decode by it, so it must stay byte-identical. #[test] fn serde_form_pins_every_variants_name() { let want = [ (Permission::Messaging, "\"messaging\""), (Permission::Meta, "\"meta\""), (Permission::Inbox, "\"inbox\""), (Permission::Execution, "\"execution\""), (Permission::WebTools, "\"web_tools\""), (Permission::ManageRootAgent, "\"manage_root_agent\""), (Permission::Logs, "\"logs\""), (Permission::Metrics, "\"metrics\""), ]; assert_eq!(want.len(), Permission::ALL.len()); for (perm, json) in want { assert_eq!(serde_json::to_string(&perm).unwrap(), json); assert_eq!(serde_json::from_str::(json).unwrap(), perm); } assert!(serde_json::from_str::("\"lifecycle\"").is_err()); } }