//! Per-agent authorization: the one [`Permission`] set an agent holds. //! //! The set lives in the swarm's `agent-permissions` record //! (`swarm_queue_client::agent_permissions`), one list per agent. Each member //! is enforced where it applies: the MCP-tool and built-in-tool members by //! the processes that spawn `claude` (the agent harness and the subagent //! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An //! agent the swarm sets nothing for holds [`Permission::default_for`]. //! //! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]): //! that set is permission-gated too (`web_tools`), so it belongs here rather //! than in any one consumer. Both processes that spawn a `claude` call the //! same function, so a subagent can never be handed a built-in its parent //! does not have — with one deliberate exception: //! [`subagent_builtin_tools_for`] additionally grants a **subagent** Claude's //! own `Bash` when the parent holds [`Permission::Execution`]. The parent //! never gets built-in `Bash` itself (it has the out-of-process `bash` MCP //! server instead); the permission transfers to a subagent, not the //! mechanism. use serde::{Deserialize, Serialize}; /// Built-in claude tools present in every session regardless of permissions. /// Anything not in this list (and not added by a permission's /// [`Permission::builtin_tools`]) literally doesn't exist in the session — /// this is the `--tools` list, which holds even under /// `--dangerously-skip-permissions`. /// /// Web egress (`WebFetch`/`WebSearch`) is permission-gated (`web_tools`) and so /// lives there, off by default. Nested agents (`Task`/`Agent`) are /// intentionally omitted. `Bash` is disallowed — shell execution goes /// through `mcp__bash__run` (background tasks with structured output via /// `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite` is /// omitted because the todo list lives in claude's in-process session state /// and silently evaporates on /compact or session reset — agents should /// plan in /state notes instead. `Skill` is included so installed plugin /// skills (`base@hyperhive` and friends) are actually invokable — without /// it here, every `SKILL.md`'s `description` frontmatter is inert: the /// model never sees the tool that triggers a skill, no matter how well it /// matches the task at hand. pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Skill", "Write"]; /// The built-in claude tools `perms` resolve to: [`ALLOWED_BUILTIN_TOOLS`] /// plus each permission's own built-ins, de-duplicated, base set first. /// /// ⚠️ The result is a claude `--tools` value once joined, and must **never be /// empty** — not because of what an empty value does, but because we don't /// know. Our own measurement and the installed `claude --help` disagree about /// whether `--tools ""` means "no tools" or parses as the flag being unset, /// and the answer is a property of whichever claude release is installed. /// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from arising. /// /// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by /// `--tools` at all; they are governed by `--strict-mcp-config` plus /// whatever the caller renders into `--mcp-config`. #[must_use] pub fn builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec(); for perm in perms { for tool in perm.builtin_tools() { if !tools.contains(tool) { tools.push(tool); } } } tools } /// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `perms` /// contains [`Permission::Execution`] — the **subagent**-only `--tools` /// resolution. /// /// `Execution` already grants shell execution to the parent agent, via the /// out-of-process `bash` MCP server (`mcp__bash__run`/`status`/`kill` — /// see the variant's own doc comment). A subagent has no MCP server at all /// by default (`docs/tools/subagent.md`), so there is no `mcp__bash__*` for /// it to inherit that permission through; it gets Claude's own `Bash` /// instead. /// /// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`Permission::builtin_tools`]: /// those are shared with the harness's own `--tools`, and the main agent must /// never gain built-in `Bash` no matter which permissions it holds — only a /// *subagent* spawned by an `Execution`-holding parent does. #[must_use] pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> { let mut tools = builtin_tools_for(perms); if perms.contains(&Permission::Execution) { tools.push("Bash"); } tools } /// One thing an agent may be permitted. Stored by its `snake_case` name. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum Permission { /// `send`, `recv`, `ack_until` 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, } 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, ]; /// What an agent the swarm sets nothing for holds. pub const DEFAULT: &'static [Self] = &[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; /// What `agent` holds when the swarm sets nothing for it: [`Self::DEFAULT`], /// plus [`Self::ManageRootAgent`] for the root agent /// ([`crate::manager::MANAGER_AGENT`]), which recovers the others. #[must_use] pub fn default_for(agent: &str) -> Vec { let mut perms = Self::DEFAULT.to_vec(); if agent == crate::manager::MANAGER_AGENT { perms.push(Self::ManageRootAgent); } perms } /// 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::Messaging => &["send", "recv", "ack_until"], Self::Meta => &["get_agent_meta"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], // `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. Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], } } /// 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"], _ => &[], } } } #[cfg(test)] mod tests { use super::*; /// The stored names are the `snake_case` spellings the old tool-group /// and capability files used, so an imported grant decodes. #[test] fn stored_names_are_snake_case() { 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" ] ); for perm in Permission::ALL { let json = serde_json::to_string(perm).unwrap(); assert_eq!(json, format!("\"{}\"", <&str>::from(*perm))); } assert!(serde_json::from_str::("\"lifecycle\"").is_err()); } #[test] fn only_the_root_agent_defaults_to_managing_the_others() { assert_eq!( Permission::default_for("atlas"), Permission::DEFAULT.to_vec() ); let ruth = Permission::default_for(crate::manager::MANAGER_AGENT); assert!(ruth.contains(&Permission::ManageRootAgent)); assert_eq!(&ruth[..Permission::DEFAULT.len()], Permission::DEFAULT); } /// Adding permissions only ever adds built-ins — the base set is in every /// resolution, and no permission can remove one. Both `--tools` callers (the /// harness and the subagent daemon) rely on this to reason about "the /// parent's set" without re-deriving it. #[test] fn builtin_tools_are_the_base_set_plus_whatever_the_permissions_add() { assert_eq!( builtin_tools_for(&[]), ALLOWED_BUILTIN_TOOLS.to_vec(), "no permissions resolves to exactly the base set" ); assert_eq!( builtin_tools_for(Permission::DEFAULT), ALLOWED_BUILTIN_TOOLS.to_vec(), "the default permissions add no built-ins" ); for perm in Permission::ALL { let resolved = builtin_tools_for(&[*perm]); for base in ALLOWED_BUILTIN_TOOLS { assert!(resolved.contains(base), "{perm:?} dropped {base}"); } } } /// `web_tools` is the one permission that widens the built-in set, and it is /// opt-in. A subagent inherits this resolution rather than a list of its /// own precisely so it cannot get web egress from a parent without the /// permission. #[test] fn web_egress_is_present_only_with_the_web_tools_permission() { let with = builtin_tools_for(&[Permission::WebTools]); assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch")); let without = builtin_tools_for(Permission::DEFAULT); assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch")); } /// No resolution may produce an empty `--tools` value. What an empty one /// means is disputed and release-dependent (see [`builtin_tools_for`]), /// so this pins the rule that makes the question moot rather than any /// answer to it. #[test] fn no_permission_combination_resolves_to_an_empty_tools_arg() { assert!(!ALLOWED_BUILTIN_TOOLS.is_empty()); assert!(!builtin_tools_for(&[]).is_empty()); assert!(!builtin_tools_for(Permission::ALL).is_empty()); } /// Built-ins are not MCP tools and the two lists must not be mixed: a /// `mcp__*` name in `--tools` would be silently inert, since `--tools` /// does not filter the MCP surface at all. #[test] fn the_builtin_list_names_no_mcp_tools() { for tool in builtin_tools_for(Permission::ALL) { assert!(!tool.contains("__"), "{tool} looks like an MCP tool name"); } } /// A subagent whose parent holds `Execution` gets Claude's own `Bash` — /// the parent already has shell execution (via the out-of-process /// `bash` MCP server), and a subagent has no MCP server of its own to /// inherit that through, so it gets Claude's built-in tool instead. #[test] fn subagent_gets_bash_when_parent_has_execution() { let tools = subagent_builtin_tools_for(&[Permission::Execution]); assert!(tools.contains(&"Bash")); } /// 🎯 The negative case that matters: without `Execution`, a subagent /// must not get `Bash` either. Without this, a later refactor that /// grants it unconditionally would go unnoticed. #[test] fn subagent_gets_no_bash_without_execution() { for permissions in [&[][..], &[Permission::Messaging, Permission::WebTools][..]] { let tools = subagent_builtin_tools_for(permissions); assert!( !tools.contains(&"Bash"), "{permissions:?} must not grant Bash" ); } } /// The parent (harness) resolver is untouched: `builtin_tools_for` — /// what `hive-agent`'s own `--tools` and /// `--allowedTools` are built from — never produce `Bash`, with or /// without `Execution`. Only the subagent-specific resolver above adds /// it; the operator's ruling was explicit that the main agent does not. #[test] fn parent_resolver_never_gains_bash() { for permissions in [&[][..], &[Permission::Execution][..], Permission::ALL] { let tools = builtin_tools_for(permissions); assert!( !tools.contains(&"Bash"), "{permissions:?} must not grant Bash to the parent" ); } } }