diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index c9e0b83f..b2324080 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -1840,6 +1840,60 @@ fn effective_tool_groups() -> Vec { groups } +/// Tool group an extra (out-of-process) MCP server is gated behind, if any. +/// +/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever +/// the operator declares them. The `bash` server is the exception: raw shell +/// execution is a privilege, so it is only exposed when the agent holds the +/// `Execution` tool group. Unlike the in-process hyperhive tools (gated at +/// dispatch) and the capability tools (re-checked server-side by hive-c0re), +/// 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 { + match server { + "bash" => Some(hive_sh4re::ToolGroup::Execution), + _ => None, + } +} + +/// 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 { + 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; + + #[test] + fn bash_is_gated_behind_execution() { + assert_eq!( + extra_server_required_group("bash"), + Some(ToolGroup::Execution) + ); + // Suppressed without Execution, even if other groups are present. + assert!(!extra_server_enabled( + "bash", + &[ToolGroup::Messaging, ToolGroup::Inbox] + )); + // Available once Execution is granted. + assert!(extra_server_enabled("bash", &[ToolGroup::Execution])); + } + + #[test] + fn other_servers_are_ungated() { + assert_eq!(extra_server_required_group("matrix"), None); + assert_eq!(extra_server_required_group("scraper"), None); + // An ungated server is available regardless of (even empty) groups. + assert!(extra_server_enabled("matrix", &[])); + assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging])); + } +} + /// MCP tools claude is allowed to call without prompting, derived from /// the supplied tool groups. Adding a new `#[tool]` fn to a server impl /// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re @@ -1864,7 +1918,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::ToolGroup]) -> Vec { // them without per-tool operator approval. `["*"]` (the default) // expands to `mcp____*` — every tool from that server. for (server, spec) in load_extra_mcp() { - if server == SERVER_NAME { + if server == SERVER_NAME || !extra_server_enabled(&server, groups) { continue; } for pat in spec.allowed_tools { @@ -2040,6 +2094,11 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str // agent's durable state dir without the agent author hard-coding it. // User-supplied env takes precedence — we only fill in the missing key. let state_dir = crate::paths::state_dir(); + // Gate tool-group-restricted extra servers (e.g. `bash` → `Execution`). + // This is the security boundary for them: an out-of-process server the + // agent isn't entitled to must not even appear in the MCP config, or the + // agent could call it directly (there is no later enforcement point). + let groups = effective_tool_groups(); for (name, mut spec) in load_extra_mcp() { if name == SERVER_NAME { tracing::warn!( @@ -2047,6 +2106,13 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str ); continue; } + if !extra_server_enabled(&name, &groups) { + tracing::info!( + server = %name, + "extra MCP server suppressed: agent lacks the required tool group" + ); + continue; + } spec.env .entry("HYPERHIVE_STATE_DIR".to_owned()) .or_insert_with(|| state_dir.display().to_string());