The `--tools` list a harness session gets is not a constant: the base set plus whatever the agent's `HIVE_TOOL_GROUPS` add (today, `web_tools` → `WebFetch`/`WebSearch`). That resolution lived in `hive-agent`'s `mcp_config`, which is fine while the harness is the only thing that spawns a `claude` — and it is not: `hive-subagent-mcp` spawns one too. `hive-agent` is binary-only (no `src/lib.rs`, no lib target), so nothing can depend on it to reach `builtin_tools_arg`. The alternative to a shared home is a second list in the subagent daemon, which diverges on the first tool anyone adds to either — and diverging upward is a subagent holding a built-in its parent does not have. So move the base list, the `HIVE_TOOL_GROUPS` parse and the resolution into `hive_sh4re::permissions`, beside `ToolGroup` — whose `builtin_tools()` was already half of the answer. `hive-agent` re-exports them, so `mcp_config::builtin_tools_arg()` still reads the same at the call site, and `allowed_tools_arg` now derives its built-in half from the same function rather than repeating the merge loop. Behaviour is unchanged. The parse is `strum::EnumString` rather than a `serde_json::from_value` round-trip through a `Value::String`: same `snake_case` names (a test pins the two derives against each other), without `hive-sh4re` needing `serde_json` outside its dev-dependencies. It is now a pure function of its input, so the fallbacks are testable without mutating the environment — which under edition 2024 is `unsafe` and racy across a test binary's threads. Refs #4416
461 lines
20 KiB
Rust
461 lines
20 KiB
Rust
//! Per-agent authorization: named MCP-tool groups (`ToolGroup`) and
|
|
//! system-level capability grants (`Capability`). Both are declared in
|
|
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected
|
|
//! into the container as env vars, and read by the harness to decide
|
|
//! which MCP tools claude actually sees.
|
|
//!
|
|
//! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]):
|
|
//! that set is group-gated too (`web_tools`), so it belongs next to the
|
|
//! groups rather than in any one consumer. Both processes that spawn a
|
|
//! `claude` — the harness itself and the subagent daemon — call the same
|
|
//! function, so a subagent can never be handed a built-in its parent does
|
|
//! not have.
|
|
|
|
use std::str::FromStr;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Env var the meta renderer writes with a comma-separated list of
|
|
/// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent
|
|
/// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See
|
|
/// `docs/process/conventions.md::Tool groups`.
|
|
pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
|
|
|
/// Built-in claude tools present in every session regardless of tool
|
|
/// groups. Anything not in this list (and not added by a group's
|
|
/// [`ToolGroup::builtin_tools`]) literally doesn't exist in the session —
|
|
/// this is the `--tools` list, which holds even under
|
|
/// `--dangerously-skip-permissions`.
|
|
///
|
|
/// Web egress (`WebFetch`/`WebSearch`) is group-gated (`web_tools`) and so
|
|
/// lives there, 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"];
|
|
|
|
/// The tool groups in effect for this process, from [`TOOL_GROUPS_ENV`].
|
|
///
|
|
/// Absent, blank, or naming nothing recognisable all fall back to
|
|
/// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta
|
|
/// renderer, so "unset" is an ordinary state, not an error.
|
|
#[must_use]
|
|
pub fn effective_tool_groups() -> Vec<ToolGroup> {
|
|
ToolGroup::parse_list(&std::env::var(TOOL_GROUPS_ENV).unwrap_or_default())
|
|
}
|
|
|
|
/// The built-in claude tools `groups` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
|
|
/// plus each group's own built-ins, de-duplicated, base set first.
|
|
#[must_use]
|
|
pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
|
|
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
|
for group in groups {
|
|
for tool in group.builtin_tools() {
|
|
if !tools.contains(tool) {
|
|
tools.push(tool);
|
|
}
|
|
}
|
|
}
|
|
tools
|
|
}
|
|
|
|
/// The value for claude's `--tools` flag: which built-in tools exist in this
|
|
/// session at all.
|
|
///
|
|
/// ⚠️ **Never emit this as an empty string.** An empty `--tools` value parses
|
|
/// as *unset* and yields **more** tools than omitting the flag, so there is
|
|
/// no way to spell "no built-in tools" — an empty list is a fail-loudly bug,
|
|
/// not a lockdown. [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which is what
|
|
/// keeps that from arising.
|
|
///
|
|
/// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by
|
|
/// `--tools` at all; they are governed by `--strict-mcp-config` plus
|
|
/// whatever the caller renders into `--mcp-config`.
|
|
#[must_use]
|
|
pub fn builtin_tools_arg() -> String {
|
|
builtin_tools_for(&effective_tool_groups()).join(",")
|
|
}
|
|
|
|
/// Named group of MCP tools an agent may be granted. The harness reads
|
|
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
|
|
/// `snake_case` group names written by the meta renderer from per-agent
|
|
/// config) and expands it to the matching tool names for `--allowedTools`.
|
|
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
|
|
/// See `docs/process/conventions.md::Tool groups`.
|
|
#[derive(
|
|
Debug,
|
|
Clone,
|
|
Copy,
|
|
PartialEq,
|
|
Eq,
|
|
Hash,
|
|
Serialize,
|
|
Deserialize,
|
|
strum::IntoStaticStr,
|
|
strum::EnumString,
|
|
)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
pub enum ToolGroup {
|
|
/// `send`, `recv`, `ack_until`
|
|
Messaging,
|
|
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
|
|
Meta,
|
|
/// `get_loose_ends`, `cancel_loose_end`, `remind`
|
|
Inbox,
|
|
/// `kill`, `start`, `restart`, `update` - *(privileged)*
|
|
Lifecycle,
|
|
/// `request_update_meta_inputs` - *(privileged)*
|
|
Approvals,
|
|
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
|
|
/// `edit_schedule`, `list_schedules` - *(privileged)*
|
|
Scheduling,
|
|
/// `get_logs` - *(privileged)*
|
|
Diagnostics,
|
|
/// `create_repo` — create git repos through hive-c0re (the only path
|
|
/// now that agents can't create them directly). Opt-in per
|
|
/// agent so the operator controls who can spin up repos.
|
|
Forge,
|
|
/// Gates whether the `bash` MCP server (`mcp__bash__run`/`status`/
|
|
/// `kill`) is rendered into the agent's config at all — see
|
|
/// `extra_server_required_group` in `hive-agent/src/mcp_config.rs`.
|
|
/// `tools()` returns `&[]`: this isn't a `mcp__hyperhive__*`
|
|
/// allowlist entry, the gate lives at config-render time instead.
|
|
Execution,
|
|
/// Claude built-in web egress tools: `WebFetch` (retrieve a URL) and
|
|
/// `WebSearch` (search the web). Both are omitted from `--tools` by
|
|
/// default; adding this group to an agent enables them in the session
|
|
/// and in `--allowedTools` so they run without a confirmation prompt.
|
|
/// Does not gate any MCP tools — `tools()` returns `&[]`.
|
|
WebTools,
|
|
}
|
|
|
|
impl ToolGroup {
|
|
/// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed
|
|
/// and lowercased; an unrecognised one is warned about and skipped
|
|
/// rather than failing the whole list, so one stale name in an agent's
|
|
/// config can't take its whole tool surface away.
|
|
///
|
|
/// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or
|
|
/// names nothing recognisable — the same fallback either way, because
|
|
/// "the operator granted no groups" and "the var is missing" are not
|
|
/// distinguishable here and neither should mean "no tools".
|
|
#[must_use]
|
|
pub fn parse_list(raw: &str) -> Vec<Self> {
|
|
let mut groups = Vec::new();
|
|
for token in raw.split(',') {
|
|
let token = token.trim().to_ascii_lowercase();
|
|
if token.is_empty() {
|
|
continue;
|
|
}
|
|
match Self::from_str(&token) {
|
|
Ok(group) => groups.push(group),
|
|
Err(_) => {
|
|
tracing::warn!(token = %token, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
|
}
|
|
}
|
|
}
|
|
if groups.is_empty() {
|
|
if !raw.trim().is_empty() {
|
|
tracing::warn!(
|
|
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
|
falling back to AGENT_DEFAULT"
|
|
);
|
|
}
|
|
return Self::AGENT_DEFAULT.to_vec();
|
|
}
|
|
groups
|
|
}
|
|
|
|
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
|
|
/// Returns `&[]` for `WebTools` — it enables Claude built-in tools,
|
|
/// not MCP tools; see `builtin_tools()`.
|
|
#[must_use]
|
|
pub fn tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::Messaging => &["send", "recv", "ack_until"],
|
|
Self::Meta => &["get_agent_meta"],
|
|
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
|
Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"],
|
|
Self::Approvals => &["request_update_meta_inputs"],
|
|
Self::Scheduling => &[
|
|
"request_schedule_prompt",
|
|
"fire_schedule_now",
|
|
"cancel_schedule",
|
|
"edit_schedule",
|
|
"list_schedules",
|
|
],
|
|
Self::Diagnostics => &["get_logs"],
|
|
Self::Forge => &["create_repo"],
|
|
// Both empty, for different reasons — see each variant's own
|
|
// doc comment above. `Execution` grants the out-of-process
|
|
// `bash` MCP server (`mcp__bash__run`/`status`/`kill`), gated
|
|
// at config-render time by `extra_server_required_group` in
|
|
// `hive-agent/src/mcp_config.rs`, not by this list — an
|
|
// out-of-process server has no later enforcement point, so
|
|
// that gate is the actual security boundary. `WebTools`
|
|
// grants Claude built-in tools, not MCP ones; see
|
|
// `builtin_tools()`.
|
|
Self::Execution | Self::WebTools => &[],
|
|
}
|
|
}
|
|
|
|
/// MCP tools that are always exposed regardless of which tool groups an
|
|
/// agent is granted. `set_status` lives here because the operator
|
|
/// dashboard depends on every agent being able to report its status
|
|
/// chip — gating it behind a group would let a misconfigured agent go
|
|
/// dark on the dashboard. The server-side `SetStatus` handler has no
|
|
/// tool-group check either (only length validation), so listing it here
|
|
/// keeps the `--allowedTools` list honest with that reality.
|
|
///
|
|
/// `compact` lives here too: it's pure self-management (no cross-agent
|
|
/// effect, no privilege), gated server-side on context usage rather
|
|
/// than on tool groups, and every agent should be able to reach for it
|
|
/// regardless of which optional groups it's been granted — same
|
|
/// reasoning as `set_status`.
|
|
///
|
|
/// `mark_todos_done` too (a critical bug every agent hit): it was
|
|
/// declared as a `#[tool]` fn but never added to *any*
|
|
/// group's [`tools`](Self::tools), including `Inbox`, so no agent could
|
|
/// ever get it into `--allowedTools` and every call prompted for
|
|
/// approval it can't get. Todos are pushed to an agent independent of
|
|
/// whether it holds `Inbox` (that group only gates
|
|
/// `get_loose_ends`/`cancel_loose_end`/`remind`), so an agent without
|
|
/// `Inbox` could accumulate todos it can never clear — same
|
|
/// "every agent needs this regardless of optional groups" shape as
|
|
/// `set_status`/`compact`, not a narrower `Inbox`-only fix.
|
|
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
|
|
&["set_status", "compact", "mark_todos_done"];
|
|
|
|
/// The Claude built-in tool names enabled by this group. Only
|
|
/// `WebTools` returns a non-empty slice; all other groups return `&[]`
|
|
/// (they control MCP tools via `tools()` instead).
|
|
#[must_use]
|
|
pub fn builtin_tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::WebTools => &["WebFetch", "WebSearch"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// Default tool groups for an agent harness. Used when `HIVE_TOOL_GROUPS` is unset.
|
|
pub const AGENT_DEFAULT: &'static [Self] =
|
|
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
|
|
|
|
/// Convenience preset for a fully-privileged agent (all groups).
|
|
/// Use this as a starting point in `tool-groups.json` for root/manager agents.
|
|
pub const MANAGER_DEFAULT: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Lifecycle,
|
|
Self::Approvals,
|
|
Self::Scheduling,
|
|
Self::Diagnostics,
|
|
Self::Execution,
|
|
];
|
|
|
|
/// Every known tool group in a stable order. Use this to enumerate
|
|
/// columns in the capabilities UI or any other place that needs the
|
|
/// full list without hard-coding it at the call site.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Lifecycle,
|
|
Self::Approvals,
|
|
Self::Scheduling,
|
|
Self::Diagnostics,
|
|
Self::Forge,
|
|
Self::Execution,
|
|
Self::WebTools,
|
|
];
|
|
|
|
/// Short human-readable description suitable for a tooltip or help text.
|
|
#[must_use]
|
|
pub fn description(self) -> &'static str {
|
|
match self {
|
|
Self::Messaging => "send, recv, ack_until — core agent communication",
|
|
Self::Meta => {
|
|
"get_agent_meta — identity introspection (set_status is always available)"
|
|
}
|
|
Self::Inbox => "get_loose_ends, cancel_loose_end, remind — self-scheduling",
|
|
Self::Lifecycle => {
|
|
"kill, start, restart, update, list_containers — container lifecycle (privileged)"
|
|
}
|
|
Self::Approvals => {
|
|
"request_update_meta_inputs — operator-approved meta-flake input bumps (privileged)"
|
|
}
|
|
Self::Scheduling => {
|
|
"request_schedule_prompt and related — operator-visible scheduled prompts (privileged)"
|
|
}
|
|
Self::Diagnostics => {
|
|
"get_logs — read a sub-agent container's systemd journal (privileged)"
|
|
}
|
|
Self::Forge => {
|
|
"create_repo — create git repos through hive-c0re (operator-gated merge)"
|
|
}
|
|
Self::Execution => {
|
|
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
|
|
}
|
|
Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-agent capability grants. Stored in `meta/capabilities.json`
|
|
/// (same shape as `tool-groups.json`: `{ "alice": ["read_host_journal"] }`).
|
|
/// Capabilities control system-level access hive-c0re enforces at
|
|
/// dispatch time; they are orthogonal to tool groups (which control
|
|
/// which MCP tools the harness exposes to claude).
|
|
///
|
|
/// Injected into containers as `HIVE_CAPABILITIES` (comma-separated
|
|
/// `snake_case`) via `meta::render_flake`. The harness reads this to
|
|
/// conditionally register capability-gated MCP tools so claude only
|
|
/// sees tools it can actually invoke. See `docs/process/conventions.md::Capabilities`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
pub enum Capability {
|
|
/// Agent can lifecycle-manage the root agent (kill/start/restart)
|
|
/// on behalf of the hive when the root has crashed. Named capability
|
|
/// for the existing manager privilege — future topology enforcement
|
|
/// will gate this via the capability system instead of the hardcoded
|
|
/// `container == MANAGER_CONTAINER` check.
|
|
ManageRootAgent,
|
|
/// Agent can read the full host journal via `GetHostJournal`.
|
|
/// hive-c0re checks this capability before running journalctl.
|
|
/// MCP tool `get_host_journal` is only registered in the harness
|
|
/// when this capability is present.
|
|
ReadHostJournal,
|
|
/// Agent can query agents outside its own subtree via `GetLooseEnds`,
|
|
/// `CountPendingReminders`, and `ReminderRollup` on the agent
|
|
/// socket. Without this capability, targeting an agent outside the
|
|
/// caller's subtree is rejected with an error (the caller itself and
|
|
/// every descendant are always accessible without any capability).
|
|
/// The `"*"` hive-wide value is not
|
|
/// available on the agent socket even with this capability — use the
|
|
/// manager socket for swarm-wide scans.
|
|
QueryAgentState,
|
|
}
|
|
|
|
impl Capability {
|
|
/// Every known capability in a stable order. Use this to enumerate
|
|
/// columns in the permissions UI or validate incoming capability strings.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::ManageRootAgent,
|
|
Self::ReadHostJournal,
|
|
Self::QueryAgentState,
|
|
];
|
|
|
|
/// Short human-readable description suitable for a tooltip or help text.
|
|
#[must_use]
|
|
pub fn description(self) -> &'static str {
|
|
match self {
|
|
Self::ManageRootAgent => {
|
|
"lifecycle-manage the root/manager agent on hive crash recovery"
|
|
}
|
|
Self::ReadHostJournal => "read host journald via get_host_journal MCP tool",
|
|
Self::QueryAgentState => {
|
|
"query non-child agents' loose ends and reminder state via get_loose_ends"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The env-var spellings the meta renderer actually writes must round-trip
|
|
/// back to groups. `strum::EnumString` and serde's `rename_all` are two
|
|
/// independent derives over the same names; this pins them together, since
|
|
/// the renderer writes what serde would produce and the parse is strum's.
|
|
#[test]
|
|
fn parse_list_accepts_the_snake_case_names_the_renderer_writes() {
|
|
assert_eq!(
|
|
ToolGroup::parse_list("messaging, Meta ,web_tools"),
|
|
vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools]
|
|
);
|
|
for group in ToolGroup::ALL {
|
|
let name: &'static str = (*group).into();
|
|
assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}");
|
|
}
|
|
}
|
|
|
|
/// Blank, absent and all-garbage are the same state as far as the tool
|
|
/// surface is concerned: the default groups, never an empty list.
|
|
#[test]
|
|
fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() {
|
|
for raw in ["", " ", ",,", "not_a_group,also_not"] {
|
|
assert_eq!(
|
|
ToolGroup::parse_list(raw),
|
|
ToolGroup::AGENT_DEFAULT.to_vec(),
|
|
"{raw:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Adding groups only ever adds built-ins — the base set is in every
|
|
/// resolution, and no group can remove one. Both `--tools` callers (the
|
|
/// harness and the subagent daemon) rely on this to reason about "the
|
|
/// parent's set" without re-deriving it.
|
|
#[test]
|
|
fn builtin_tools_are_the_base_set_plus_whatever_the_groups_add() {
|
|
assert_eq!(
|
|
builtin_tools_for(&[]),
|
|
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
"no groups resolves to exactly the base set"
|
|
);
|
|
assert_eq!(
|
|
builtin_tools_for(ToolGroup::AGENT_DEFAULT),
|
|
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
"the default groups add no built-ins"
|
|
);
|
|
for group in ToolGroup::ALL {
|
|
let resolved = builtin_tools_for(&[*group]);
|
|
for base in ALLOWED_BUILTIN_TOOLS {
|
|
assert!(resolved.contains(base), "{group:?} dropped {base}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `web_tools` is the one group that widens the built-in set, and it is
|
|
/// opt-in. A subagent inherits this resolution rather than a list of its
|
|
/// own precisely so it cannot get web egress from a parent without the
|
|
/// group.
|
|
#[test]
|
|
fn web_egress_is_present_only_with_the_web_tools_group() {
|
|
let with = builtin_tools_for(&[ToolGroup::WebTools]);
|
|
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch"));
|
|
let without = builtin_tools_for(ToolGroup::MANAGER_DEFAULT);
|
|
assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch"));
|
|
}
|
|
|
|
/// An empty `--tools` value parses as *unset* and grants more than
|
|
/// omitting the flag, so no resolution may ever produce one.
|
|
#[test]
|
|
fn no_group_combination_resolves_to_an_empty_tools_arg() {
|
|
assert!(!ALLOWED_BUILTIN_TOOLS.is_empty());
|
|
assert!(!builtin_tools_for(&[]).is_empty());
|
|
assert!(!builtin_tools_for(ToolGroup::ALL).is_empty());
|
|
assert!(!builtin_tools_arg().is_empty());
|
|
}
|
|
|
|
/// Built-ins are not MCP tools and the two lists must not be mixed: a
|
|
/// `mcp__*` name in `--tools` would be silently inert, since `--tools`
|
|
/// does not filter the MCP surface at all.
|
|
#[test]
|
|
fn the_builtin_list_names_no_mcp_tools() {
|
|
for tool in builtin_tools_for(ToolGroup::ALL) {
|
|
assert!(!tool.contains("__"), "{tool} looks like an MCP tool name");
|
|
}
|
|
}
|
|
}
|