From 90af3e8d0b32aec57a398f643044eae3b95f3c2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Sun, 5 Jul 2026 22:24:46 +0200 Subject: [PATCH] refactor(mcp): split claude launch-config layer into mcp_config.rs --- CLAUDE.md | 7 +- docs/conventions.md | 3 +- docs/turn-loop.md | 2 +- hive-ag3nt/src/lib.rs | 1 + hive-ag3nt/src/mcp.rs | 446 +---------------------------------- hive-ag3nt/src/mcp_config.rs | 439 ++++++++++++++++++++++++++++++++++ hive-ag3nt/src/turn.rs | 12 +- 7 files changed, 466 insertions(+), 444 deletions(-) create mode 100644 hive-ag3nt/src/mcp_config.rs diff --git a/CLAUDE.md b/CLAUDE.md index 1a8b0df0..ddb1d70c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,9 +34,10 @@ hand-maintained per-file tree drifts out of sync with the code. operator dashboard (`dashboard.rs`). Largest crate. - **`hive-ag3nt/`** — in-container harness; one `hive` binary for every agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude` - driver, embedded MCP server (`mcp.rs`), per-agent web UI (`web_ui/` - module dir), event + turn-stats sqlite sinks, login flow, system-prompt - renderer, forge-notify subscriber. + driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer + (`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config` + render), per-agent web UI (`web_ui/` module dir), event + turn-stats + sqlite sinks, login flow, system-prompt renderer, forge-notify subscriber. - **`hive-claude/`** — reusable, app-agnostic driver for headless `claude --print`: spawns the CLI, streams + classifies stream-json, parses per-turn `Telemetry`, and drives a durable self-compacting diff --git a/docs/conventions.md b/docs/conventions.md index a927ec52..2c43c4af 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -360,7 +360,8 @@ the var is absent or empty. **Updating the surface** — when a new `#[tool]` fn is added to `HiveServer` in `hive-ag3nt/src/mcp.rs`, add its name to the matching `ToolGroup::tools()` slice in `hive-sh4re/src/lib.rs`. That's the single source of truth; -`allowed_mcp_tools` reads it at session start. +`mcp_config::allowed_mcp_tools` (in `hive-ag3nt/src/mcp_config.rs`) reads it at +session start. ## Capabilities diff --git a/docs/turn-loop.md b/docs/turn-loop.md index 54814b42..2f940e0e 100644 --- a/docs/turn-loop.md +++ b/docs/turn-loop.md @@ -743,7 +743,7 @@ than deriving from SSE events. runs the body, logs the result. Pre-/post-log only — the inbox status hint moved to the wake prompt + UI header. -### Tool whitelist (`mcp::ALLOWED_BUILTIN_TOOLS`) +### Tool whitelist (`mcp_config::ALLOWED_BUILTIN_TOOLS`) - Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`. - Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the diff --git a/hive-ag3nt/src/lib.rs b/hive-ag3nt/src/lib.rs index 56a62a29..4b861eed 100644 --- a/hive-ag3nt/src/lib.rs +++ b/hive-ag3nt/src/lib.rs @@ -9,6 +9,7 @@ pub mod identity; pub mod login; pub mod login_session; pub mod mcp; +pub mod mcp_config; pub mod mcp_loose_ends; pub mod paths; pub mod plugins; diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 7604bca6..35b2252e 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -626,7 +626,7 @@ impl AgentServer { let to = args.to.clone(); // Check per-agent allow-list (hyperhive.allowedRecipients). When no // policy file is present (e.g. manager containers) the check is a no-op. - if let Err(refusal) = check_send_allowed(&to) { + if let Err(refusal) = crate::mcp_config::check_send_allowed(&to) { return run_tool_envelope("send", log, async move { refusal }).await; } run_tool_envelope("send", log, async move { @@ -1464,8 +1464,10 @@ impl AgentServer { run_tool_envelope("list_schedules", String::new(), async move { let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await; let body = match resp { - Ok(hive_sh4re::Response::Schedules { schedules }) => serde_json::to_string(&schedules) - .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")), + Ok(hive_sh4re::Response::Schedules { schedules }) => { + serde_json::to_string(&schedules) + .unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")) + } other => reply_err(other, "list_schedules"), }; annotate_retries(body, retries) @@ -1808,448 +1810,26 @@ pub struct GetHostJournalArgs { #[serde(default)] pub until: Option, } - -/// 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"; - -/// 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`) 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. -pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "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"`). -/// 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. -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-gateway / hive-forge) 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::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::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::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 -/// (single source of truth). See `docs/conventions.md::Tool groups`. -#[must_use] -pub fn allowed_mcp_tools(groups: &[hive_sh4re::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 - .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-base.nix`). 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"; - -/// Where the NixOS module writes the per-agent send allow-list (see -/// `nix/templates/harness-base.nix`). Empty list = unrestricted (the -/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` -/// field; the manager is always implicitly permitted regardless of -/// the list contents. -const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; - -/// Enforce the per-agent send allow-list. Returns `Ok` when the -/// recipient is permitted (no list configured, `` sentinel -/// always allowed, or `to` is in the list); returns `Err(refusal)` -/// with a claude-readable string when blocked ��� the harness surfaces -/// the refusal as the tool result so claude knows the message didn't -/// land and can react (e.g. route via `` instead). -fn check_send_allowed(to: &str) -> Result<(), String> { - if to == hive_sh4re::PARENT_RECIPIENT { - // Always allow `` — the allow-list constrains peer - // chatter, not the structural reporting line; the operator - // can rewire who the parent IS via `set_parent` without - // having to remember to update the per-agent allow-list. - // The broker resolves the sentinel to the real parent label - // on the host side per topology.json (falls back to `operator` - // for root agents). - return Ok(()); - } - let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { - return Ok(()); // file missing → no policy configured → unrestricted - }; - let allow: Vec = match serde_json::from_str(&raw) { - Ok(v) => v, - Err(e) => { - tracing::warn!( - path = SEND_ALLOW_PATH, - error = ?e, - "send allow-list parse failed; falling back to unrestricted", - ); - return Ok(()); - } - }; - if allow.is_empty() { - return Ok(()); // empty list = unrestricted (back-compat) - } - if allow.iter().any(|n| n == to) { - return Ok(()); - } - Err(format!( - "send refused: recipient '{to}' not in hyperhive.allowedRecipients \ - (configured in agent.nix). Allowed: {allow:?}. Your structural \ - parent is always reachable — route through `send(to: \"{}\", …)` \ - if you need to reach someone outside the allow-list.", - hive_sh4re::PARENT_RECIPIENT - )) -} - -#[derive(Debug, serde::Deserialize)] -struct ExtraMcpServer { - command: String, - #[serde(default)] - args: Vec, - #[serde(default)] - env: std::collections::BTreeMap, - #[serde(default = "default_allowed_tools")] - #[serde(rename = "allowedTools")] - allowed_tools: Vec, -} - -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 `. -/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` -/// executable; `socket` is the hyperhive per-agent socket bind-mounted into -/// the container (forwarded to the child as `--socket `). Merges in -/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the -/// agent's NixOS config. -#[must_use] -pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { - let mut servers = serde_json::Map::new(); - // When the harness is configured to run the built-in server as a - // persistent streamable-http daemon (loopback port in - // `HYPERHIVE_MCP_HTTP_PORT`), point claude at the stable URL instead of - // 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. Extra servers (matrix/bash) stay stdio bridges. - let hyperhive_entry = match std::env::var("HYPERHIVE_MCP_HTTP_PORT") - .ok() - .and_then(|p| p.trim().parse::().ok()) - { - Some(port) => serde_json::json!({ - "type": "http", - "url": format!("http://127.0.0.1:{port}/mcp"), - }), - None => serde_json::json!({ - "command": agent_binary, - "args": ["--socket", socket.display().to_string(), "mcp"], - "env": {} - }), - }; - servers.insert(SERVER_NAME.to_owned(), hyperhive_entry); - // Auto-inject HYPERHIVE_STATE_DIR so extra 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. - 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!( - "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; - } - spec.env - .entry("HYPERHIVE_STATE_DIR".to_owned()) - .or_insert_with(|| state_dir.display().to_string()); - servers.insert( - name, - serde_json::json!({ - "command": spec.command, - "args": spec.args, - "env": spec.env, - }), - ); - } - let config = serde_json::json!({ "mcpServers": servers }); - serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) -} - #[cfg(test)] mod tests { use super::{IDLE_WAIT_HINT, format_recv}; - use super::{SERVER_NAME, allowed_mcp_tools}; - use hive_sh4re::ToolGroup; #[test] fn empty_recv_after_wait_appends_idle_hint() { - let out = format_recv(Ok(hive_sh4re::Response::Messages { messages: vec![] }), true); + let out = format_recv( + Ok(hive_sh4re::Response::Messages { messages: vec![] }), + true, + ); assert!(out.starts_with("(empty)")); assert!(out.contains(IDLE_WAIT_HINT)); } #[test] fn empty_recv_without_wait_has_no_hint() { - let out = format_recv(Ok(hive_sh4re::Response::Messages { messages: vec![] }), false); + let out = format_recv( + Ok(hive_sh4re::Response::Messages { messages: vec![] }), + false, + ); assert_eq!(out, "(empty)"); } - - 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 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"))); - } } diff --git a/hive-ag3nt/src/mcp_config.rs b/hive-ag3nt/src/mcp_config.rs new file mode 100644 index 00000000..8bd13af0 --- /dev/null +++ b/hive-ag3nt/src/mcp_config.rs @@ -0,0 +1,439 @@ +//! 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 ([`crate::mcp`]). The `send` allow-list check +//! ([`check_send_allowed`]) lives here too since it's driven by the same +//! `/etc/hyperhive/*.json` operator config. + +/// 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"; + +/// 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`) 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. +pub const ALLOWED_BUILTIN_TOOLS: &[&str] = &["Edit", "Glob", "Grep", "Read", "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"`). +/// 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. +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-gateway / hive-forge) 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::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::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::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 +/// (single source of truth). See `docs/conventions.md::Tool groups`. +#[must_use] +pub fn allowed_mcp_tools(groups: &[hive_sh4re::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 + .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-base.nix`). 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"; + +/// Where the NixOS module writes the per-agent send allow-list (see +/// `nix/templates/harness-base.nix`). Empty list = unrestricted (the +/// default). Non-empty list constrains `mcp__hyperhive__send`'s `to` +/// field; the manager is always implicitly permitted regardless of +/// the list contents. +const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json"; + +/// Enforce the per-agent send allow-list. Returns `Ok` when the +/// recipient is permitted (no list configured, `` sentinel +/// always allowed, or `to` is in the list); returns `Err(refusal)` +/// with a claude-readable string when blocked ��� the harness surfaces +/// the refusal as the tool result so claude knows the message didn't +/// land and can react (e.g. route via `` instead). +pub fn check_send_allowed(to: &str) -> Result<(), String> { + if to == hive_sh4re::PARENT_RECIPIENT { + // Always allow `` — the allow-list constrains peer + // chatter, not the structural reporting line; the operator + // can rewire who the parent IS via `set_parent` without + // having to remember to update the per-agent allow-list. + // The broker resolves the sentinel to the real parent label + // on the host side per topology.json (falls back to `operator` + // for root agents). + return Ok(()); + } + let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else { + return Ok(()); // file missing → no policy configured → unrestricted + }; + let allow: Vec = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + tracing::warn!( + path = SEND_ALLOW_PATH, + error = ?e, + "send allow-list parse failed; falling back to unrestricted", + ); + return Ok(()); + } + }; + if allow.is_empty() { + return Ok(()); // empty list = unrestricted (back-compat) + } + if allow.iter().any(|n| n == to) { + return Ok(()); + } + Err(format!( + "send refused: recipient '{to}' not in hyperhive.allowedRecipients \ + (configured in agent.nix). Allowed: {allow:?}. Your structural \ + parent is always reachable — route through `send(to: \"{}\", …)` \ + if you need to reach someone outside the allow-list.", + hive_sh4re::PARENT_RECIPIENT + )) +} + +#[derive(Debug, serde::Deserialize)] +struct ExtraMcpServer { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: std::collections::BTreeMap, + #[serde(default = "default_allowed_tools")] + #[serde(rename = "allowedTools")] + allowed_tools: Vec, +} + +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 `. +/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt` +/// executable; `socket` is the hyperhive per-agent socket bind-mounted into +/// the container (forwarded to the child as `--socket `). Merges in +/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the +/// agent's NixOS config. +#[must_use] +pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String { + let mut servers = serde_json::Map::new(); + // When the harness is configured to run the built-in server as a + // persistent streamable-http daemon (loopback port in + // `HYPERHIVE_MCP_HTTP_PORT`), point claude at the stable URL instead of + // 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. Extra servers (matrix/bash) stay stdio bridges. + let hyperhive_entry = match std::env::var("HYPERHIVE_MCP_HTTP_PORT") + .ok() + .and_then(|p| p.trim().parse::().ok()) + { + Some(port) => serde_json::json!({ + "type": "http", + "url": format!("http://127.0.0.1:{port}/mcp"), + }), + None => serde_json::json!({ + "command": agent_binary, + "args": ["--socket", socket.display().to_string(), "mcp"], + "env": {} + }), + }; + servers.insert(SERVER_NAME.to_owned(), hyperhive_entry); + // Auto-inject HYPERHIVE_STATE_DIR so extra 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. + 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!( + "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; + } + spec.env + .entry("HYPERHIVE_STATE_DIR".to_owned()) + .or_insert_with(|| state_dir.display().to_string()); + servers.insert( + name, + serde_json::json!({ + "command": spec.command, + "args": spec.args, + "env": spec.env, + }), + ); + } + let config = serde_json::json!({ "mcpServers": servers }); + serde_json::to_string_pretty(&config).unwrap_or_else(|_| "{}".into()) +} + +#[cfg(test)] +mod tests { + use super::{SERVER_NAME, allowed_mcp_tools}; + use hive_sh4re::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 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"))); + } +} diff --git a/hive-ag3nt/src/turn.rs b/hive-ag3nt/src/turn.rs index 9d9384cd..ea03bdb4 100644 --- a/hive-ag3nt/src/turn.rs +++ b/hive-ag3nt/src/turn.rs @@ -12,7 +12,7 @@ use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink}; use serde_json::Value; use crate::events::{Bus, LiveEvent}; -use crate::mcp; +use crate::mcp_config; // Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json` // (wired in `nix/templates/harness-base.nix` from the `prompts/claude-settings.json` @@ -121,7 +121,7 @@ pub async fn write_mcp_config(socket: &Path) -> Result { let exe = std::env::current_exe() .ok() .map_or_else(|| "hive".into(), |p| p.display().to_string()); - let body = mcp::render_claude_config(&exe, socket); + let body = mcp_config::render_claude_config(&exe, socket); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude MCP config"); Ok(path) @@ -236,7 +236,8 @@ fn compact_percent() -> u8 { if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) { return 0; } - let pct = env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); + let pct = + env_u64("HIVE_COMPACT_WATERMARK_PERCENT").unwrap_or(u64::from(DEFAULT_COMPACT_PERCENT)); u8::try_from(pct.min(100)).unwrap_or(DEFAULT_COMPACT_PERCENT) } @@ -515,8 +516,8 @@ fn claude_config(bus: &Bus, files: &TurnFiles) -> Config { system_prompt_file: Some(files.system_prompt.clone()), mcp_config: Some(files.mcp_config.clone()), strict_mcp_config: true, - tools: Some(mcp::builtin_tools_arg()), - allowed_tools: Some(mcp::allowed_tools_arg()), + tools: Some(mcp_config::builtin_tools_arg()), + allowed_tools: Some(mcp_config::allowed_tools_arg()), add_dirs, ..Config::default() } @@ -631,4 +632,3 @@ fn archive_session(bus: &Bus) { } } } -