//! Claude launch-config layer: resolves the agent's tool-group / capability //! set into the `--allowedTools` / `--tools` argument strings and renders the //! `--mcp-config` blob claude reads at spawn (built-in hyperhive server + //! any `hyperhive.extraMcpServers`). Pure config-string generation consumed by //! [`crate::turn`] when it builds the claude command. It never touches the //! running MCP server (a separate binary) — the `send` allow-list check that //! server enforces lives alongside it in the `hive-agent-mcp` crate. /// Name of the hyperhive MCP server inside claude's view. Claude prefixes /// tools as `mcp____` (e.g. `mcp__hyperhive__send`). pub const SERVER_NAME: &str = "hyperhive"; /// Default loopback port the built-in hyperhive MCP surface is served on /// (streamable HTTP, via the persistent `hive-mcp-http` daemon). Overridable /// via `hyperhive.mcp.httpPort`; **must match that option's default** in /// `nix/templates/harness/`. Safe as a single fixed value across all /// agents because each container runs in its own private network namespace, /// so `127.0.0.1:` is per-container-private (no cross-agent collision). pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790; /// Built-in claude tools always present in every session. Anything not /// in this list (or added by `extra_builtin_tools`) literally doesn't /// exist in the session. Web egress (`WebFetch`/`WebSearch`) are /// tool-group-gated (`web_tools`) — 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"]; /// Env var written by the meta renderer with a comma-separated list of /// `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::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 /// unlocked by the agent's current capability set. These are added to the /// `--allowedTools` list so claude can call them without prompting, and /// hive-c0re performs a second server-side capability check before executing. fn allowed_capability_tools() -> Vec { let raw = match std::env::var(CAPABILITIES_ENV) { Ok(v) if !v.trim().is_empty() => v, _ => return vec![], }; let mut tools = Vec::new(); for token in raw.split(',') { let t = token.trim().to_ascii_lowercase(); match t.as_str() { "read_host_journal" => tools.push("get_host_journal".to_owned()), // infra_admin lets an agent restart hive infrastructure // containers (hive-ci / hive-forge / hive-matrix — not the // gateway) through the existing `restart` tool. Unlock it here so agents that hold // the capability without the full `lifecycle` group can still // call it; c0re re-checks the capability server-side and only // honours infra-container names via this path. "infra_admin" => tools.push("restart".to_owned()), // manage_root_agent / query_agent_state don't expose new MCP // tools: manage_root_agent gates existing lifecycle tools via // topology enforcement; query_agent_state unlocks the `agent` // field in get_loose_ends / count_pending_reminders / // reminder_rollup (c0re enforces the cap server-side). "manage_root_agent" | "query_agent_state" => {} unknown => { tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped"); } } } tools } /// Resolve the active tool groups for a harness session. /// /// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated /// token is matched (case-insensitive) against the `ToolGroup` serde names /// (`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 { let raw = match std::env::var(TOOL_GROUPS_ENV) { Ok(v) if !v.trim().is_empty() => v, _ => 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()), ) { groups.push(g); } else { tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping"); } } if groups.is_empty() { tracing::warn!( "{TOOL_GROUPS_ENV} set but contained no recognised groups; \ falling back to AGENT_DEFAULT" ); return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_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::permissions::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::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::permissions::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 /// (single source of truth). See `docs/conventions.md::Tool groups`. #[must_use] 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::permissions::ToolGroup::ALWAYS_ON_TOOLS .iter() .copied() .chain(groups.iter().flat_map(|g| g.tools().iter().copied())) .filter(|t| seen.insert(*t)) .map(|t| format!("mcp__{SERVER_NAME}__{t}")) .collect(); // Extra MCP servers declared via `hyperhive.extraMcpServers` in // the agent's NixOS config. Each entry maps its `allowedTools` // pattern list to `mcp____` so claude can call // 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 || !extra_server_enabled(&server, groups) { continue; } for pat in spec.allowed_tools() { out.push(format!("mcp__{server}__{pat}")); } } out } /// Combined allow-list passed to `--allowedTools` (auto-approve) — covers /// both the built-ins and the MCP surface. #[must_use] pub fn allowed_tools_arg() -> String { let groups = effective_tool_groups(); // Base built-ins always present. let mut all: Vec = ALLOWED_BUILTIN_TOOLS .iter() .map(|s| (*s).to_owned()) .collect(); // Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools). for group in &groups { for tool in group.builtin_tools() { if !all.iter().any(|t| t == *tool) { all.push((*tool).to_owned()); } } } all.extend(allowed_mcp_tools(&groups)); // Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES // includes the corresponding capability. hive-c0re performs a second // server-side check, so this is a usability gate (no annoying prompts), // not the security boundary. for tool in allowed_capability_tools() { all.push(format!("mcp__{SERVER_NAME}__{tool}")); } all.join(",") } /// Built-in tools list for `--tools` (which built-ins exist in this /// session). Base set plus any group-gated built-ins (e.g. /// `WebFetch`/`WebSearch` when the `web_tools` group is active). #[must_use] pub fn builtin_tools_arg() -> String { let groups = effective_tool_groups(); let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec(); for group in &groups { for t in group.builtin_tools() { if !tools.contains(t) { tools.push(t); } } } tools.join(",") } /// Where the NixOS module writes the per-agent extra-MCP spec (see /// `nix/templates/harness/`). Each entry becomes an additional /// `mcpServers.` block in the rendered claude config + a /// `mcp____` pattern in `--allowedTools`. const EXTRA_MCP_PATH: &str = "/etc/hyperhive/extra-mcp.json"; /// An extra MCP server declared via `hyperhive.extraMcpServers`. Two /// transports: `Stdio` (claude spawns `command` fresh each turn, talks /// JSON-RPC over its stdin/stdout) and `Http` (claude points at a /// long-lived streamable-http `url` instead — no per-turn spawn, no /// re-registration race, same shape as the built-in hyperhive surface). /// Internally tagged on the nix-rendered `type` field; unrecognised /// fields for the inactive variant (e.g. `command` on an `Http` entry) /// are ignored by serde's default struct deserialization. #[derive(Debug, serde::Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] enum ExtraMcpServer { Stdio { command: String, #[serde(default)] args: Vec, #[serde(default)] env: std::collections::BTreeMap, #[serde(default = "default_allowed_tools")] #[serde(rename = "allowedTools")] allowed_tools: Vec, }, Http { url: String, #[serde(default = "default_allowed_tools")] #[serde(rename = "allowedTools")] allowed_tools: Vec, }, } impl ExtraMcpServer { fn allowed_tools(&self) -> &[String] { match self { Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools, } } } fn default_allowed_tools() -> Vec { vec!["*".to_owned()] } /// Read + parse the extra-MCP spec. Returns an empty map when /// the file is missing or unparsable (the agent has none configured, /// or the file is malformed — both cases degrade to "no extra servers"). fn load_extra_mcp() -> std::collections::BTreeMap { let Ok(raw) = std::fs::read_to_string(EXTRA_MCP_PATH) else { return std::collections::BTreeMap::new(); }; serde_json::from_str(&raw).unwrap_or_else(|e| { tracing::warn!( path = EXTRA_MCP_PATH, error = ?e, "extra-mcp spec parse failed; ignoring", ); std::collections::BTreeMap::new() }) } /// Render the MCP config blob claude reads from `--mcp-config `. /// The built-in `hyperhive` surface is an HTTP entry pointing at the /// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there /// is no per-turn stdio child for it. Merges in any extra MCP servers /// declared via `hyperhive.extraMcpServers` — each one is either a /// per-turn stdio bridge or another persistent HTTP entry, per its own /// `type`. #[must_use] pub fn render_claude_config() -> String { let config = serde_json::json!({ "mcpServers": build_mcp_servers() }); serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) } /// The set of MCP server names claude is configured with this turn — the /// keys of the rendered `--mcp-config` (built-in hyperhive HTTP surface + /// any tool-group-permitted extra servers). The harness compares /// this against the per-turn `system`/`init` event's `mcp_servers` to /// detect a configured server that failed to connect or was dropped /// (the MCP-health instrumentation). #[must_use] pub fn configured_server_names() -> Vec { build_mcp_servers().into_iter().map(|(k, _)| k).collect() } /// Build the `mcpServers` map claude gets in its `--mcp-config`: the /// built-in hyperhive HTTP surface plus any tool-group-permitted extra /// servers (stdio or http, per each entry's `type`). Shared by /// [`render_claude_config`] (serialises it) and [`configured_server_names`] /// (lists its keys) so the two never drift. fn build_mcp_servers() -> serde_json::Map { let mut servers = serde_json::Map::new(); // The built-in hyperhive surface is served exclusively over streamable // HTTP by the persistent `hive-mcp-http` daemon (loopback, inside the // agent's private network namespace). Point claude at the stable URL // rather than respawning a fresh stdio child each turn: the URL survives // the per-turn claude re-spawn, so there is no per-turn re-registration // race for the hyperhive surface. The port comes from // `HYPERHIVE_MCP_HTTP_PORT` (always set by the harness); // `DEFAULT_MCP_HTTP_PORT` is the fallback matching the nix default. let port = std::env::var("HYPERHIVE_MCP_HTTP_PORT") .ok() .and_then(|p| p.trim().parse::().ok()) .unwrap_or(DEFAULT_MCP_HTTP_PORT); let hyperhive_entry = serde_json::json!({ "type": "http", "url": format!("http://127.0.0.1:{port}/mcp"), }); servers.insert(SERVER_NAME.to_owned(), hyperhive_entry); // Auto-inject HYPERHIVE_STATE_DIR so extra stdio MCP servers can resolve // the agent's durable state dir without the agent author hard-coding it. // User-supplied env takes precedence — we only fill in the missing key. // (Http entries have no child-process env to inject into — the daemon // behind the URL resolves its own state dir independently.) 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, spec) in load_extra_mcp() { if name == SERVER_NAME { tracing::warn!( "extra MCP server name `{SERVER_NAME}` collides with the built-in surface; ignoring", ); continue; } if !extra_server_enabled(&name, &groups) { tracing::info!( server = %name, "extra MCP server suppressed: agent lacks the required tool group" ); continue; } let entry = match spec { ExtraMcpServer::Stdio { command, args, mut env, .. } => { env.entry("HYPERHIVE_STATE_DIR".to_owned()) .or_insert_with(|| state_dir.display().to_string()); serde_json::json!({ "command": command, "args": args, "env": env }) } ExtraMcpServer::Http { url, .. } => serde_json::json!({ "type": "http", "url": url }), }; servers.insert(name, entry); } servers } #[cfg(test)] mod tests { use super::{SERVER_NAME, allowed_mcp_tools}; use hive_sh4re::permissions::ToolGroup; fn qualified(tool: &str) -> String { format!("mcp__{SERVER_NAME}__{tool}") } #[test] fn set_status_present_with_no_groups() { // An agent with zero tool groups (or any group set that omits // `meta`) must still be able to report its dashboard status. let tools = allowed_mcp_tools(&[]); assert!( tools.contains(&qualified("set_status")), "set_status missing from empty-group allow-list: {tools:?}" ); } #[test] fn mark_todos_done_present_with_no_groups() { // Regression: mark_todos_done was declared as a // tool but never added to any ToolGroup, so no agent could ever // get it into --allowedTools regardless of which groups it held — // including `inbox`, which only gates get_loose_ends/ // cancel_loose_end/remind. let tools = allowed_mcp_tools(&[]); assert!( tools.contains(&qualified("mark_todos_done")), "mark_todos_done missing from empty-group allow-list: {tools:?}" ); let tools = allowed_mcp_tools(&[ToolGroup::Inbox]); assert!(tools.contains(&qualified("mark_todos_done"))); } #[test] fn set_status_present_without_meta_group() { let tools = allowed_mcp_tools(&[ToolGroup::Messaging, ToolGroup::Inbox]); assert!(tools.contains(&qualified("set_status"))); // get_agent_meta stays gated behind `meta` — only set_status is always-on. assert!(!tools.contains(&qualified("get_agent_meta"))); } #[test] fn no_duplicate_set_status_when_meta_granted() { let tools = allowed_mcp_tools(&[ToolGroup::Meta]); let count = tools .iter() .filter(|t| **t == qualified("set_status")) .count(); assert_eq!(count, 1, "set_status duplicated: {tools:?}"); assert!(tools.contains(&qualified("get_agent_meta"))); } }