refactor(mcp): split claude launch-config layer into mcp_config.rs
This commit is contained in:
parent
e1e3bda94a
commit
90af3e8d0b
7 changed files with 466 additions and 444 deletions
|
|
@ -34,9 +34,10 @@ hand-maintained per-file tree drifts out of sync with the code.
|
||||||
operator dashboard (`dashboard.rs`). Largest crate.
|
operator dashboard (`dashboard.rs`). Largest crate.
|
||||||
- **`hive-ag3nt/`** — in-container harness; one `hive` binary for every
|
- **`hive-ag3nt/`** — in-container harness; one `hive` binary for every
|
||||||
agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
agent. Turn-loop *policy* layer (`turn.rs`) over the `hive-claude`
|
||||||
driver, embedded MCP server (`mcp.rs`), per-agent web UI (`web_ui/`
|
driver, embedded MCP server (`mcp.rs`) + its claude launch-config layer
|
||||||
module dir), event + turn-stats sqlite sinks, login flow, system-prompt
|
(`mcp_config.rs`: tool-group/capability → `--allowedTools`, `--mcp-config`
|
||||||
renderer, forge-notify subscriber.
|
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
|
- **`hive-claude/`** — reusable, app-agnostic driver for headless
|
||||||
`claude --print`: spawns the CLI, streams + classifies stream-json,
|
`claude --print`: spawns the CLI, streams + classifies stream-json,
|
||||||
parses per-turn `Telemetry`, and drives a durable self-compacting
|
parses per-turn `Telemetry`, and drives a durable self-compacting
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,8 @@ the var is absent or empty.
|
||||||
**Updating the surface** — when a new `#[tool]` fn is added to `HiveServer`
|
**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()`
|
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;
|
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
|
## Capabilities
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -743,7 +743,7 @@ than deriving from SSE events.
|
||||||
runs the body, logs the result. Pre-/post-log only — the inbox
|
runs the body, logs the result. Pre-/post-log only — the inbox
|
||||||
status hint moved to the wake prompt + UI header.
|
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`.
|
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Write`.
|
||||||
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the
|
- Tool-group-gated built-ins: `WebFetch`, `WebSearch` (added when the
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ pub mod identity;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod login_session;
|
pub mod login_session;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
|
pub mod mcp_config;
|
||||||
pub mod mcp_loose_ends;
|
pub mod mcp_loose_ends;
|
||||||
pub mod paths;
|
pub mod paths;
|
||||||
pub mod plugins;
|
pub mod plugins;
|
||||||
|
|
|
||||||
|
|
@ -626,7 +626,7 @@ impl AgentServer {
|
||||||
let to = args.to.clone();
|
let to = args.to.clone();
|
||||||
// Check per-agent allow-list (hyperhive.allowedRecipients). When no
|
// Check per-agent allow-list (hyperhive.allowedRecipients). When no
|
||||||
// policy file is present (e.g. manager containers) the check is a no-op.
|
// 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;
|
return run_tool_envelope("send", log, async move { refusal }).await;
|
||||||
}
|
}
|
||||||
run_tool_envelope("send", log, async move {
|
run_tool_envelope("send", log, async move {
|
||||||
|
|
@ -1464,8 +1464,10 @@ impl AgentServer {
|
||||||
run_tool_envelope("list_schedules", String::new(), async move {
|
run_tool_envelope("list_schedules", String::new(), async move {
|
||||||
let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await;
|
let (resp, retries) = self.dispatch(hive_sh4re::Request::ListSchedules).await;
|
||||||
let body = match resp {
|
let body = match resp {
|
||||||
Ok(hive_sh4re::Response::Schedules { schedules }) => serde_json::to_string(&schedules)
|
Ok(hive_sh4re::Response::Schedules { schedules }) => {
|
||||||
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}")),
|
serde_json::to_string(&schedules)
|
||||||
|
.unwrap_or_else(|e| format!("list_schedules: serialise: {e:#}"))
|
||||||
|
}
|
||||||
other => reply_err(other, "list_schedules"),
|
other => reply_err(other, "list_schedules"),
|
||||||
};
|
};
|
||||||
annotate_retries(body, retries)
|
annotate_retries(body, retries)
|
||||||
|
|
@ -1808,448 +1810,26 @@ pub struct GetHostJournalArgs {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub until: Option<String>,
|
pub until: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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";
|
|
||||||
|
|
||||||
/// 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<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()),
|
|
||||||
// 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<hive_sh4re::ToolGroup> {
|
|
||||||
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::<hive_sh4re::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::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::ToolGroup> {
|
|
||||||
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<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::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/templates/harness-base.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";
|
|
||||||
|
|
||||||
/// 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, `<parent>` sentinel
|
|
||||||
/// always allowed, or `to` is in the list); returns `Err(refusal)`
|
|
||||||
/// with a claude-readable string when blocked <20><><EFBFBD> the harness surfaces
|
|
||||||
/// the refusal as the tool result so claude knows the message didn't
|
|
||||||
/// land and can react (e.g. route via `<parent>` instead).
|
|
||||||
fn check_send_allowed(to: &str) -> Result<(), String> {
|
|
||||||
if to == hive_sh4re::PARENT_RECIPIENT {
|
|
||||||
// Always allow `<parent>` — 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<String> = 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<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
env: std::collections::BTreeMap<String, String>,
|
|
||||||
#[serde(default = "default_allowed_tools")]
|
|
||||||
#[serde(rename = "allowedTools")]
|
|
||||||
allowed_tools: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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>`.
|
|
||||||
/// `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 <path>`). 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::<u16>().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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{IDLE_WAIT_HINT, format_recv};
|
use super::{IDLE_WAIT_HINT, format_recv};
|
||||||
use super::{SERVER_NAME, allowed_mcp_tools};
|
|
||||||
use hive_sh4re::ToolGroup;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_recv_after_wait_appends_idle_hint() {
|
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.starts_with("(empty)"));
|
||||||
assert!(out.contains(IDLE_WAIT_HINT));
|
assert!(out.contains(IDLE_WAIT_HINT));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_recv_without_wait_has_no_hint() {
|
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)");
|
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")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
439
hive-ag3nt/src/mcp_config.rs
Normal file
439
hive-ag3nt/src/mcp_config.rs
Normal file
|
|
@ -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__<this>__<tool>` (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<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()),
|
||||||
|
// 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<hive_sh4re::ToolGroup> {
|
||||||
|
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::<hive_sh4re::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::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::ToolGroup> {
|
||||||
|
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<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::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/templates/harness-base.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";
|
||||||
|
|
||||||
|
/// 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, `<parent>` sentinel
|
||||||
|
/// always allowed, or `to` is in the list); returns `Err(refusal)`
|
||||||
|
/// with a claude-readable string when blocked <20><><EFBFBD> the harness surfaces
|
||||||
|
/// the refusal as the tool result so claude knows the message didn't
|
||||||
|
/// land and can react (e.g. route via `<parent>` instead).
|
||||||
|
pub fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||||
|
if to == hive_sh4re::PARENT_RECIPIENT {
|
||||||
|
// Always allow `<parent>` — 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<String> = 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
env: std::collections::BTreeMap<String, String>,
|
||||||
|
#[serde(default = "default_allowed_tools")]
|
||||||
|
#[serde(rename = "allowedTools")]
|
||||||
|
allowed_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>`.
|
||||||
|
/// `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 <path>`). 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::<u16>().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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ use hive_claude::{Config, InfiniteSession, PercentPolicy, Sink};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::events::{Bus, LiveEvent};
|
use crate::events::{Bus, LiveEvent};
|
||||||
use crate::mcp;
|
use crate::mcp_config;
|
||||||
|
|
||||||
// Hive-enforced claude settings ship at `/etc/claude-code/managed-settings.json`
|
// 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`
|
// (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<PathBuf> {
|
||||||
let exe = std::env::current_exe()
|
let exe = std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.map_or_else(|| "hive".into(), |p| p.display().to_string());
|
.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?;
|
tokio::fs::write(&path, body).await?;
|
||||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||||
Ok(path)
|
Ok(path)
|
||||||
|
|
@ -236,7 +236,8 @@ fn compact_percent() -> u8 {
|
||||||
if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) {
|
if env_u64("HIVE_COMPACT_WATERMARK_TOKENS") == Some(0) {
|
||||||
return 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)
|
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()),
|
system_prompt_file: Some(files.system_prompt.clone()),
|
||||||
mcp_config: Some(files.mcp_config.clone()),
|
mcp_config: Some(files.mcp_config.clone()),
|
||||||
strict_mcp_config: true,
|
strict_mcp_config: true,
|
||||||
tools: Some(mcp::builtin_tools_arg()),
|
tools: Some(mcp_config::builtin_tools_arg()),
|
||||||
allowed_tools: Some(mcp::allowed_tools_arg()),
|
allowed_tools: Some(mcp_config::allowed_tools_arg()),
|
||||||
add_dirs,
|
add_dirs,
|
||||||
..Config::default()
|
..Config::default()
|
||||||
}
|
}
|
||||||
|
|
@ -631,4 +632,3 @@ fn archive_session(bus: &Bus) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue