193 lines
8.9 KiB
Rust
193 lines
8.9 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`].
|
|
//!
|
|
//! 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 hive_types::Permission;
|
|
|
|
/// 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
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// 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"
|
|
);
|
|
}
|
|
}
|
|
}
|