hyperhive/hive-sh4re/src/permissions.rs
atlas b88a5b2430 remove the list_containers and request_update_meta_inputs MCP tools
Both agent-facing tools go away end to end, with no replacement. This is
an intentional capability removal: agents can no longer enumerate their
own subtree, and can no longer queue a meta-flake input bump.

The system prompt and docs/tools/lifecycle.md land in this same commit
on purpose. A tool named in the prompt but absent from the server makes
agents confidently call something that doesn't exist, and the failure
then surfaces far from its cause.

Removed:

- MCP registrations and bodies (hive-agent-mcp), plus the now-unused
  UpdateMetaInputsArgs.
- Wire variants Request::ListDescendants,
  Request::RequestUpdateMetaInputs and Response::Containers, plus
  ContainerInfo, whose only consumer was that response.
- hive-c0re's handle_list_descendants (its whole module) and
  handle_request_update_meta_inputs, the two dispatch arms, and the
  require_group(agent, "approvals", ...) gate on the meta-inputs verb.
- The stream_enrich emoji entry and argument formatter.
- docs/tools/lifecycle.md (both tools it documented are gone), its two
  referrers, the tool-group tables and the agent-hierarchy prose.

Tool groups are kept, deliberately. ToolGroup::Lifecycle listed exactly
one tool and now lists none — it is vestigial, but the variant stays so
existing meta/capabilities.json grants still parse; retiring it is a
separate decision. ToolGroup::Approvals also listed exactly one tool,
but the group is NOT dead: check_can_cancel_approval still gates
cancel_loose_end's approval-cancel arm on it server-side.

ApprovalKind::UpdateMetaInputs stays too. Nothing in production code
produces it any more, but pre-existing approval rows may still carry it,
and the operator's own path to a meta update is unaffected — the
dashboard's POST /api/meta-update inserts the meta_update job directly,
bypassing approvals entirely.

The two format_ack tests in hive-agent-mcp that named
request_update_meta_inputs were only using it as a label string while
exercising the generic OkWarn/Ok renderer, so they are retargeted to a
surviving tool rather than deleted.

Note hive-c0re's priv_client::list_containers is a different thing (the
host-side privileged container listing behind hive-priv) and is
untouched.

Closes #4591
2026-09-20 22:47:46 +02:00

527 lines
24 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 — with one deliberate exception: [`subagent_builtin_tools_arg`]
//! additionally grants a **subagent** Claude's own `Bash` when the parent
//! holds [`ToolGroup::Execution`]. The parent never gets built-in `Bash`
//! itself (it has the out-of-process `bash` MCP server instead); the
//! capability transfers to a subagent, not the mechanism.
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** — not because of what an empty
/// value does, but because we don't know. Our own measurement and the
/// installed `claude --help` disagree about whether `--tools ""` means "no
/// tools" or parses as the flag being unset, and the answer is a property of
/// whichever claude release is installed, not of this code. So an empty
/// value is refused rather than relied on: the rule holds under either
/// reading, and no caller has to know which one is true today.
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from
/// arising in the first place.
///
/// ⚠️ 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(",")
}
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `groups`
/// contains [`ToolGroup::Execution`] — the **subagent**-only `--tools`
/// resolution.
///
/// `Execution` already grants shell execution to the parent agent, via the
/// out-of-process `bash` MCP server (`mcp__bash__run`/`status`/`kill` —
/// see the variant's own doc comment). A subagent has no MCP server at all
/// by default (`docs/tools/subagent.md`), so there is no `mcp__bash__*` for
/// it to inherit that capability through; it gets Claude's own `Bash`
/// instead. The capability transfers from the parent, the mechanism it
/// arrives by does not have to match.
///
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`ToolGroup::builtin_tools`]:
/// those are shared with the harness's own `--tools` (`hive-agent`'s
/// `mcp_config::allowed_tools_arg`), and the main agent must never gain
/// built-in `Bash` no matter which groups it holds — only a *subagent*
/// spawned by an `Execution`-holding parent does.
#[must_use]
pub fn subagent_builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
let mut tools = builtin_tools_for(groups);
if groups.contains(&ToolGroup::Execution) {
tools.push("Bash");
}
tools
}
/// The value for a **subagent's** `--tools` flag: [`subagent_builtin_tools_for`]
/// resolved against [`effective_tool_groups`]. See that function for why this
/// differs from [`builtin_tools_arg`].
#[must_use]
pub fn subagent_builtin_tools_arg() -> String {
subagent_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,
/// Gates no tool today — `list_containers`, its only member, was
/// removed with no replacement. Kept so existing
/// `meta/capabilities.json` grants still parse; `tools()` returns
/// `&[]`.
Lifecycle,
/// Grants no MCP tool today — `request_update_meta_inputs`, its only
/// member, was removed with no replacement. Still a live
/// server-side gate: `cancel_loose_end`'s approval-cancel arm
/// requires it (`socket_server::check_can_cancel_approval`).
Approvals,
/// `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`,
/// `edit_schedule`, `list_schedules` - *(privileged)*
Scheduling,
/// `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()`) and for `Lifecycle` / `Approvals`
/// (their tools were removed); see each variant's doc comment.
#[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::Scheduling => &[
"request_schedule_prompt",
"fire_schedule_now",
"cancel_schedule",
"edit_schedule",
"list_schedules",
],
Self::Forge => &["create_repo"],
// All four empty, for four 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()`. `Lifecycle` and `Approvals` each listed
// exactly one tool — `list_containers` and
// `request_update_meta_inputs` respectively — and both tools
// were removed outright; the variants stay so existing
// grants parse, and `Approvals` still gates
// `cancel_loose_end`'s approval-cancel arm server-side.
Self::Lifecycle | Self::Approvals | 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::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::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 => "no tools — vestigial since list_containers was removed",
Self::Approvals => {
"no tools — grants cancel_loose_end's approval-cancel arm (privileged)"
}
Self::Scheduling => {
"request_schedule_prompt and related — operator-visible scheduled prompts (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,
}
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];
/// 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",
}
}
}
#[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"));
}
/// No resolution may produce an empty `--tools` value. What an empty one
/// means is disputed and release-dependent (see [`builtin_tools_arg`]),
/// so this pins the rule that makes the question moot rather than any
/// answer to it.
#[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");
}
}
/// A subagent whose parent holds `Execution` gets Claude's own `Bash` —
/// the parent already has shell execution (via the out-of-process
/// `bash` MCP server), and a subagent has no MCP server of its own to
/// inherit that through, so it gets Claude's built-in tool instead.
#[test]
fn subagent_gets_bash_when_parent_has_execution() {
let tools = subagent_builtin_tools_for(&[ToolGroup::Execution]);
assert!(tools.contains(&"Bash"));
}
/// 🎯 The negative case that matters: without `Execution`, a subagent
/// must not get `Bash` either. Without this, a later refactor that
/// grants it unconditionally would go unnoticed.
#[test]
fn subagent_gets_no_bash_without_execution() {
for groups in [&[][..], &[ToolGroup::Messaging, ToolGroup::WebTools][..]] {
let tools = subagent_builtin_tools_for(groups);
assert!(!tools.contains(&"Bash"), "{groups:?} must not grant Bash");
}
}
/// The parent (harness) resolver is untouched: `builtin_tools_for` /
/// `builtin_tools_arg` — what `hive-agent`'s own `--tools` and
/// `--allowedTools` are built from — never produce `Bash`, with or
/// without `Execution`. Only the subagent-specific resolver above adds
/// it; the operator's ruling was explicit that the main agent does not.
#[test]
fn parent_resolver_never_gains_bash() {
for groups in [&[][..], &[ToolGroup::Execution][..], ToolGroup::ALL] {
let tools = builtin_tools_for(groups);
assert!(
!tools.contains(&"Bash"),
"{groups:?} must not grant Bash to the parent"
);
}
}
}