diff --git a/docs/conventions.md b/docs/conventions.md index a390dbbd..44839b1c 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -318,7 +318,7 @@ straight to `new Date(s)` for display. ## Tool groups The MCP tool surface an agent receives is derived from a set of named -`ToolGroup` values (`hive_sh4re::ToolGroup`), not from a hardcoded +`ToolGroup` values (`hive_sh4re::permissions::ToolGroup`), not from a hardcoded binary flavor. | Group | Tools | diff --git a/hive-agent/src/mcp_config.rs b/hive-agent/src/mcp_config.rs index a36fce3a..ec23b8d6 100644 --- a/hive-agent/src/mcp_config.rs +++ b/hive-agent/src/mcp_config.rs @@ -35,14 +35,14 @@ pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790; pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "Skill", "Write"]; /// Env var written by the meta renderer with a comma-separated list of -/// `hive_sh4re::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). +/// `hive_sh4re::permissions::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`). /// When present, the harness expands the groups into per-tool allow entries /// instead of using the hardcoded flavor default. See `docs/conventions.md::Tool groups`. const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS"; /// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the /// operator grants capabilities to this agent. Comma-separated -/// `hive_sh4re::Capability` `snake_case` names. Absent = no extra capabilities. +/// `hive_sh4re::permissions::Capability` `snake_case` names. Absent = no extra capabilities. const CAPABILITIES_ENV: &str = "HIVE_CAPABILITIES"; /// Returns the MCP tool names (without `mcp__hyperhive__` prefix) that are @@ -87,18 +87,18 @@ fn allowed_capability_tools() -> Vec { /// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`, /// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped. /// Falls back to `AGENT_DEFAULT` when the env var is absent or empty. -fn effective_tool_groups() -> Vec { +fn effective_tool_groups() -> Vec { let raw = match std::env::var(TOOL_GROUPS_ENV) { Ok(v) if !v.trim().is_empty() => v, - _ => return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(), + _ => return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec(), }; let mut groups = Vec::new(); for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); // Parse via serde_json (the canonical deserialization path). - if let Ok(g) = - serde_json::from_value::(serde_json::Value::String(t.clone())) - { + if let Ok(g) = serde_json::from_value::( + serde_json::Value::String(t.clone()), + ) { groups.push(g); } else { tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); @@ -109,7 +109,7 @@ fn effective_tool_groups() -> Vec { "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ falling back to AGENT_DEFAULT" ); - return hive_sh4re::ToolGroup::AGENT_DEFAULT.to_vec(); + return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec(); } groups } @@ -124,9 +124,9 @@ fn effective_tool_groups() -> Vec { /// an out-of-process server has **no** later enforcement point — once it is /// in the claude MCP config the agent can call it. So this gate, applied at /// config-render time, is the security boundary for those servers. -fn extra_server_required_group(server: &str) -> Option { +fn extra_server_required_group(server: &str) -> Option { match server { - "bash" => Some(hive_sh4re::ToolGroup::Execution), + "bash" => Some(hive_sh4re::permissions::ToolGroup::Execution), _ => None, } } @@ -134,14 +134,14 @@ fn extra_server_required_group(server: &str) -> Option { /// Whether an extra MCP server should be exposed to claude given the active /// tool `groups`. A gated server (see [`extra_server_required_group`]) is /// suppressed when the agent lacks its required group. -fn extra_server_enabled(server: &str, groups: &[hive_sh4re::ToolGroup]) -> bool { +fn extra_server_enabled(server: &str, groups: &[hive_sh4re::permissions::ToolGroup]) -> bool { extra_server_required_group(server).is_none_or(|required| groups.contains(&required)) } #[cfg(test)] mod extra_server_gate_tests { use super::{extra_server_enabled, extra_server_required_group}; - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; #[test] fn bash_is_gated_behind_execution() { @@ -173,13 +173,13 @@ mod extra_server_gate_tests { /// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re /// (single source of truth). See `docs/conventions.md::Tool groups`. #[must_use] -pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { +pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec { // Collect all tool names, deduplicating while preserving order. // Always-on tools (e.g. `set_status`) come first so they're present // regardless of which groups the agent is granted — a misconfigured // agent still has to be able to report its dashboard status. let mut seen = std::collections::HashSet::new(); - let mut out: Vec = hive_sh4re::ToolGroup::ALWAYS_ON_TOOLS + let mut out: Vec = hive_sh4re::permissions::ToolGroup::ALWAYS_ON_TOOLS .iter() .copied() .chain(groups.iter().flat_map(|g| g.tools().iter().copied())) @@ -406,7 +406,7 @@ fn build_mcp_servers() -> serde_json::Map { #[cfg(test)] mod tests { use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index dd8e4b33..187cb3b7 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -3,7 +3,7 @@ //! and `tool-groups.json`. //! //! Format: a JSON object mapping agent name to an array of -//! `hive_sh4re::Capability` `snake_case` strings: +//! `hive_sh4re::permissions::Capability` `snake_case` strings: //! //! ```json //! { @@ -52,7 +52,7 @@ pub fn caps_for(name: &str) -> Vec { /// Check whether an agent holds a specific capability. #[must_use] -pub fn has_cap(name: &str, cap: hive_sh4re::Capability) -> bool { +pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> bool { caps_for(name) .iter() .any(|s| s.eq_ignore_ascii_case(cap.as_str())) diff --git a/hive-c0re/src/agent_config/tool_groups.rs b/hive-c0re/src/agent_config/tool_groups.rs index 4bb1fe08..4f73f7ba 100644 --- a/hive-c0re/src/agent_config/tool_groups.rs +++ b/hive-c0re/src/agent_config/tool_groups.rs @@ -3,7 +3,7 @@ //! and the meta `flake.nix`. //! //! Format: a JSON object mapping agent name to an array of -//! `hive_sh4re::ToolGroup` `snake_case` strings: +//! `hive_sh4re::permissions::ToolGroup` `snake_case` strings: //! //! ```json //! { @@ -70,7 +70,7 @@ fn write(map: &BTreeMap>) -> std::io::Result<()> { /// 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::ToolGroup::ALL + let valid: std::collections::BTreeSet<&str> = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect(); @@ -85,7 +85,7 @@ pub fn validate_groups(groups: &[String]) -> anyhow::Result<()> { anyhow::bail!( "unknown tool group(s): {}; valid names are: {}", unknown.join(", "), - hive_sh4re::ToolGroup::ALL + hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect::>() diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index eff72de2..6f4ffec3 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -696,7 +696,7 @@ impl Coordinator { /// rebuild-queue worker after a `PermChange` / Capabilities entry /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_capabilities_snapshot(self: &Arc) { - use hive_sh4re::Capability; + use hive_sh4re::permissions::Capability; let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() @@ -723,7 +723,7 @@ impl Coordinator { /// rebuild-queue worker after a `PermChange` / `ToolGroups` entry /// commits the JSON file, so the P3RM1SS10NS tab updates live. pub fn emit_tool_groups_snapshot(self: &Arc) { - use hive_sh4re::ToolGroup; + use hive_sh4re::permissions::ToolGroup; let groups = ToolGroup::ALL.iter().map(|g| g.as_str()).collect(); let descriptions = ToolGroup::ALL .iter() diff --git a/hive-c0re/src/dashboard/permissions.rs b/hive-c0re/src/dashboard/permissions.rs index d542fc8c..0ac5dc25 100644 --- a/hive-c0re/src/dashboard/permissions.rs +++ b/hive-c0re/src/dashboard/permissions.rs @@ -48,11 +48,11 @@ pub(super) struct ToolGroupsSnapshot { pub(super) async fn get_tool_groups( State(state): State, ) -> axum::Json { - let groups = hive_sh4re::ToolGroup::ALL + let groups = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| g.as_str()) .collect(); - let descriptions = hive_sh4re::ToolGroup::ALL + let descriptions = hive_sh4re::permissions::ToolGroup::ALL .iter() .map(|g| (g.as_str(), g.description())) .collect(); @@ -80,7 +80,7 @@ pub(super) async fn get_tool_groups( /// the container actually runs with. #[must_use] pub(crate) fn tool_group_default_names() -> Vec<&'static str> { - hive_sh4re::ToolGroup::AGENT_DEFAULT + hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT .iter() .map(|g| g.as_str()) .collect() @@ -204,7 +204,7 @@ pub(super) struct CapabilitiesSnapshot { pub(super) async fn get_capabilities( State(state): State, ) -> axum::Json { - use hive_sh4re::Capability; + use hive_sh4re::permissions::Capability; let caps = Capability::ALL.iter().map(|c| c.as_str()).collect(); let descriptions = Capability::ALL .iter() @@ -256,7 +256,7 @@ pub(super) async fn post_capabilities( if let Some(reject) = guard_agent_name(&state, &logical).await { return Ok(reject); } - let known: Vec<&str> = hive_sh4re::Capability::ALL + let known: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); @@ -334,7 +334,7 @@ pub(super) async fn post_permissions( State(state): State, axum::Json(body): axum::Json, ) -> Result { - let known_caps: Vec<&str> = hive_sh4re::Capability::ALL + let known_caps: Vec<&str> = hive_sh4re::permissions::Capability::ALL .iter() .map(|c| c.as_str()) .collect(); diff --git a/hive-c0re/src/socket_server/lifecycle_handlers.rs b/hive-c0re/src/socket_server/lifecycle_handlers.rs index 87e05681..c1421edf 100644 --- a/hive-c0re/src/socket_server/lifecycle_handlers.rs +++ b/hive-c0re/src/socket_server/lifecycle_handlers.rs @@ -75,7 +75,7 @@ async fn handle_restart_infra( coord.emit_audit_entry(entry); } }; - if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::InfraAdmin) { + if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::InfraAdmin) { tracing::warn!(%agent, %name, "agent: infra restart denied (no infra_admin capability)"); audit( crate::audit_log::AuditOutcome::Err, diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 83a6d6fb..a166cfc2 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -757,7 +757,10 @@ fn require_new_child(agent: &str, target: &str, action: &str) -> Option, agent: &str, target: Option<&str>) -> Response { let result = if target == Some("*") { - if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::QueryAgentState) { + if !crate::capabilities::has_cap( + agent, + hive_sh4re::permissions::Capability::QueryAgentState, + ) { return Response::Err { message: "query_agent_state capability required for hive-wide loose ends" .to_owned(), @@ -801,7 +804,10 @@ fn resolve_agent_state_target<'a>( if crate::topology::is_descendant_of(name, caller) { return Ok(name); } - if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { + if crate::capabilities::has_cap( + caller, + hive_sh4re::permissions::Capability::QueryAgentState, + ) { Ok(name) } else { Err(format!( @@ -842,7 +848,7 @@ pub async fn dispatch_host_journal(agent: &str, args: HostJournalArgs<'_>) -> Re since, until, } = args; - if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::ReadHostJournal) { + if !crate::capabilities::has_cap(agent, hive_sh4re::permissions::Capability::ReadHostJournal) { return Response::Err { message: "agent does not have the read_host_journal capability".to_owned(), }; diff --git a/hive-c0re/src/workers/auto_update.rs b/hive-c0re/src/workers/auto_update.rs index 0084b102..6c4c5621 100644 --- a/hive-c0re/src/workers/auto_update.rs +++ b/hive-c0re/src/workers/auto_update.rs @@ -171,7 +171,7 @@ fn seed_manager_tool_groups() { tracing::debug!("manager tool groups already set — leaving as-is"); return; } - let all_groups: Vec = hive_sh4re::ToolGroup::MANAGER_DEFAULT + let all_groups: Vec = hive_sh4re::permissions::ToolGroup::MANAGER_DEFAULT .iter() .map(|g| g.as_str().to_owned()) .collect(); diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 48984a0c..b076e00c 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -9,6 +9,7 @@ pub mod assets; pub mod bash_task; pub mod manager; pub mod paths; +pub mod permissions; pub mod wire_time; /// Server-side hard cap on `Recv.max` (see the `Recv` request). Bounds @@ -290,191 +291,6 @@ pub struct MatrixIdentity { pub homeserver: String, } -/// 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/conventions.md::Tool groups`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolGroup { - /// `send`, `recv`, `ask`, `answer` - 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_init_config`, `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, - /// `run`, `status` (via `mcp__bash__*`) - 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 { - /// 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", "ask", "answer"], - 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_init_config", "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"], - Self::Execution => &["run", "status"], - 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, - ]; - - /// The `snake_case` wire name for this group (matches `serde(rename_all = - /// "snake_case")` serialisation). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Messaging => "messaging", - Self::Meta => "meta", - Self::Inbox => "inbox", - Self::Lifecycle => "lifecycle", - Self::Approvals => "approvals", - Self::Scheduling => "scheduling", - Self::Diagnostics => "diagnostics", - Self::Forge => "forge", - Self::Execution => "execution", - Self::WebTools => "web_tools", - } - } - - /// 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, ask, answer — 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_init_config, request_update_meta_inputs — config change flow (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 that hive-c0re enforces @@ -514,83 +330,6 @@ impl JournalPriority { } } -/// 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/conventions.md::Capabilities`. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_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 non-child agents via `GetLooseEnds`, - /// `CountPendingReminders`, and `ReminderRollup` on the agent - /// socket. Without this capability, targeting a non-child agent is - /// rejected with an error (direct children 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, - /// Agent can restart hive infrastructure containers (hive-ci, - /// hive-gateway, hive-forge) via the `restart` MCP tool. hive-c0re - /// checks this capability before routing the restart through - /// hive-priv; the concrete service allowlist lives root-side in - /// hive-priv. Deliberately generic ("infra admin") so future - /// privileged infra ops can hang off the same grant. - InfraAdmin, -} - -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, - Self::InfraAdmin, - ]; - - /// Canonical `snake_case` name for this capability (matches serde). - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::ManageRootAgent => "manage_root_agent", - Self::ReadHostJournal => "read_host_journal", - Self::QueryAgentState => "query_agent_state", - Self::InfraAdmin => "infra_admin", - } - } - - /// 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" - } - Self::InfraAdmin => { - "restart hive infrastructure containers (hive-ci, hive-gateway, hive-forge) via the restart tool" - } - } - } -} - /// Schedule row shape on the wire — mirror of /// `scheduled_prompts::Schedule` but in the public crate so the /// dashboard and agent surfaces can deserialize without depending diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs new file mode 100644 index 00000000..61d67cd3 --- /dev/null +++ b/hive-sh4re/src/permissions.rs @@ -0,0 +1,272 @@ +//! 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. + +use serde::{Deserialize, Serialize}; + +/// 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/conventions.md::Tool groups`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolGroup { + /// `send`, `recv`, `ask`, `answer` + 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_init_config`, `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, + /// `run`, `status` (via `mcp__bash__*`) + 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 { + /// 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", "ask", "answer"], + 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_init_config", "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"], + Self::Execution => &["run", "status"], + 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, + ]; + + /// The `snake_case` wire name for this group (matches `serde(rename_all = + /// "snake_case")` serialisation). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Messaging => "messaging", + Self::Meta => "meta", + Self::Inbox => "inbox", + Self::Lifecycle => "lifecycle", + Self::Approvals => "approvals", + Self::Scheduling => "scheduling", + Self::Diagnostics => "diagnostics", + Self::Forge => "forge", + Self::Execution => "execution", + Self::WebTools => "web_tools", + } + } + + /// 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, ask, answer — 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_init_config, request_update_meta_inputs — config change flow (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/conventions.md::Capabilities`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_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 non-child agents via `GetLooseEnds`, + /// `CountPendingReminders`, and `ReminderRollup` on the agent + /// socket. Without this capability, targeting a non-child agent is + /// rejected with an error (direct children 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, + /// Agent can restart hive infrastructure containers (hive-ci, + /// hive-gateway, hive-forge) via the `restart` MCP tool. hive-c0re + /// checks this capability before routing the restart through + /// hive-priv; the concrete service allowlist lives root-side in + /// hive-priv. Deliberately generic ("infra admin") so future + /// privileged infra ops can hang off the same grant. + InfraAdmin, +} + +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, + Self::InfraAdmin, + ]; + + /// Canonical `snake_case` name for this capability (matches serde). + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ManageRootAgent => "manage_root_agent", + Self::ReadHostJournal => "read_host_journal", + Self::QueryAgentState => "query_agent_state", + Self::InfraAdmin => "infra_admin", + } + } + + /// 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" + } + Self::InfraAdmin => { + "restart hive infrastructure containers (hive-ci, hive-gateway, hive-forge) via the restart tool" + } + } + } +}