The gateway's nginx + dnsmasq no longer run in their own nspawn container. `nix/host-modules/hive-gateway/default.nix` loses the `containers.hive-gateway` wrapper and everything that existed only to punch holes in it: `privateNetwork = false`, `CAP_NET_ADMIN`, five bind mounts, its own `stateVersion`, `networking.firewall.enable = false`, `networking.resolvconf.enable = false`, and the `hive-gateway-resolv` path+service pair. 465 -> 303 lines. The container never bought isolation here. It shared the host netns by necessity — nginx binds the host's :80/:443, dnsmasq answers on the bridge — so each of those settings was undoing a boundary the gateway could not afford in the first place. Four things made it more than a deletion, none of them visible in the nix diff: - The self-signed cert service also imports the hive CA leaf, so removing it with the container would have left nginx naming a missing cert file, which it refuses to load at all. - The nginx reload is a hive-priv verb. It still needs root, but no longer for the reason its doc gave, and `--machine=` was both transport and scope — so the unit name is now hard-coded in the helper as the containment. - The lifecycle verb named a container that stops existing. - `journalctl -M hive-gateway` had no machine to enter. Per the operator's ruling, the operator verb keeps working and agents lose it. `InfraContainer` answered three questions that used to share an answer; it now splits into `name()` (identity), `target()` (Container vs HostUnit), `service_unit()` (the systemd unit), and `agent_restartable()`, which the MCP restart path checks before the capability so the refusal cannot read as "ask for infra_admin". `SIBLING_CONTAINERS` drops the gateway — it gates the requests that name a container as a string — while `FromStr` still accepts it, because that answers what a name is, not who may act on it. The dashboard's gateway journal reads host journald filtered to `nginx.service`. Prose was corrected where it only named a location, and re-argued where the container was doing security work: a `0666` per-agent socket was safe because only the gateway container had the directory bind-mounted. There is no mount now, so the directory permissions are the whole of the access control — the constraint holds, its mechanism doesn't. Gate: nix fmt / clippy --all-targets -D warnings / cargo test all clean (710 tests); hivectl-cli.md regenerated from the clap tree. The nix eval was run in both TLS shapes at this commit: every delta in the rendered virtualHosts is one of the three intended path moves, dnsmasq settings are byte-identical, and the absence probe flips true -> false with bindMounts emptied.
460 lines
20 KiB
Rust
460 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/templates/harness/`. Safe as a single fixed value across all
|
|
/// agents because each container runs in its own private network namespace,
|
|
/// so `127.0.0.1:<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/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()),
|
|
// infra_admin lets an agent restart hive infrastructure
|
|
// containers (hive-ci / hive-forge / hive-matrix — not the
|
|
// gateway) through the existing `restart` tool. Unlock it here so agents that hold
|
|
// the capability without the full `lifecycle` group can still
|
|
// call it; c0re re-checks the capability server-side and only
|
|
// honours infra-container names via this path.
|
|
"infra_admin" => tools.push("restart".to_owned()),
|
|
// manage_root_agent / query_agent_state don't expose new MCP
|
|
// tools: manage_root_agent gates existing lifecycle tools via
|
|
// topology enforcement; query_agent_state unlocks the `agent`
|
|
// field in get_loose_ends / count_pending_reminders /
|
|
// reminder_rollup (c0re enforces the cap server-side).
|
|
"manage_root_agent" | "query_agent_state" => {}
|
|
unknown => {
|
|
tracing::warn!(capability = %unknown, "unrecognised capability in HIVE_CAPABILITIES — skipped");
|
|
}
|
|
}
|
|
}
|
|
tools
|
|
}
|
|
|
|
/// Resolve the active tool groups for a harness session.
|
|
///
|
|
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
|
|
/// token is matched (case-insensitive) against the `ToolGroup` serde names
|
|
/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`,
|
|
/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped.
|
|
/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty.
|
|
fn effective_tool_groups() -> Vec<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/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/templates/harness/`). 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")));
|
|
}
|
|
}
|