subagent: say why we refuse an empty --tools, not what one would do

The comments and docs around the empty-`--tools` assert stated a
mechanism: that an empty value parses as *unset* and therefore grants
MORE built-ins than omitting the flag. That claim came from a
measurement, and the installed `claude --help` (2.1.268) says the
opposite in as many words — `Use "" to disable all tools`. One of the two
is wrong and this repo is not the place that settles it.

The rule the code enforces is right under both readings, which is
precisely why it should not be argued from either: we never emit an empty
`--tools`, because what an empty one means is release-dependent and
nothing here wants a subagent with no built-in tools regardless. So the
prose now states the rule and the ambiguity, and asserts nothing about
`""` in either direction — replacing the claim with its opposite would be
the same mistake sourced differently.

No behaviour change: the assert, the resolution and every test are
untouched.

Two test comments also gained the limit they were quietly missing.
`no_spawned_tool_escapes_the_session` hardcodes ten names, so it is a
canary for tools someone thought to list, not a guarantee — the
guarantee is the subset property, which covers tools nobody has invented
yet. And `a_subagent_gets_no_builtin_its_parent_lacks` resolves both
sides in one process off one env var, so it catches a code divergence but
not the two real systemd units disagreeing about `HIVE_TOOL_GROUPS` —
which is what they did until the previous commit, and is not a thing a
unit test can reach.

Refs #4416
This commit is contained in:
atlas 2026-09-15 17:17:09 +02:00 committed by mara
commit 9cd30a58ba
3 changed files with 50 additions and 28 deletions

View file

@ -250,10 +250,15 @@ them — `--dangerously-skip-permissions` had removed the only thing that
would have asked, and `--allowedTools` would not have helped: it approves would have asked, and `--allowedTools` would not have helped: it approves
prompts in advance rather than restricting anything. prompts in advance rather than restricting anything.
One trap worth knowing before editing any of this: an empty `--tools` One rule worth knowing before editing any of this: **the daemon never
value parses as _unset_ and grants **more** than omitting the flag, so emits an empty `--tools` value**, and asserts rather than doing so. Not
there is no way to spell "no built-in tools" — the daemon asserts rather because of what an empty value does — that's exactly the point. Our own
than emitting one. 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`) ### MCP servers (`--strict-mcp-config`)

View file

@ -69,11 +69,15 @@ pub fn builtin_tools_for(groups: &[ToolGroup]) -> Vec<&'static str> {
/// The value for claude's `--tools` flag: which built-in tools exist in this /// The value for claude's `--tools` flag: which built-in tools exist in this
/// session at all. /// session at all.
/// ///
/// ⚠️ **Never emit this as an empty string.** An empty `--tools` value parses /// ⚠️ **Never emit this as an empty string** — not because of what an empty
/// as *unset* and yields **more** tools than omitting the flag, so there is /// value does, but because we don't know. Our own measurement and the
/// no way to spell "no built-in tools" — an empty list is a fail-loudly bug, /// installed `claude --help` disagree about whether `--tools ""` means "no
/// not a lockdown. [`ALLOWED_BUILTIN_TOOLS`] is non-empty, which is what /// tools" or parses as the flag being unset, and the answer is a property of
/// keeps that from arising. /// 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 /// ⚠️ This governs built-ins only. `mcp__*` tools are not filtered by
/// `--tools` at all; they are governed by `--strict-mcp-config` plus /// `--tools` at all; they are governed by `--strict-mcp-config` plus
@ -439,8 +443,10 @@ mod tests {
assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch")); assert!(!without.contains(&"WebFetch") && !without.contains(&"WebSearch"));
} }
/// An empty `--tools` value parses as *unset* and grants more than /// No resolution may produce an empty `--tools` value. What an empty one
/// omitting the flag, so no resolution may ever produce 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] #[test]
fn no_group_combination_resolves_to_an_empty_tools_arg() { fn no_group_combination_resolves_to_an_empty_tools_arg() {
assert!(!ALLOWED_BUILTIN_TOOLS.is_empty()); assert!(!ALLOWED_BUILTIN_TOOLS.is_empty());

View file

@ -866,14 +866,15 @@ fn build_config(
signal_url: Option<&str>, signal_url: Option<&str>,
) -> Config { ) -> Config {
let tools = hive_sh4re::permissions::builtin_tools_arg(); let tools = hive_sh4re::permissions::builtin_tools_arg();
// An empty `--tools` value parses as *unset* and yields MORE tools than // Refuse to spawn on an empty `--tools` rather than reason about what it
// omitting the flag, so there is no way to spell "no built-ins" — an // would do: what an empty value means is disputed and depends on the
// empty resolution is a bug, and failing here is louder than silently // installed claude release (see `builtin_tools_arg`). Nothing here wants
// spawning an unrestricted subagent. // "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!( assert!(
!tools.is_empty(), !tools.is_empty(),
"resolved an empty --tools value — that parses as unset and would \ "resolved an empty --tools value — a subagent is never spawned on one, \
grant the subagent every built-in claude has" since what an empty value grants is release-dependent"
); );
let mut extra_args = vec![ let mut extra_args = vec![
"--dangerously-skip-permissions".to_owned(), "--dangerously-skip-permissions".to_owned(),
@ -1996,14 +1997,13 @@ mod tests {
} }
/// `--tools` reaches the argv on every caller shape, carrying a non-empty /// `--tools` reaches the argv on every caller shape, carrying a non-empty
/// value. Non-empty is the load-bearing half: an empty `--tools` parses as /// value. Non-empty matters separately from "the flag is there": what an
/// *unset* and hands the subagent **more** built-ins than omitting the /// empty value grants is disputed and release-dependent, so we never ship
/// flag would, so "we passed the flag" is not on its own the restriction. /// one and never depend on the answer. Checked for each shape because a
/// Checked for each shape because a flag that appears on only some spawn /// flag that appears on only some spawn paths is no restriction at all.
/// paths is no restriction at all. `signal_url` stays `None` throughout — /// `signal_url` stays `None` throughout — it only steers `mcp_config`,
/// it only steers `mcp_config`, which `--tools` does not govern, and /// which `--tools` does not govern, and passing it would make this test
/// passing it would make this test need the container's injected /// need the container's injected `HYPERHIVE_HARNESS_DIR`.
/// `HYPERHIVE_HARNESS_DIR`.
#[test] #[test]
fn build_config_always_passes_a_non_empty_tools_list() { fn build_config_always_passes_a_non_empty_tools_list() {
for config in [ for config in [
@ -2024,6 +2024,12 @@ mod tests {
/// `hive_sh4re::permissions`, deliberately: it is the parent harness's /// `hive_sh4re::permissions`, deliberately: it is the parent harness's
/// own resolver, so this compares against what the parent actually gets /// own resolver, so this compares against what the parent actually gets
/// rather than against a restatement of it that could drift. /// 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] #[test]
fn a_subagent_gets_no_builtin_its_parent_lacks() { fn a_subagent_gets_no_builtin_its_parent_lacks() {
let parent = hive_sh4re::permissions::builtin_tools_for( let parent = hive_sh4re::permissions::builtin_tools_for(
@ -2040,9 +2046,14 @@ mod tests {
/// Named individually rather than inferred from "the list is short": the /// Named individually rather than inferred from "the list is short": the
/// tools this daemon used to hand a subagent that reach outside the run /// tools this daemon used to hand a subagent that reach outside the run
/// it was started for. Adding one back has to be a deliberate edit here. /// it was started for. They are absent because the parent has never had
/// They are absent because the parent has never had them, which is the /// them — this pins the consequence, not a second allow-list.
/// point — 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] #[test]
fn no_spawned_tool_escapes_the_session() { fn no_spawned_tool_escapes_the_session() {
let config = build_config("n", None, None, None, None, None); let config = build_config("n", None, None, None, None, None);