`nix/templates/harness/` is cited from `send_allow.rs` and twice from `mcp_config.rs`, in the present tense, as where the NixOS module writes the send allow-list, writes the extra-MCP spec, and defines the MCP http port default. There is no such directory — `nix/templates/` holds only `agent.nix` and `ruth.nix`. All three describe things that live in `nix/agent-modules/mcp.nix`: send-allow.json is written at :230, extra-mcp.json at :228, and the `hyperhive.mcp.httpPort` option is declared at :143. These are the mechanical half of #3923, which explicitly carved the repointing out from the question of whether the reference gate should widen. Nothing here changes that question.
453 lines
20 KiB
Rust
453 lines
20 KiB
Rust
//! 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__<this>__<tool>` (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/agent-modules/mcp.nix`. Safe as a single fixed value across all
|
|
/// agents because each container runs in its own private network namespace,
|
|
/// so `127.0.0.1:<port>` 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/process/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<String> {
|
|
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()),
|
|
// 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<hive_sh4re::permissions::ToolGroup> {
|
|
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::<hive_sh4re::permissions::ToolGroup>(
|
|
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<hive_sh4re::permissions::ToolGroup> {
|
|
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/process/conventions.md::Tool groups`.
|
|
#[must_use]
|
|
pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<String> {
|
|
// 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<String> = 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__<server>__<pattern>` so claude can call
|
|
// them without per-tool operator approval. `["*"]` (the default)
|
|
// expands to `mcp__<server>__*` — 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<String> = 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/agent-modules/mcp.nix`). Each entry becomes an additional
|
|
/// `mcpServers.<key>` block in the rendered claude config + a
|
|
/// `mcp__<key>__<tool>` 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<String>,
|
|
#[serde(default)]
|
|
env: std::collections::BTreeMap<String, String>,
|
|
#[serde(default = "default_allowed_tools")]
|
|
#[serde(rename = "allowedTools")]
|
|
allowed_tools: Vec<String>,
|
|
},
|
|
Http {
|
|
url: String,
|
|
#[serde(default = "default_allowed_tools")]
|
|
#[serde(rename = "allowedTools")]
|
|
allowed_tools: Vec<String>,
|
|
},
|
|
}
|
|
|
|
impl ExtraMcpServer {
|
|
fn allowed_tools(&self) -> &[String] {
|
|
match self {
|
|
Self::Stdio { allowed_tools, .. } | Self::Http { allowed_tools, .. } => allowed_tools,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_allowed_tools() -> Vec<String> {
|
|
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<String, ExtraMcpServer> {
|
|
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 <path>`.
|
|
/// 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<String> {
|
|
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<String, serde_json::Value> {
|
|
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::<u16>().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")));
|
|
}
|
|
}
|