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:
atlas 2026-09-15 18:47:27 +02:00
commit 5ce0a357b4
4 changed files with 122 additions and 32 deletions

View file

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