Compare commits
9 changed files with 102 additions and 428 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -1972,7 +1972,6 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"strum",
|
"strum",
|
||||||
"tracing",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1995,7 +1994,6 @@ dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"hive-agent-sock",
|
"hive-agent-sock",
|
||||||
"hive-claude",
|
"hive-claude",
|
||||||
"hive-sh4re",
|
|
||||||
"hive-sock-client",
|
"hive-sock-client",
|
||||||
"hive-types",
|
"hive-types",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
|
||||||
|
|
@ -218,55 +218,13 @@ agent that needs a stable or non-default port.
|
||||||
Own systemd unit, defined alongside the other per-agent MCP daemons in
|
Own systemd unit, defined alongside the other per-agent MCP daemons in
|
||||||
`nix/agent-modules/mcp.nix`.
|
`nix/agent-modules/mcp.nix`.
|
||||||
|
|
||||||
## The tool surface a subagent gets
|
## MCP servers available to a subagent
|
||||||
|
|
||||||
Two flags, each covering one half, and neither covering the other:
|
|
||||||
`--tools` governs claude's built-in tools, `--strict-mcp-config` governs
|
|
||||||
the MCP ones. Dropping either brings that half back in full; in
|
|
||||||
particular `--tools` does **not** filter `mcp__*` tools.
|
|
||||||
|
|
||||||
### Built-in tools (`--tools`)
|
|
||||||
|
|
||||||
**A subagent gets exactly the built-ins its parent agent has** — the same
|
|
||||||
list, resolved by the same function
|
|
||||||
(`hive_sh4re::permissions::builtin_tools_arg`) from the same
|
|
||||||
`HIVE_TOOL_GROUPS`: `Edit`, `Glob`, `Grep`, `Read`, `Skill`,
|
|
||||||
`Write`, plus `WebFetch`/`WebSearch` for an agent granted the `web_tools`
|
|
||||||
tool group and not otherwise. See
|
|
||||||
[the harness's own allowlist](../turn-loop/mcp.md#tool-allowlist-hive_sh4repermissionsallowed_builtin_tools)
|
|
||||||
for what that list contains and why.
|
|
||||||
|
|
||||||
Inheriting rather than listing is the point: a hardcoded subagent list
|
|
||||||
would hand web egress to the subagent of an agent that isn't allowed web
|
|
||||||
egress, and would diverge from the parent's on the first tool anyone adds
|
|
||||||
to either.
|
|
||||||
|
|
||||||
Everything else in claude's built-in set is absent, in particular the
|
|
||||||
tools that let a session act outside the run it was started for: peer and
|
|
||||||
operator messaging, nested agents (including the stop verb, which takes
|
|
||||||
an _agent_ id rather than a session), schedule and webhook creation, and
|
|
||||||
worktree switching. Before this flag was passed, a subagent reached all of
|
|
||||||
them — `--dangerously-skip-permissions` had removed the only thing that
|
|
||||||
would have asked, and `--allowedTools` would not have helped: it approves
|
|
||||||
prompts in advance rather than restricting anything.
|
|
||||||
|
|
||||||
One rule worth knowing before editing any of this: **the daemon never
|
|
||||||
emits an empty `--tools` value**, and asserts rather than doing so. Not
|
|
||||||
because of what an empty value does — that's exactly the point. Our own
|
|
||||||
measurement and the installed `claude --help` disagree about whether
|
|
||||||
`--tools ""` means "no tools" or reads as the flag being absent, and the
|
|
||||||
answer belongs to whichever claude release is installed rather than to
|
|
||||||
this repo. Refusing the case is correct under either reading, so the code
|
|
||||||
never has to know which holds. Nothing here wants a subagent with no
|
|
||||||
built-in tools anyway.
|
|
||||||
|
|
||||||
### MCP servers (`--strict-mcp-config`)
|
|
||||||
|
|
||||||
A subagent runs with `--strict-mcp-config` and, by default, exactly one
|
A subagent runs with `--strict-mcp-config` and, by default, exactly one
|
||||||
MCP server: the two-tool `subagent_control` route above — not the
|
MCP server: the two-tool `subagent_control` route above. It otherwise
|
||||||
parent's `mcp__bash__*` / `mcp__hyperhive__*` surface. Those two signal
|
falls back to claude's own native tools (`Bash`, `WebFetch`, etc.), not
|
||||||
tools are deliberately unnamed in `--tools`, which doesn't govern them;
|
the parent's `mcp__bash__*` / `mcp__hyperhive__*` surface. Nothing
|
||||||
they survive on this flag alone. Nothing implicit reaches it: the built-in
|
implicit reaches it: the built-in
|
||||||
hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at
|
hyperhive surface (todos/messaging) isn't an `extraMcpServers` entry at
|
||||||
all, and the automatically injected `bash`/`subagent` entries default to excluded
|
all, and the automatically injected `bash`/`subagent` entries default to excluded
|
||||||
too (a subagent can't spawn hive-bash tasks or its own nested subagents
|
too (a subagent can't spawn hive-bash tasks or its own nested subagents
|
||||||
|
|
|
||||||
|
|
@ -203,12 +203,7 @@ than deriving from SSE events.
|
||||||
runs the body, logs the result. Pre-/post-log only — the inbox
|
runs the body, logs the result. Pre-/post-log only — the inbox
|
||||||
status hint lives in the wake prompt + UI header, not here.
|
status hint lives in the wake prompt + UI header, not here.
|
||||||
|
|
||||||
## Tool allowlist (`hive_sh4re::permissions::ALLOWED_BUILTIN_TOOLS`)
|
## Tool allowlist (`mcp_config::ALLOWED_BUILTIN_TOOLS`)
|
||||||
|
|
||||||
The built-in list and the `--tools` value it resolves to live in
|
|
||||||
`hive-sh4re` alongside `ToolGroup`, not in the harness, because the
|
|
||||||
subagent daemon spawns its own `claude` and must resolve the same set —
|
|
||||||
see [`docs/tools/subagent.md`](../tools/subagent.md).
|
|
||||||
|
|
||||||
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`.
|
- Allowed built-ins: `Edit`, `Glob`, `Grep`, `Read`, `Skill`, `Write`.
|
||||||
`Skill` is what makes an installed plugin's `SKILL.md` invokable —
|
`Skill` is what makes an installed plugin's `SKILL.md` invokable —
|
||||||
|
|
|
||||||
|
|
@ -18,15 +18,27 @@ pub const SERVER_NAME: &str = "hyperhive";
|
||||||
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
|
/// so `127.0.0.1:<port>` is per-container-private (no cross-agent collision).
|
||||||
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
|
pub const DEFAULT_MCP_HTTP_PORT: u16 = 8790;
|
||||||
|
|
||||||
/// The built-in tool surface — the base list, the group-gated additions, the
|
/// Built-in claude tools always present in every session. Anything not
|
||||||
/// `HIVE_TOOL_GROUPS` parse, and the `--tools` value they resolve to — lives
|
/// in this list (or added by `extra_builtin_tools`) literally doesn't
|
||||||
/// in `hive_sh4re::permissions`, next to `ToolGroup` itself. Re-exported here
|
/// exist in the session. Web egress (`WebFetch`/`WebSearch`) are
|
||||||
/// because this module is where the rest of the claude launch config is
|
/// tool-group-gated (`web_tools`) — off by default. Nested agents
|
||||||
/// assembled, and because the subagent daemon (`hive-subagent-mcp`) spawns
|
/// (`Task`/`Agent`) are intentionally omitted. `Bash` is disallowed — shell
|
||||||
/// its own `claude` from the same resolution: `hive-agent` is a binary-only
|
/// execution goes through `mcp__bash__run` (background tasks
|
||||||
/// crate with no lib target, so a shared home was the only way for both
|
/// with structured output via `hive-bash-mcp`) instead of a raw interactive shell. `TodoWrite`
|
||||||
/// spawners to read one list rather than two that drift.
|
/// is omitted because the todo list lives in claude's in-process session
|
||||||
pub use hive_sh4re::permissions::{builtin_tools_arg, effective_tool_groups};
|
/// 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"];
|
||||||
|
|
||||||
|
/// Env var written by the meta renderer with a comma-separated list of
|
||||||
|
/// `hive_sh4re::permissions::ToolGroup` `snake_case` names (e.g. `"messaging,inbox,meta"`).
|
||||||
|
/// When present, the harness expands the groups into per-tool allow entries
|
||||||
|
/// instead of using the hardcoded flavor default. See `docs/process/conventions.md::Tool groups`.
|
||||||
|
const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
||||||
|
|
||||||
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
/// `HIVE_CAPABILITIES` env var injected by `meta::render_flake` when the
|
||||||
/// operator grants capabilities to this agent. Comma-separated
|
/// operator grants capabilities to this agent. Comma-separated
|
||||||
|
|
@ -61,6 +73,40 @@ fn allowed_capability_tools() -> Vec<String> {
|
||||||
tools
|
tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the active tool groups for a harness session.
|
||||||
|
///
|
||||||
|
/// Reads `HIVE_TOOL_GROUPS` from the environment first. Each comma-separated
|
||||||
|
/// token is matched (case-insensitive) against the `ToolGroup` serde names
|
||||||
|
/// (`messaging`, `meta`, `inbox`, `lifecycle`, `approvals`, `scheduling`,
|
||||||
|
/// `diagnostics`, `execution`). Unrecognised tokens are logged and skipped.
|
||||||
|
/// Falls back to `AGENT_DEFAULT` when the env var is absent or empty.
|
||||||
|
fn effective_tool_groups() -> Vec<hive_sh4re::permissions::ToolGroup> {
|
||||||
|
let raw = match std::env::var(TOOL_GROUPS_ENV) {
|
||||||
|
Ok(v) if !v.trim().is_empty() => v,
|
||||||
|
_ => return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec(),
|
||||||
|
};
|
||||||
|
let mut groups = Vec::new();
|
||||||
|
for token in raw.split(',') {
|
||||||
|
let t = token.trim().to_ascii_lowercase();
|
||||||
|
// Parse via serde_json (the canonical deserialization path).
|
||||||
|
if let Ok(g) = serde_json::from_value::<hive_sh4re::permissions::ToolGroup>(
|
||||||
|
serde_json::Value::String(t.clone()),
|
||||||
|
) {
|
||||||
|
groups.push(g);
|
||||||
|
} else {
|
||||||
|
tracing::warn!(token = %t, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if groups.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
||||||
|
falling back to AGENT_DEFAULT"
|
||||||
|
);
|
||||||
|
return hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT.to_vec();
|
||||||
|
}
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
|
/// Tool group an extra (out-of-process) MCP server is gated behind, if any.
|
||||||
///
|
///
|
||||||
/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever
|
/// Most `hyperhive.extraMcpServers` entries are ungated — available whenever
|
||||||
|
|
@ -154,12 +200,19 @@ pub fn allowed_mcp_tools(groups: &[hive_sh4re::permissions::ToolGroup]) -> Vec<S
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn allowed_tools_arg() -> String {
|
pub fn allowed_tools_arg() -> String {
|
||||||
let groups = effective_tool_groups();
|
let groups = effective_tool_groups();
|
||||||
// The same built-ins `--tools` makes exist this session, so a tool that
|
// Base built-ins always present.
|
||||||
// exists is never one claude has to prompt about.
|
let mut all: Vec<String> = ALLOWED_BUILTIN_TOOLS
|
||||||
let mut all: Vec<String> = hive_sh4re::permissions::builtin_tools_for(&groups)
|
.iter()
|
||||||
.into_iter()
|
.map(|s| (*s).to_owned())
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.collect();
|
.collect();
|
||||||
|
// Extra built-ins gated by tool groups (e.g. WebFetch/WebSearch via web_tools).
|
||||||
|
for group in &groups {
|
||||||
|
for tool in group.builtin_tools() {
|
||||||
|
if !all.iter().any(|t| t == *tool) {
|
||||||
|
all.push((*tool).to_owned());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
all.extend(allowed_mcp_tools(&groups));
|
all.extend(allowed_mcp_tools(&groups));
|
||||||
// Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES
|
// Capability-gated MCP tools: added to --allowedTools when HIVE_CAPABILITIES
|
||||||
// includes the corresponding capability. hive-c0re performs a second
|
// includes the corresponding capability. hive-c0re performs a second
|
||||||
|
|
@ -171,6 +224,23 @@ pub fn allowed_tools_arg() -> String {
|
||||||
all.join(",")
|
all.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Built-in tools list for `--tools` (which built-ins exist in this
|
||||||
|
/// session). Base set plus any group-gated built-ins (e.g.
|
||||||
|
/// `WebFetch`/`WebSearch` when the `web_tools` group is active).
|
||||||
|
#[must_use]
|
||||||
|
pub fn builtin_tools_arg() -> String {
|
||||||
|
let groups = effective_tool_groups();
|
||||||
|
let mut tools: Vec<&str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
||||||
|
for group in &groups {
|
||||||
|
for t in group.builtin_tools() {
|
||||||
|
if !tools.contains(t) {
|
||||||
|
tools.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tools.join(",")
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
/// The built-in `hyperhive` surface is an HTTP entry pointing at the
|
||||||
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
/// persistent `hive-mcp-http` daemon (see [`DEFAULT_MCP_HTTP_PORT`]); there
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,6 @@ hive-types.workspace = true
|
||||||
schemars.workspace = true
|
schemars.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
strum.workspace = true
|
strum.workspace = true
|
||||||
# Facade only, for the one warn in `permissions::ToolGroup::parse_list`: an
|
|
||||||
# unknown tool-group name is skipped rather than fatal, so the log line is the
|
|
||||||
# only trace it leaves.
|
|
||||||
tracing.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -3,108 +3,16 @@
|
||||||
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected
|
//! per-agent config (`tool-groups.json` / `capabilities.json`), injected
|
||||||
//! into the container as env vars, and read by the harness to decide
|
//! into the container as env vars, and read by the harness to decide
|
||||||
//! which MCP tools claude actually sees.
|
//! which MCP tools claude actually sees.
|
||||||
//!
|
|
||||||
//! It also resolves the *built-in* claude tool list ([`builtin_tools_arg`]):
|
|
||||||
//! that set is group-gated too (`web_tools`), so it belongs next to the
|
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Env var the meta renderer writes with a comma-separated list of
|
|
||||||
/// [`ToolGroup`] `snake_case` names (e.g. `"messaging,inbox,meta"`). Absent
|
|
||||||
/// or unparseable means [`ToolGroup::AGENT_DEFAULT`]. See
|
|
||||||
/// `docs/process/conventions.md::Tool groups`.
|
|
||||||
pub const TOOL_GROUPS_ENV: &str = "HIVE_TOOL_GROUPS";
|
|
||||||
|
|
||||||
/// Built-in claude tools present in every session regardless of tool
|
|
||||||
/// groups. Anything not in this list (and not added by a group's
|
|
||||||
/// [`ToolGroup::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 group-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 tool groups in effect for this process, from [`TOOL_GROUPS_ENV`].
|
|
||||||
///
|
|
||||||
/// Absent, blank, or naming nothing recognisable all fall back to
|
|
||||||
/// [`ToolGroup::AGENT_DEFAULT`] — the var is written per agent by the meta
|
|
||||||
/// renderer, so "unset" is an ordinary state, not an error.
|
|
||||||
#[must_use]
|
|
||||||
pub fn effective_tool_groups() -> Vec<ToolGroup> {
|
|
||||||
ToolGroup::parse_list(&std::env::var(TOOL_GROUPS_ENV).unwrap_or_default())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The built-in claude tools `groups` resolve to: [`ALLOWED_BUILTIN_TOOLS`]
|
|
||||||
/// plus each group's own built-ins, de-duplicated, base set first.
|
|
||||||
#[must_use]
|
|
||||||
pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
|
|
||||||
let mut tools: Vec<&'static str> = ALLOWED_BUILTIN_TOOLS.to_vec();
|
|
||||||
for group in groups {
|
|
||||||
for tool in group.builtin_tools() {
|
|
||||||
if !tools.contains(tool) {
|
|
||||||
tools.push(tool);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tools
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The value for claude's `--tools` flag: which built-in tools exist in this
|
|
||||||
/// session at all.
|
|
||||||
///
|
|
||||||
/// ⚠️ **Never emit this as an empty string** — 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, not of this code. So an empty
|
|
||||||
/// value is refused rather than relied on: the rule holds under either
|
|
||||||
/// reading, and no caller has to know which one is true today.
|
|
||||||
/// [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which keeps the case from
|
|
||||||
/// arising in the first place.
|
|
||||||
///
|
|
||||||
/// ⚠️ 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_arg() -> String {
|
|
||||||
builtin_tools_for(&effective_tool_groups()).join(",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Named group of MCP tools an agent may be granted. The harness reads
|
/// Named group of MCP tools an agent may be granted. The harness reads
|
||||||
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
|
/// `HIVE_TOOL_GROUPS` from the environment (a comma-separated list of
|
||||||
/// `snake_case` group names written by the meta renderer from per-agent
|
/// `snake_case` group names written by the meta renderer from per-agent
|
||||||
/// config) and expands it to the matching tool names for `--allowedTools`.
|
/// config) and expands it to the matching tool names for `--allowedTools`.
|
||||||
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
|
/// When the env var is absent the harness falls back to `AGENT_DEFAULT`.
|
||||||
/// See `docs/process/conventions.md::Tool groups`.
|
/// See `docs/process/conventions.md::Tool groups`.
|
||||||
#[derive(
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::IntoStaticStr)]
|
||||||
Debug,
|
|
||||||
Clone,
|
|
||||||
Copy,
|
|
||||||
PartialEq,
|
|
||||||
Eq,
|
|
||||||
Hash,
|
|
||||||
Serialize,
|
|
||||||
Deserialize,
|
|
||||||
strum::IntoStaticStr,
|
|
||||||
strum::EnumString,
|
|
||||||
)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
#[strum(serialize_all = "snake_case")]
|
#[strum(serialize_all = "snake_case")]
|
||||||
pub enum ToolGroup {
|
pub enum ToolGroup {
|
||||||
|
|
@ -142,42 +50,6 @@ pub enum ToolGroup {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolGroup {
|
impl ToolGroup {
|
||||||
/// Parse a comma-separated [`TOOL_GROUPS_ENV`] value. Tokens are trimmed
|
|
||||||
/// and lowercased; an unrecognised one is warned about and skipped
|
|
||||||
/// rather than failing the whole list, so one stale name in an agent's
|
|
||||||
/// config can't take its whole tool surface away.
|
|
||||||
///
|
|
||||||
/// Returns [`AGENT_DEFAULT`](Self::AGENT_DEFAULT) when `raw` is blank or
|
|
||||||
/// names nothing recognisable — the same fallback either way, because
|
|
||||||
/// "the operator granted no groups" and "the var is missing" are not
|
|
||||||
/// distinguishable here and neither should mean "no tools".
|
|
||||||
#[must_use]
|
|
||||||
pub fn parse_list(raw: &str) -> Vec<Self> {
|
|
||||||
let mut groups = Vec::new();
|
|
||||||
for token in raw.split(',') {
|
|
||||||
let token = token.trim().to_ascii_lowercase();
|
|
||||||
if token.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
match Self::from_str(&token) {
|
|
||||||
Ok(group) => groups.push(group),
|
|
||||||
Err(_) => {
|
|
||||||
tracing::warn!(token = %token, "{TOOL_GROUPS_ENV}: unknown tool group, skipping");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groups.is_empty() {
|
|
||||||
if !raw.trim().is_empty() {
|
|
||||||
tracing::warn!(
|
|
||||||
"{TOOL_GROUPS_ENV} set but contained no recognised groups; \
|
|
||||||
falling back to AGENT_DEFAULT"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Self::AGENT_DEFAULT.to_vec();
|
|
||||||
}
|
|
||||||
groups
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
|
/// The MCP tool names (without the `mcp__hyperhive__` prefix) in this group.
|
||||||
/// Returns `&[]` for `WebTools` — it enables Claude built-in tools,
|
/// Returns `&[]` for `WebTools` — it enables Claude built-in tools,
|
||||||
/// not MCP tools; see `builtin_tools()`.
|
/// not MCP tools; see `builtin_tools()`.
|
||||||
|
|
@ -373,95 +245,3 @@ impl Capability {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// The env-var spellings the meta renderer actually writes must round-trip
|
|
||||||
/// back to groups. `strum::EnumString` and serde's `rename_all` are two
|
|
||||||
/// independent derives over the same names; this pins them together, since
|
|
||||||
/// the renderer writes what serde would produce and the parse is strum's.
|
|
||||||
#[test]
|
|
||||||
fn parse_list_accepts_the_snake_case_names_the_renderer_writes() {
|
|
||||||
assert_eq!(
|
|
||||||
ToolGroup::parse_list("messaging, Meta ,web_tools"),
|
|
||||||
vec![ToolGroup::Messaging, ToolGroup::Meta, ToolGroup::WebTools]
|
|
||||||
);
|
|
||||||
for group in ToolGroup::ALL {
|
|
||||||
let name: &'static str = (*group).into();
|
|
||||||
assert_eq!(ToolGroup::parse_list(name), vec![*group], "{name}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blank, absent and all-garbage are the same state as far as the tool
|
|
||||||
/// surface is concerned: the default groups, never an empty list.
|
|
||||||
#[test]
|
|
||||||
fn parse_list_falls_back_to_the_agent_default_rather_than_nothing() {
|
|
||||||
for raw in ["", " ", ",,", "not_a_group,also_not"] {
|
|
||||||
assert_eq!(
|
|
||||||
ToolGroup::parse_list(raw),
|
|
||||||
ToolGroup::AGENT_DEFAULT.to_vec(),
|
|
||||||
"{raw:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adding groups only ever adds built-ins — the base set is in every
|
|
||||||
/// resolution, and no group 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_groups_add() {
|
|
||||||
assert_eq!(
|
|
||||||
builtin_tools_for(&[]),
|
|
||||||
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
||||||
"no groups resolves to exactly the base set"
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
builtin_tools_for(ToolGroup::AGENT_DEFAULT),
|
|
||||||
ALLOWED_BUILTIN_TOOLS.to_vec(),
|
|
||||||
"the default groups add no built-ins"
|
|
||||||
);
|
|
||||||
for group in ToolGroup::ALL {
|
|
||||||
let resolved = builtin_tools_for(&[*group]);
|
|
||||||
for base in ALLOWED_BUILTIN_TOOLS {
|
|
||||||
assert!(resolved.contains(base), "{group:?} dropped {base}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `web_tools` is the one group 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
|
|
||||||
/// group.
|
|
||||||
#[test]
|
|
||||||
fn web_egress_is_present_only_with_the_web_tools_group() {
|
|
||||||
let with = builtin_tools_for(&[ToolGroup::WebTools]);
|
|
||||||
assert!(with.contains(&"WebFetch") && with.contains(&"WebSearch"));
|
|
||||||
let without = builtin_tools_for(ToolGroup::MANAGER_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_arg`]),
|
|
||||||
/// so this pins the rule that makes the question moot rather than any
|
|
||||||
/// answer to it.
|
|
||||||
#[test]
|
|
||||||
fn no_group_combination_resolves_to_an_empty_tools_arg() {
|
|
||||||
assert!(!ALLOWED_BUILTIN_TOOLS.is_empty());
|
|
||||||
assert!(!builtin_tools_for(&[]).is_empty());
|
|
||||||
assert!(!builtin_tools_for(ToolGroup::ALL).is_empty());
|
|
||||||
assert!(!builtin_tools_arg().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(ToolGroup::ALL) {
|
|
||||||
assert!(!tool.contains("__"), "{tool} looks like an MCP tool name");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,6 @@ axum.workspace = true
|
||||||
clap.workspace = true
|
clap.workspace = true
|
||||||
hive-agent-sock.workspace = true
|
hive-agent-sock.workspace = true
|
||||||
hive-claude.workspace = true
|
hive-claude.workspace = true
|
||||||
# `permissions::builtin_tools_arg` — the same `--tools` resolution the parent
|
|
||||||
# harness spawns its own claude with, so a subagent's built-in surface is its
|
|
||||||
# parent's rather than a second list that drifts. See `session::build_config`.
|
|
||||||
hive-sh4re.workspace = true
|
|
||||||
hive-sock-client.workspace = true
|
hive-sock-client.workspace = true
|
||||||
hive-types.workspace = true
|
hive-types.workspace = true
|
||||||
libc.workspace = true
|
libc.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -834,18 +834,18 @@ fn subagent_otel_attrs(name: &str) -> String {
|
||||||
/// (`high` on most models) — a deliberate hive policy for subagent work
|
/// (`high` on most models) — a deliberate hive policy for subagent work
|
||||||
/// specifically, not a reflection of Anthropic's own recommendation, on the
|
/// specifically, not a reflection of Anthropic's own recommendation, on the
|
||||||
/// same "cheaper than you" cost-consciousness the `base:claude-subagents`
|
/// same "cheaper than you" cost-consciousness the `base:claude-subagents`
|
||||||
/// skill already asks of `model`. `prompt_file`, when given, becomes
|
/// skill already asks of `model`. `prompt_file`, when
|
||||||
/// `--append-system-prompt-file` — the subagent's task instructions.
|
/// given, becomes `--append-system-prompt-file` — the subagent's task
|
||||||
/// `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
/// instructions. `dir`, when given, becomes `Config::cwd` (e.g. a worktree
|
||||||
/// the caller already prepared); `None` inherits this daemon's own working
|
/// the caller already prepared); `None` inherits this daemon's own working
|
||||||
/// directory, same as before this field existed. Always
|
/// directory, same as before this field existed. Always
|
||||||
/// `--dangerously-skip-permissions --strict-mcp-config --tools` — two gates
|
/// `--dangerously-skip-permissions --strict-mcp-config` — the safety
|
||||||
/// covering one half each, neither substituting for the other:
|
/// property is `strict_mcp_config: true` with no ambient MCP discovery, not
|
||||||
/// `strict_mcp_config` over **MCP** tools (no ambient discovery, so exactly
|
/// an unconditional absence of `--mcp-config`: a subagent gets exactly the
|
||||||
/// what [`crate::mcp_config::build`] renders), `--tools` over **built-in**
|
/// `hyperhive.extraMcpServers` entries an operator has explicitly opted in
|
||||||
/// ones, via [`hive_sh4re::permissions::builtin_tools_arg`] — the parent
|
/// via `availableToSubagents = true` (`crate::mcp_config::build`), plus this
|
||||||
/// agent's own resolved set, never wider. `--tools` does not filter
|
/// daemon's own two-tool signal surface when `signal_url` is given — nothing
|
||||||
/// `mcp__*`, so the signal tools are unnamed in it (`docs/tools/subagent.md`).
|
/// implicit and nothing more.
|
||||||
///
|
///
|
||||||
/// `signal_url` is what makes `goal_reached`/`need_help` callable at all: a
|
/// `signal_url` is what makes `goal_reached`/`need_help` callable at all: a
|
||||||
/// subagent reaches them over the same streamable-http listener its parent
|
/// subagent reaches them over the same streamable-http listener its parent
|
||||||
|
|
@ -865,22 +865,7 @@ fn build_config(
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
signal_url: Option<&str>,
|
signal_url: Option<&str>,
|
||||||
) -> Config {
|
) -> Config {
|
||||||
let tools = hive_sh4re::permissions::builtin_tools_arg();
|
let mut extra_args = vec!["--dangerously-skip-permissions".to_owned()];
|
||||||
// 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
|
|
||||||
// "a subagent with no built-ins" anyway, so an empty resolution is a bug
|
|
||||||
// either way, and failing on it is safe under every reading.
|
|
||||||
assert!(
|
|
||||||
!tools.is_empty(),
|
|
||||||
"resolved an empty --tools value — a subagent is never spawned on one, \
|
|
||||||
since what an empty value grants is release-dependent"
|
|
||||||
);
|
|
||||||
let mut extra_args = vec![
|
|
||||||
"--dangerously-skip-permissions".to_owned(),
|
|
||||||
"--tools".to_owned(),
|
|
||||||
tools,
|
|
||||||
];
|
|
||||||
if let Some(path) = prompt_file {
|
if let Some(path) = prompt_file {
|
||||||
extra_args.push("--append-system-prompt-file".to_owned());
|
extra_args.push("--append-system-prompt-file".to_owned());
|
||||||
extra_args.push(path.to_owned());
|
extra_args.push(path.to_owned());
|
||||||
|
|
@ -1983,100 +1968,6 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The `--tools` value as it reaches the spawned argv.
|
|
||||||
fn spawned_tools(config: &Config) -> &str {
|
|
||||||
let flag = config
|
|
||||||
.extra_args
|
|
||||||
.iter()
|
|
||||||
.position(|a| a == "--tools")
|
|
||||||
.expect("--tools is passed on every spawn");
|
|
||||||
config
|
|
||||||
.extra_args
|
|
||||||
.get(flag + 1)
|
|
||||||
.expect("--tools carries a value")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `--tools` reaches the argv on every caller shape, carrying a non-empty
|
|
||||||
/// value. Non-empty matters separately from "the flag is there": what an
|
|
||||||
/// empty value grants is disputed and release-dependent, so we never ship
|
|
||||||
/// one and never depend on the answer. Checked for each shape because a
|
|
||||||
/// flag that appears on only some spawn paths is no restriction at all.
|
|
||||||
/// `signal_url` stays `None` throughout — it only steers `mcp_config`,
|
|
||||||
/// which `--tools` does not govern, and passing it would make this test
|
|
||||||
/// need the container's injected `HYPERHIVE_HARNESS_DIR`.
|
|
||||||
#[test]
|
|
||||||
fn build_config_always_passes_a_non_empty_tools_list() {
|
|
||||||
for config in [
|
|
||||||
build_config("n", None, None, None, None, None),
|
|
||||||
build_config("n", None, None, Some("/tmp/p.md"), None, None),
|
|
||||||
build_config("n", None, None, None, Some("/tmp/wt"), None),
|
|
||||||
] {
|
|
||||||
let tools = spawned_tools(&config);
|
|
||||||
assert!(!tools.is_empty(), "--tools must never be empty");
|
|
||||||
assert!(tools.split(',').all(|t| !t.trim().is_empty()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
///
|
|
||||||
/// ⚠️ **Scope: this catches a code divergence, not an environment one.**
|
|
||||||
/// Both sides resolve in one process off one `HIVE_TOOL_GROUPS`, so it
|
|
||||||
/// cannot see the two real units disagreeing about that var — which they
|
|
||||||
/// did until `nix/agent-modules/mcp.nix` forwarded it onto this daemon.
|
|
||||||
/// 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(
|
|
||||||
&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"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Named individually rather than inferred from "the list is short": the
|
|
||||||
/// tools this daemon used to hand a subagent that reach outside the run
|
|
||||||
/// it was started for. They are absent because the parent has never had
|
|
||||||
/// them — this pins the consequence, not a second allow-list.
|
|
||||||
///
|
|
||||||
/// ⚠️ **A canary, not a guarantee.** The names are hardcoded, so this
|
|
||||||
/// only fails for a tool someone thought to list here; the *guarantee*
|
|
||||||
/// is the subset property above, which holds for tools nobody has
|
|
||||||
/// imagined yet. Do not read a pass as "no escaping tool is reachable" —
|
|
||||||
/// read it as "none of these ten came back".
|
|
||||||
#[test]
|
|
||||||
fn no_spawned_tool_escapes_the_session() {
|
|
||||||
let config = build_config("n", None, None, None, None, None);
|
|
||||||
let tools: Vec<&str> = spawned_tools(&config).split(',').collect();
|
|
||||||
for escaping in [
|
|
||||||
"SendMessage",
|
|
||||||
"ListAgents",
|
|
||||||
"Task",
|
|
||||||
"TaskStop",
|
|
||||||
"TaskOutput",
|
|
||||||
"CronCreate",
|
|
||||||
"CronDelete",
|
|
||||||
"RemoteTrigger",
|
|
||||||
"EnterWorktree",
|
|
||||||
"ExitWorktree",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
!tools.contains(&escaping),
|
|
||||||
"{escaping} must not be in a subagent's --tools"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_dir_remembers_an_explicit_dir_and_falls_back_to_it_when_omitted() {
|
fn resolve_dir_remembers_an_explicit_dir_and_falls_back_to_it_when_omitted() {
|
||||||
let state = State::new(PathBuf::from("/dev/null"), signal_url());
|
let state = State::new(PathBuf::from("/dev/null"), signal_url());
|
||||||
|
|
|
||||||
|
|
@ -378,16 +378,6 @@ in
|
||||||
# HIVE_AGENT_SOCKET (agent-service.nix).
|
# HIVE_AGENT_SOCKET (agent-service.nix).
|
||||||
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
HIVE_AGENT_SOCKET = "/run/hive-agent/${userName}/agent.sock";
|
||||||
RUST_LOG = "info";
|
RUST_LOG = "info";
|
||||||
# This daemon resolves each subagent's `--tools` from the same
|
|
||||||
# tool groups the harness resolves its own session from, so that a
|
|
||||||
# subagent's built-in tools are its parent agent's and never wider.
|
|
||||||
# The meta renderer writes this var onto the harness unit alone, so
|
|
||||||
# forward it from there rather than re-deriving it: without it the
|
|
||||||
# daemon falls back to the default groups and an agent granted
|
|
||||||
# `web_tools` would spawn subagents that quietly lack web access.
|
|
||||||
# `null` when the agent has no groups declared, which systemd drops
|
|
||||||
# — the same "absent" the harness itself would see.
|
|
||||||
HIVE_TOOL_GROUPS = config.systemd.services.hive-agent.environment.HIVE_TOOL_GROUPS or null;
|
|
||||||
# HYPERHIVE_HARNESS_DIR / HYPERHIVE_STATE_DIR: see
|
# HYPERHIVE_HARNESS_DIR / HYPERHIVE_STATE_DIR: see
|
||||||
# `hive-bash-daemon`'s own comment above — same global injection,
|
# `hive-bash-daemon`'s own comment above — same global injection,
|
||||||
# same reasoning.
|
# same reasoning.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue