subagent: grant claude's Bash when the agent holds the execution group
A subagent inherited the parent's built-in tool list, which correctly has no `Bash` -- the agent reaches a shell through the `bash` MCP server, not the built-in. Subagents get no such server, so the intersection was empty and they could not run a command at all: no commits, no pushes, no gates. Add `subagent_builtin_tools_for`/`_arg`, which reuse the shared resolver and append `Bash` only when `Execution` -- the group that gates the `bash` MCP server -- is present. Only the subagent spawn path calls them, so the harness's own `--tools`/`--allowedTools` are unchanged. The capability transfers; the mechanism does not. Refs #4422
This commit is contained in:
parent
9cd30a58ba
commit
5ce0a357b4
4 changed files with 122 additions and 32 deletions
|
|
@ -9,7 +9,11 @@
|
|||
//! 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.
|
||||
//! 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;
|
||||
|
||||
|
|
@ -87,6 +91,40 @@ 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
|
||||
|
|
@ -464,4 +502,41 @@ mod tests {
|
|||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue