Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: one set, tool groups included, set at agent creation

mara on #5170: "dont split caps and perms. its all perms. / dont include
the now empty tool groups / nothing set -> default / also add perms to
agent creation screen".

- hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution,
  web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`;
  `lifecycle`/`forge` are gone. `Permission::default_for` is the default
  for an agent the swarm sets nothing for.
- The record is `{ "permissions": [...] }`. Missing key, unset field or
  no successful read yet = default; `[]` = none; a failed read after a
  good one keeps the last value.
- hive-agent reads its own key every 10s and builds each turn's --tools,
  --allowedTools and MCP config (rewritten before every turn) from it;
  the subagent daemon reads it on each start/continue. Agents get a
  read-only grant on their own key.
- hive-c0re restarts an agent only when its manage_root_agent changes.
  The import now merges meta/capabilities.json and meta/tool-groups.json
  into one list per agent and deletes both; the "import pending, don't
  read" decision is `ImportOutcome::read_allowed`, table-tested.
- swarm-controller: `GET /api/permissions` (names + default) and an
  optional `permissions` on `POST /api/agents`, stored before the job is
  queued. swarm-ui's create-agent form gets a permissions field.
- Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon,
  tool_groups.rs, ruth's tool-group seed, the permissions rebuild job
  (PermPayload/WritePermFile/perm_change), the dashboard permissions tab
  and its stale-entries panel.

Part of #5170.
This commit is contained in:
atlas 2026-10-15 04:09:53 +02:00 • committed by mara
commit f0844f6da3
73 changed files with 1578 additions and 2742 deletions

View file

@ -1,4 +1,4 @@
//! Claude launch-config layer: resolves the agent's tool-group / capability
//! Claude launch-config layer: resolves the agent's permission
//! set into the `--allowedTools` / `--tools` argument strings and renders the
//! `--mcp-config` blob claude reads at spawn (built-in hyperhive server +
//! any `services.hyperhive.agent.extraMcpServers`). Pure config-string generation consumed by
@ -6,6 +6,8 @@
//! running MCP server (a separate binary) — the `send` allow-list check that
//! server enforces lives alongside it in the `hive-agent-mcp` crate.
use hive_sh4re::permissions::Permission;
/// 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";
@ -18,87 +20,83 @@ pub const SERVER_NAME: &str = "hyperhive";
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
/// The built-in tool surface — the base list, the group-gated additions, the
/// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives
/// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here
/// because this module is where the rest of the claude launch config is
/// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns
/// its own `claude` from the same base resolution (plus its own `Bash`
/// exception — see `hive_sh4re::permissions::subagent_builtin_tools_arg`):
/// `hive-agent` is a binary-only crate with no lib target, so a shared home
/// was the only way for both spawners to read one list rather than two that
/// drift.
pub use hive_sh4re::permissions::{builtin_tools_arg, effective_tool_groups};
/// The value for claude's `--tools` flag this turn: which built-in tools exist
/// in the session at all, from the agent's current permissions. The
/// resolution lives in `hive_sh4re::permissions` because the subagent daemon
/// (`hive-subagent-mcp`) spawns its own `claude` from the same one.
#[must_use]
pub fn builtin_tools_arg() -> String {
hive_sh4re::permissions::builtin_tools_for(&crate::swarm_permissions::current()).join(",")
}
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
/// Permission an extra (out-of-process) MCP server is gated behind, if any.
///
/// Most `services.hyperhive.agent.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),
/// execution is a privilege, so it is only exposed when the agent holds
/// `Execution`. Unlike the in-process hyperhive tools (gated at dispatch),
/// 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> {
fn extra_server_required_permission(server: &str) -> Option<Permission> {
match server {
"bash" => Some(hive_sh4re::permissions::ToolGroup::Execution),
"bash" => Some(Permission::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))
/// Whether an extra MCP server should be exposed to claude given the agent's
/// `perms`. A gated server (see [`extra_server_required_permission`]) is
/// suppressed when the agent lacks its required permission.
fn extra_server_enabled(server: &str, perms: &[Permission]) -> bool {
extra_server_required_permission(server).is_none_or(|required| perms.contains(&required))
}
#[cfg(test)]
mod extra_server_gate_tests {
use super::{extra_server_enabled, extra_server_required_group};
use hive_sh4re::permissions::ToolGroup;
use super::{extra_server_enabled, extra_server_required_permission};
use hive_sh4re::permissions::Permission;
#[test]
fn bash_is_gated_behind_execution() {
assert_eq!(
extra_server_required_group("bash"),
Some(ToolGroup::Execution)
extra_server_required_permission("bash"),
Some(Permission::Execution)
);
// Suppressed without Execution, even if other groups are present.
assert!(!extra_server_enabled(
"bash",
&[ToolGroup::Messaging, ToolGroup::Inbox]
&[Permission::Messaging, Permission::Inbox]
));
// Available once Execution is granted.
assert!(extra_server_enabled("bash", &[ToolGroup::Execution]));
assert!(extra_server_enabled("bash", &[Permission::Execution]));
}
#[test]
fn other_servers_are_ungated() {
assert_eq!(extra_server_required_group("matrix"), None);
assert_eq!(extra_server_required_group("scraper"), None);
assert_eq!(extra_server_required_permission("matrix"), None);
assert_eq!(extra_server_required_permission("scraper"), None);
// An ungated server is available regardless of (even empty) groups.
assert!(extra_server_enabled("matrix", &[]));
assert!(extra_server_enabled("scraper", &[ToolGroup::Messaging]));
assert!(extra_server_enabled("scraper", &[Permission::Messaging]));
}
}
/// MCP tools claude is allowed to call without prompting, derived from
/// the supplied tool groups. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `ToolGroup::tools()` slice in hive-sh4re
/// (single source of truth). See `docs/process/conventions.md::Tool groups`.
/// the supplied permissions. Adding a new `#[tool]` fn to a server impl
/// requires updating the matching `Permission::tools()` slice in hive-sh4re
/// (single source of truth).
#[must_use]
pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<String> {
pub fn allowed_mcp_tools(perms: &[Permission]) -> 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
// regardless of which permissions the agent holds — 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
let mut out: Vec<String> = Permission::ALWAYS_ON_TOOLS
.iter()
.copied()
.chain(groups.iter().flat_map(|g| g.tools().iter().copied()))
.chain(perms.iter().flat_map(|p| p.tools().iter().copied()))
.filter(|t| seen.insert(*t))
.map(|t| format!("mcp__{SERVER_NAME}__{t}"))
.collect();
@ -108,7 +106,7 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
// them without per-tool operator approval. `["*"]` (the default)
// expands to `mcp__<server>__*` — every tool from that server.
for (server, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if server == SERVER_NAME || !extra_server_enabled(&server, groups) {
if server == SERVER_NAME || !extra_server_enabled(&server, perms) {
continue;
}
for pat in spec.allowed_tools() {
@ -122,14 +120,14 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
/// both the built-ins and the MCP surface.
#[must_use]
pub fn allowed_tools_arg() -> String {
let groups = effective_tool_groups();
let perms = crate::swarm_permissions::current();
// The same built-ins `--tools` makes exist this session, so a tool that
// exists is never one claude has to prompt about.
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&groups)
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&perms)
.into_iter()
.map(ToOwned::to_owned)
.collect();
all.extend(allowed_mcp_tools(&groups));
all.extend(allowed_mcp_tools(&perms));
all.join(",")
}
@ -148,7 +146,7 @@ pub fn render_claude_config() -> String {
/// 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
/// any 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).
@ -158,7 +156,7 @@ pub fn configured_server_names() -> Vec<String> {
}
/// Build the `mcpServers` map claude gets in its `--mcp-config`: the
/// built-in hyperhive HTTP surface plus any tool-group-permitted extra
/// built-in hyperhive HTTP surface plus any 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.
@ -187,11 +185,11 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
// (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`).
// Gate permission-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();
let perms = crate::swarm_permissions::current();
for (name, spec) in hive_agent_sock::extra_mcp::load_extra_mcp() {
if name == SERVER_NAME {
tracing::warn!(
@ -199,10 +197,10 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
);
continue;
}
if !extra_server_enabled(&name, &groups) {
if !extra_server_enabled(&name, &perms) {
tracing::info!(
server = %name,
"extra MCP server suppressed: agent lacks the required tool group"
"extra MCP server suppressed: agent lacks the required permission"
);
continue;
}
@ -214,7 +212,7 @@ fn build_mcp_servers() -> serde_json::Map<String, serde_json::Value> {
#[cfg(test)]
mod tests {
use super::{SERVER_NAME, allowed_mcp_tools};
use hive_sh4re::permissions::ToolGroup;
use hive_sh4re::permissions::Permission;
fn qualified(tool: &str) -> String {
format!("mcp__{SERVER_NAME}__{tool}")
@ -222,7 +220,7 @@ mod tests {
#[test]
fn set_status_present_with_no_groups() {
// An agent with zero tool groups (or any group set that omits
// An agent with no permissions (or any set that omits
// `meta`) must still be able to report its dashboard status.
let tools = allowed_mcp_tools(&[]);
assert!(
@ -234,7 +232,7 @@ mod tests {
#[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
// tool but never added to any group, 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.
@ -243,13 +241,13 @@ mod tests {
tools.contains(&qualified("mark_todos_done")),
"mark_todos_done missing from empty-group allow-list: {tools:?}"
);
let tools = allowed_mcp_tools(&[ToolGroup::Inbox]);
let tools = allowed_mcp_tools(&[Permission::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]);
let tools = allowed_mcp_tools(&[Permission::Messaging, Permission::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")));
@ -257,7 +255,7 @@ mod tests {
#[test]
fn no_duplicate_set_status_when_meta_granted() {
let tools = allowed_mcp_tools(&[ToolGroup::Meta]);
let tools = allowed_mcp_tools(&[Permission::Meta]);
let count = tools
.iter()
.filter(|t| **t == qualified("set_status"))