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
|
|
@ -836,16 +836,15 @@ fn subagent_otel_attrs(name: &str) -> String {
|
|||
/// same "cheaper than you" cost-consciousness the `base:claude-subagents`
|
||||
/// skill already asks of `model`. `prompt_file`, when given, becomes
|
||||
/// `--append-system-prompt-file` — the subagent's task instructions.
|
||||
/// `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
||||
/// the caller already prepared); `None` inherits this daemon's own working
|
||||
/// directory, same as before this field existed. Always
|
||||
/// `--dangerously-skip-permissions --strict-mcp-config --tools` — two gates
|
||||
/// covering one half each, neither substituting for the other:
|
||||
/// `dir`, when given, becomes `Config::cwd` (e.g. a worktree the caller
|
||||
/// already prepared); `None` inherits this daemon's own working directory.
|
||||
/// Always `--dangerously-skip-permissions --strict-mcp-config --tools` — two
|
||||
/// gates covering one half each, neither substituting for the other:
|
||||
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
|
||||
/// what [`crate::mcp_config::build`] renders), `--tools` over **built-in**
|
||||
/// ones, via [`hive_sh4re::permissions::builtin_tools_arg`] — the parent
|
||||
/// agent's own resolved set, never wider. `--tools` does not filter
|
||||
/// `mcp__*`, so the signal tools are unnamed in it (`docs/tools/subagent.md`).
|
||||
/// ones, via [`hive_sh4re::permissions::subagent_builtin_tools_arg`], which
|
||||
/// documents its one `Bash` exception. `--tools` does not filter `mcp__*`,
|
||||
/// so the signal tools are unnamed in it (`docs/tools/subagent.md`).
|
||||
///
|
||||
/// `signal_url` is what makes `goal_reached`/`need_help` callable at all: a
|
||||
/// subagent reaches them over the same streamable-http listener its parent
|
||||
|
|
@ -865,7 +864,7 @@ fn build_config(
|
|||
dir: Option<&str>,
|
||||
signal_url: Option<&str>,
|
||||
) -> Config {
|
||||
let tools = hive_sh4re::permissions::builtin_tools_arg();
|
||||
let tools = hive_sh4re::permissions::subagent_builtin_tools_arg();
|
||||
// Refuse to spawn on an empty `--tools` rather than reason about what it
|
||||
// would do: what an empty value means is disputed and depends on the
|
||||
// installed claude release (see `builtin_tools_arg`). Nothing here wants
|
||||
|
|
@ -2017,13 +2016,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// A subagent's built-ins are a subset of its parent's resolved set —
|
||||
/// the property that makes this safe to ship, since a subagent that can
|
||||
/// reach a tool its parent cannot is a privilege escalation dressed as a
|
||||
/// convenience. Both sides are read from
|
||||
/// `hive_sh4re::permissions`, deliberately: it is the parent harness's
|
||||
/// own resolver, so this compares against what the parent actually gets
|
||||
/// rather than against a restatement of it that could drift.
|
||||
/// A subagent's built-ins are a subset of `subagent_builtin_tools_for` —
|
||||
/// the parent's own resolved set, plus `Bash` iff the parent holds
|
||||
/// `Execution` (see that function's doc comment). This is the property
|
||||
/// that makes shipping the daemon safe: a subagent reaching a tool
|
||||
/// outside that resolution is a privilege escalation dressed as a
|
||||
/// convenience. Read from `hive_sh4re::permissions`, deliberately: it is
|
||||
/// the parent harness's own resolver, so this compares against what the
|
||||
/// parent actually licenses rather than against a restatement of it that
|
||||
/// could drift.
|
||||
///
|
||||
/// ⚠️ **Scope: this catches a code divergence, not an environment one.**
|
||||
/// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it
|
||||
|
|
@ -2032,14 +2033,14 @@ mod tests {
|
|||
/// Nothing in a unit test reaches that; it is a deployment property.
|
||||
#[test]
|
||||
fn a_subagent_gets_no_builtin_its_parent_lacks() {
|
||||
let parent = hive_sh4re::permissions::builtin_tools_for(
|
||||
let allowed = hive_sh4re::permissions::subagent_builtin_tools_for(
|
||||
&hive_sh4re::permissions::effective_tool_groups(),
|
||||
);
|
||||
let config = build_config("n", None, None, None, None, None);
|
||||
for tool in spawned_tools(&config).split(',') {
|
||||
assert!(
|
||||
parent.contains(&tool),
|
||||
"subagent got {tool}, which the parent's resolved set does not have"
|
||||
allowed.contains(&tool),
|
||||
"subagent got {tool}, which the parent's resolved set does not license"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue