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.
337 lines
15 KiB
Rust
337 lines
15 KiB
Rust
//! Per-agent authorization: the one [`Permission`] set an agent holds.
|
|
//!
|
|
//! The set lives in the swarm's `agent-permissions` record
|
|
//! (`swarm_queue_client::agent_permissions`), one list per agent. Each member
|
|
//! is enforced where it applies: the MCP-tool and built-in-tool members by
|
|
//! the processes that spawn `claude` (the agent harness and the subagent
|
|
//! daemon), [`Permission::ManageRootAgent`] by hive-c0re's bind mounts. An
|
|
//! agent the swarm sets nothing for holds [`Permission::default_for`].
|
|
//!
|
|
//! It also resolves the *built-in* claude tool list ([`builtin_tools_for`]):
|
|
//! that set is permission-gated too (`web_tools`), so it belongs here rather
|
|
//! than in any one consumer. Both processes that spawn a `claude` 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_for`] additionally grants a **subagent** Claude's
|
|
//! own `Bash` when the parent holds [`Permission::Execution`]. The parent
|
|
//! never gets built-in `Bash` itself (it has the out-of-process `bash` MCP
|
|
//! server instead); the permission transfers to a subagent, not the
|
|
//! mechanism.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Built-in claude tools present in every session regardless of permissions.
|
|
/// Anything not in this list (and not added by a permission's
|
|
/// [`Permission::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 permission-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 built-in claude tools `perms` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
|
|
/// plus each permission's own built-ins, de-duplicated, base set first.
|
|
///
|
|
/// ⚠️ The result is a claude `--tools` value once joined, and must **never be
|
|
/// empty** — 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.
|
|
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case 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_for(perms: &[Permission]) -> Vec<&'static str> {
|
|
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
|
for perm in perms {
|
|
for tool in perm.builtin_tools() {
|
|
if !tools.contains(tool) {
|
|
tools.push(tool);
|
|
}
|
|
}
|
|
}
|
|
tools
|
|
}
|
|
|
|
/// [`builtin_tools_for`] plus Claude's built-in `Bash`, granted iff `perms`
|
|
/// contains [`Permission::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 permission through; it gets Claude's own `Bash`
|
|
/// instead.
|
|
///
|
|
/// 🩸 Deliberately **not** folded into [`builtin_tools_for`]/[`Permission::builtin_tools`]:
|
|
/// those are shared with the harness's own `--tools`, and the main agent must
|
|
/// never gain built-in `Bash` no matter which permissions it holds — only a
|
|
/// *subagent* spawned by an `Execution`-holding parent does.
|
|
#[must_use]
|
|
pub fn subagent_builtin_tools_for(perms: &[Permission]) -> Vec<&'static str> {
|
|
let mut tools = builtin_tools_for(perms);
|
|
if perms.contains(&Permission::Execution) {
|
|
tools.push("Bash");
|
|
}
|
|
tools
|
|
}
|
|
|
|
/// One thing an agent may be permitted. Stored by its `snake_case` name.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
|
|
#[serde(rename_all = "snake_case")]
|
|
#[strum(serialize_all = "snake_case")]
|
|
pub enum Permission {
|
|
/// `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 whether the `bash` MCP server (`mcp__bash__run`/`status`/
|
|
/// `kill`) is rendered into the agent's config at all — see
|
|
/// `extra_server_required_permission` 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`
|
|
/// without it; holding it enables them in the session and in
|
|
/// `--allowedTools` so they run without a confirmation prompt.
|
|
WebTools,
|
|
/// Agent may manage *any* agent in the hive. Concretely, hive-c0re
|
|
/// bind-mounts every agent's state dir (RW) and config dir (RO) into the
|
|
/// holder's container, plus `/applied` and `/meta` read-only, so the
|
|
/// holder can recover an agent that is down. See
|
|
/// `lifecycle::host_config::set_nspawn_flags`.
|
|
///
|
|
/// The name is historical, from when "the root agent" was the widest
|
|
/// thing to manage. Kept under that spelling because every stored grant
|
|
/// carries it.
|
|
ManageRootAgent,
|
|
}
|
|
|
|
impl Permission {
|
|
/// Every permission in a stable order.
|
|
pub const ALL: &'static [Self] = &[
|
|
Self::Messaging,
|
|
Self::Meta,
|
|
Self::Inbox,
|
|
Self::Execution,
|
|
Self::WebTools,
|
|
Self::ManageRootAgent,
|
|
];
|
|
|
|
/// What an agent the swarm sets nothing for holds.
|
|
pub const DEFAULT: &'static [Self] =
|
|
&[Self::Messaging, Self::Meta, Self::Inbox, Self::Execution];
|
|
|
|
/// What `agent` holds when the swarm sets nothing for it: [`Self::DEFAULT`],
|
|
/// plus [`Self::ManageRootAgent`] for the root agent
|
|
/// ([`crate::manager::MANAGER_AGENT`]), which recovers the others.
|
|
#[must_use]
|
|
pub fn default_for(agent: &str) -> Vec<Self> {
|
|
let mut perms = Self::DEFAULT.to_vec();
|
|
if agent == crate::manager::MANAGER_AGENT {
|
|
perms.push(Self::ManageRootAgent);
|
|
}
|
|
perms
|
|
}
|
|
|
|
/// The MCP tool names (without the `mcp__hyperhive__` prefix) this
|
|
/// permission grants. Empty for the permissions that grant something
|
|
/// else; 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"],
|
|
// `Execution` grants the out-of-process `bash` MCP server,
|
|
// gated at config-render time in `hive-agent/src/mcp_config.rs`
|
|
// — an out-of-process server has no later enforcement point, so
|
|
// that gate is the actual security boundary. `WebTools` grants
|
|
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
|
// bind mounts.
|
|
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[],
|
|
}
|
|
}
|
|
|
|
/// MCP tools that are always exposed regardless of which permissions an
|
|
/// agent holds. `set_status` lives here because the operator
|
|
/// dashboard depends on every agent being able to report its status
|
|
/// chip — gating it would let a misconfigured agent go dark on the
|
|
/// dashboard. The server-side `SetStatus` handler has no permission
|
|
/// 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, and every
|
|
/// agent should be able to reach for it — same reasoning as `set_status`.
|
|
///
|
|
/// `mark_todos_done` too: todos are pushed to an agent whether or not it
|
|
/// holds `Inbox` (which only gates `get_loose_ends`/`cancel_loose_end`/
|
|
/// `remind`), so an agent without `Inbox` must still be able to clear
|
|
/// them.
|
|
pub const ALWAYS_ON_TOOLS: &'static [&'static str] =
|
|
&["set_status", "compact", "mark_todos_done"];
|
|
|
|
/// The Claude built-in tool names this permission enables. Only
|
|
/// `WebTools` returns a non-empty slice.
|
|
#[must_use]
|
|
pub fn builtin_tools(self) -> &'static [&'static str] {
|
|
match self {
|
|
Self::WebTools => &["WebFetch", "WebSearch"],
|
|
_ => &[],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The stored names are the `snake_case` spellings the old tool-group
|
|
/// and capability files used, so an imported grant decodes.
|
|
#[test]
|
|
fn stored_names_are_snake_case() {
|
|
let names: Vec<&str> = Permission::ALL.iter().map(|p| <&str>::from(*p)).collect();
|
|
assert_eq!(
|
|
names,
|
|
[
|
|
"messaging",
|
|
"meta",
|
|
"inbox",
|
|
"execution",
|
|
"web_tools",
|
|
"manage_root_agent"
|
|
]
|
|
);
|
|
for perm in Permission::ALL {
|
|
let json = serde_json::to_string(perm).unwrap();
|
|
assert_eq!(json, format!("\"{}\"", <&str>::from(*perm)));
|
|
}
|
|
assert!(serde_json::from_str::<Permission>("\"lifecycle\"").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_root_agent_defaults_to_managing_the_others() {
|
|
assert_eq!(
|
|
Permission::default_for("atlas"),
|
|
Permission::DEFAULT.to_vec()
|
|
);
|
|
let ruth = Permission::default_for(crate::manager::MANAGER_AGENT);
|
|
assert!(ruth.contains(&Permission::ManageRootAgent));
|
|
assert_eq!(&ruth[..Permission::DEFAULT.len()], Permission::DEFAULT);
|
|
}
|
|
|
|
/// Adding permissions only ever adds built-ins — the base set is in every
|
|
/// resolution, and no permission 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_permissions_add() {
|
|
assert_eq!(
|
|
builtin_tools_for(&[]),
|
|
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
"no permissions resolves to exactly the base set"
|
|
);
|
|
assert_eq!(
|
|
builtin_tools_for(Permission::DEFAULT),
|
|
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
"the default permissions add no built-ins"
|
|
);
|
|
for perm in Permission::ALL {
|
|
let resolved = builtin_tools_for(&[*perm]);
|
|
for base in ALLOWED_BUILTIN_TOOLS {
|
|
assert!(resolved.contains(base), "{perm:?} dropped {base}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `web_tools` is the one permission 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
|
|
/// permission.
|
|
#[test]
|
|
fn web_egress_is_present_only_with_the_web_tools_permission() {
|
|
let with = builtin_tools_for(&[Permission::WebTools]);
|
|
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch"));
|
|
let without = builtin_tools_for(Permission::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_for`]),
|
|
/// so this pins the rule that makes the question moot rather than any
|
|
/// answer to it.
|
|
#[test]
|
|
fn no_permission_combination_resolves_to_an_empty_tools_arg() {
|
|
assert!(!ALLOWED_BUILTIN_TOOLS.is_empty());
|
|
assert!(!builtin_tools_for(&[]).is_empty());
|
|
assert!(!builtin_tools_for(Permission::ALL).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(Permission::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(&[Permission::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 permissions in [&[][..], &[Permission::Messaging, Permission::WebTools][..]] {
|
|
let tools = subagent_builtin_tools_for(permissions);
|
|
assert!(
|
|
!tools.contains(&"Bash"),
|
|
"{permissions:?} must not grant Bash"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The parent (harness) resolver is untouched: `builtin_tools_for` —
|
|
/// 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 permissions in [&[][..], &[Permission::Execution][..], Permission::ALL] {
|
|
let tools = builtin_tools_for(permissions);
|
|
assert!(
|
|
!tools.contains(&"Bash"),
|
|
"{permissions:?} must not grant Bash to the parent"
|
|
);
|
|
}
|
|
}
|
|
}
|