Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-sh4re/src/permissions.rs

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"
);
}
}
}