//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and //! system-level capability grants (`Capability`). Both are declared in //! per-agent config (`tool-groups.json` / `capabilities.json`), injected //! into the container as env vars, and read by the harness to decide //! which MCP tools claude actually sees. //! //! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]): //! that set is group-gated too (`web_tools`), so it belongs next to the //! groups rather than in any one consumer. Both processes that spawn a //! `claude` — the harness itself and the subagent daemon — call the same //! function, so a subagent can never be handed a built-in its parent does //! not have. use std::str::FromStr; use serde::{Deserialize, Serialize}; /// Env var the meta renderer writes with a comma-separated list of /// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent /// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See /// `docs/process/conventions.md::Tool groups`. pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; /// Built-in claude tools present in every session regardless of tool /// groups. Anything not in this list (and not added by a group's /// [`ToolGroup::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 group-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 tool groups in effect for this process, from [`TOOL_GROUPS_ENV`]. /// /// Absent, blank, or naming nothing recognisable all fall back to /// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta /// renderer, so "unset" is an ordinary state, not an error. #[must_use] pub fn effective_tool_groups() -> Vec { ToolGroup::parse_list(&std::env::var(TOOL_GROUPS_ENV).unwrap_or_default()) } /// The built-in claude tools `groups` resolve to: [`ALLOWED_BUILTIN_TOOLS`] /// plus each group's own built-ins, de-duplicated, base set first. #[must_use] pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> { let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec(); for group in groups { for tool in group.builtin_tools() { if !tools.contains(tool) { tools.push(tool); } } } tools } /// The value for claude's `--tools` flag: which built-in tools exist in this /// session at all. /// /// ⚠️ **Never emit this as an empty string.** An empty `--tools` value parses /// as *unset* and yields **more** tools than omitting the flag, so there is /// no way to spell "no built-in tools" — an empty list is a fail-loudly bug, /// not a lockdown. [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which is what /// keeps that 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_arg() -> String { builtin_tools_for(&effective_tool_groups()).join(",") } /// Named group of MCP tools an agent may be granted. The harness reads /// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of /// `snake_case` group names written by the meta renderer from per-agent /// config) and expands it to the matching tool names for `--allowedTools`. /// When the env var is absent the harness falls back to `AGENT_DEFAULT`. /// See `docs/process/conventions.md::Tool groups`. #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr, strum::EnumString, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum ToolGroup { /// `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, /// `kill`, `start`, `restart`, `update` - *(privileged)* Lifecycle, /// `request_update_meta_inputs` - *(privileged)* Approvals, /// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, /// `edit_schedule`, `list_schedules` - *(privileged)* Scheduling, /// `get_logs` - *(privileged)* Diagnostics, /// `create_repo` — create git repos through hive-c0re (the only path /// now that agents can't create them directly). Opt-in per /// agent so the operator controls who can spin up repos. Forge, /// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/ /// `kill`) is rendered into the agent's config at all — see /// `extra_server_required_group` 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` by /// default; adding this group to an agent enables them in the session /// and in `--allowedTools` so they run without a confirmation prompt. /// Does not gate any MCP tools — `tools()` returns `&[]`. WebTools, } impl ToolGroup { /// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed /// and lowercased; an unrecognised one is warned about and skipped /// rather than failing the whole list, so one stale name in an agent's /// config can't take its whole tool surface away. /// /// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or /// names nothing recognisable — the same fallback either way, because /// "the operator granted no groups" and "the var is missing" are not /// distinguishable here and neither should mean "no tools". #[must_use] pub fn parse_list(raw: &str) -> Vec { let mut groups = Vec::new(); for token in raw.split(',') { let token = token.trim().to_ascii_lowercase(); if token.is_empty() { continue; } match Self::from_str(&token) { Ok(group) => groups.push(group), Err(_) => { tracing::warn!(token = %token, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); } } } if groups.is_empty() { if !raw.trim().is_empty() { tracing::warn!( "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ falling back to AGENT_DEFAULT" ); } return Self::AGENT_DEFAULT.to_vec(); } groups } /// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group. /// Returns `&[]` for `WebTools` — it enables Claude built-in tools, /// not MCP tools; see `builtin_tools()`. #[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"], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], Self::Approvals => &["request_update_meta_inputs"], Self::Scheduling => &[ "request_schedule_prompt", "fire_schedule_now", "cancel_schedule", "edit_schedule", "list_schedules", ], Self::Diagnostics => &["get_logs"], Self::Forge => &["create_repo"], // Both empty, for different reasons — see each variant's own // doc comment above. `Execution` grants the out-of-process // `bash` MCP server (`mcp__bash__run`/`status`/`kill`), gated // at config-render time by `extra_server_required_group` in // `hive-agent/src/mcp_config.rs`, not by this list — an // out-of-process server has no later enforcement point, so // that gate is the actual security boundary. `WebTools` // grants Claude built-in tools, not MCP ones; see // `builtin_tools()`. Self::Execution | Self::WebTools => &[], } } /// MCP tools that are always exposed regardless of which tool groups an /// agent is granted. `set_status` lives here because the operator /// dashboard depends on every agent being able to report its status /// chip — gating it behind a group would let a misconfigured agent go /// dark on the dashboard. The server-side `SetStatus` handler has no /// tool-group 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 rather /// than on tool groups, and every agent should be able to reach for it /// regardless of which optional groups it's been granted — same /// reasoning as `set_status`. /// /// `mark_todos_done` too (a critical bug every agent hit): it was /// declared as a `#[tool]` fn but never added to *any* /// group's [`tools`](Self::tools), including `Inbox`, so no agent could /// ever get it into `--allowedTools` and every call prompted for /// approval it can't get. Todos are pushed to an agent independent of /// whether it holds `Inbox` (that group only gates /// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without /// `Inbox` could accumulate todos it can never clear — same /// "every agent needs this regardless of optional groups" shape as /// `set_status`/`compact`, not a narrower `Inbox`-only fix. pub const ALWAYS_ON_TOOLS: &'static [&'static str] = &["set_status", "compact", "mark_todos_done"]; /// The Claude built-in tool names enabled by this group. Only /// `WebTools` returns a non-empty slice; all other groups return `&[]` /// (they control MCP tools via `tools()` instead). #[must_use] pub fn builtin_tools(self) -> &'static [&'static str] { match self { Self::WebTools => &["WebFetch", "WebSearch"], _ => &[], } } /// Default tool groups for an agent harness. Used when `HIVE_TOOL_GROUPS` is unset. pub const AGENT_DEFAULT: &'static [Self] = &[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution]; /// Convenience preset for a fully-privileged agent (all groups). /// Use this as a starting point in `tool-groups.json` for root/manager agents. pub const MANAGER_DEFAULT: &'static [Self] = &[ Self::Messaging, Self::Meta, Self::Inbox, Self::Lifecycle, Self::Approvals, Self::Scheduling, Self::Diagnostics, Self::Execution, ]; /// Every known tool group in a stable order. Use this to enumerate /// columns in the capabilities UI or any other place that needs the /// full list without hard-coding it at the call site. pub const ALL: &'static [Self] = &[ Self::Messaging, Self::Meta, Self::Inbox, Self::Lifecycle, Self::Approvals, Self::Scheduling, Self::Diagnostics, Self::Forge, Self::Execution, Self::WebTools, ]; /// Short human-readable description suitable for a tooltip or help text. #[must_use] pub fn description(self) -> &'static str { match self { Self::Messaging => "send, recv, ack_until — core agent communication", Self::Meta => { "get_agent_meta — identity introspection (set_status is always available)" } Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling", Self::Lifecycle => { "kill, start, restart, update, list_containers — container lifecycle (privileged)" } Self::Approvals => { "request_update_meta_inputs — operator-approved meta-flake input bumps (privileged)" } Self::Scheduling => { "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)" } Self::Diagnostics => { "get_logs — read a sub-agent container's systemd journal (privileged)" } Self::Forge => { "create_repo — create git repos through hive-c0re (operator-gated merge)" } Self::Execution => { "run, status — run shell commands via mcp__bash__run / mcp__bash__status" } Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools", } } } /// Per-agent capability grants. Stored in `meta/capabilities.json` /// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`). /// Capabilities control system-level access hive-c0re enforces at /// dispatch time; they are orthogonal to tool groups (which control /// which MCP tools the harness exposes to claude). /// /// Injected into containers as `HIVE_CAPABILITIES` (comma-separated /// `snake_case`) via `meta::render_flake`. The harness reads this to /// conditionally register capability-gated MCP tools so claude only /// sees tools it can actually invoke. See `docs/process/conventions.md::Capabilities`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum Capability { /// Agent can lifecycle-manage the root agent (kill/start/restart) /// on behalf of the hive when the root has crashed. Named capability /// for the existing manager privilege — future topology enforcement /// will gate this via the capability system instead of the hardcoded /// `container == MANAGER_CONTAINER` check. ManageRootAgent, /// Agent can read the full host journal via `GetHostJournal`. /// hive-c0re checks this capability before running journalctl. /// MCP tool `get_host_journal` is only registered in the harness /// when this capability is present. ReadHostJournal, /// Agent can query agents outside its own subtree via `GetLooseEnds`, /// `CountPendingReminders`, and `ReminderRollup` on the agent /// socket. Without this capability, targeting an agent outside the /// caller's subtree is rejected with an error (the caller itself and /// every descendant are always accessible without any capability). /// The `"*"` hive-wide value is not /// available on the agent socket even with this capability — use the /// manager socket for swarm-wide scans. QueryAgentState, } impl Capability { /// Every known capability in a stable order. Use this to enumerate /// columns in the permissions UI or validate incoming capability strings. pub const ALL: &'static [Self] = &[ Self::ManageRootAgent, Self::ReadHostJournal, Self::QueryAgentState, ]; /// Short human-readable description suitable for a tooltip or help text. #[must_use] pub fn description(self) -> &'static str { match self { Self::ManageRootAgent => { "lifecycle-manage the root/manager agent on hive crash recovery" } Self::ReadHostJournal => "read host journald via get_host_journal MCP tool", Self::QueryAgentState => { "query non-child agents' loose ends and reminder state via get_loose_ends" } } } } #[cfg(test)] mod tests { use super::*; /// The env-var spellings the meta renderer actually writes must round-trip /// back to groups. `strum::EnumString` and serde's `rename_all` are two /// independent derives over the same names; this pins them together, since /// the renderer writes what serde would produce and the parse is strum's. #[test] fn parse_list_accepts_the_snake_case_names_the_renderer_writes() { assert_eq!( ToolGroup::parse_list("messaging, Meta ,web_tools"), vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools] ); for group in ToolGroup::ALL { let name: &'static str = (*group).into(); assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}"); } } /// Blank, absent and all-garbage are the same state as far as the tool /// surface is concerned: the default groups, never an empty list. #[test] fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() { for raw in ["", " ", ",,", "not_a_group,also_not"] { assert_eq!( ToolGroup::parse_list(raw), ToolGroup::AGENT_DEFAULT.to_vec(), "{raw:?}" ); } } /// Adding groups only ever adds built-ins — the base set is in every /// resolution, and no group 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_groups_add() { assert_eq!( builtin_tools_for(&[]), ALLOWED_BUILTIN_TOOLS.to_vec(), "no groups resolves to exactly the base set" ); assert_eq!( builtin_tools_for(ToolGroup::AGENT_DEFAULT), ALLOWED_BUILTIN_TOOLS.to_vec(), "the default groups add no built-ins" ); for group in ToolGroup::ALL { let resolved = builtin_tools_for(&[*group]); for base in ALLOWED_BUILTIN_TOOLS { assert!(resolved.contains(base), "{group:?} dropped {base}"); } } } /// `web_tools` is the one group 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 /// group. #[test] fn web_egress_is_present_only_with_the_web_tools_group() { let with = builtin_tools_for(&[ToolGroup::WebTools]); assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch")); let without = builtin_tools_for(ToolGroup::MANAGER_DEFAULT); assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch")); } /// An empty `--tools` value parses as *unset* and grants more than /// omitting the flag, so no resolution may ever produce one. #[test] fn no_group_combination_resolves_to_an_empty_tools_arg() { assert!(!ALLOWED_BUILTIN_TOOLS.is_empty()); assert!(!builtin_tools_for(&[]).is_empty()); assert!(!builtin_tools_for(ToolGroup::ALL).is_empty()); assert!(!builtin_tools_arg().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(ToolGroup::ALL) { assert!(!tool.contains("__"), "{tool} looks like an MCP tool name"); } } }