From 0a80f21003c9bf73865eb8e84ae479d6aba2e100 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 15:20:47 +0200 Subject: [PATCH] prompts+docs: the lifecycle tools reach the whole subtree, not just direct children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `require_descendant` (`socket_server/mod.rs:666`) authorises kill/start/restart/update/get_logs with `topology::is_descendant_of` — the caller's whole subtree, itself included. That has been true since `53b4e752` (#1865), whose message says "a parent owns its whole subtree; the root covers every agent as a consequence, no positional privilege", and two tests pin it (`is_descendant_of_in_grandchild`, `is_descendant_of_in_self_is_true`). The prose never followed. The four lifecycle tool descriptions, their `// IMPORTANT:` comments, `docs/tools/lifecycle.md`, the tools README, hive-agent-mcp's README and the system prompt every agent is rendered from all still said "direct children only" — while `list_containers`, four tools away in the same file, said "direct children + their subtrees". `lifecycle.md` also taught the model #1865 deleted: "Privileged agents (for example ruth) may operate on any sub-agent — the topology scope applies to all others." There is no privileged class to belong to; ruth reaches every agent because the check is transitive and everything sits under it. Same drift on the state-query side: `resolve_agent_state_target` is subtree-scoped by the same commit, so `get_loose_ends`' argument doc, the `QueryAgentState` capability doc and `docs/turn-loop/mcp.md` were all telling a parent it needs a capability to read a grandchild's threads. Two smaller corrections found on the way: * `list_containers` returns the caller itself. `is_descendant_of` is true for `candidate == ancestor` and `handle_list_descendants` filters the topology with it; called from a leaf agent it answers one row, that agent. * `request_init_config` accepts any unused name — the requester becomes its parent — or an existing agent already in the caller's subtree, not "a direct child". The editing surface is narrower than the guard, though: only direct children's config repos are bind-mounted, so re-seeding deeper in the subtree leaves no local copy to edit. `lifecycle.md` now says so. The prompt's other stale claim, the dead `request_apply_commit`, is #4226 and was fixed independently by damocles in #4227 while this was being gated. This branch keeps only the scope wording on that line. Closes #4225. --- docs/tools/README.md | 6 +-- docs/tools/lifecycle.md | 44 ++++++++++++------- docs/turn-loop/mcp.md | 5 ++- hive-agent-mcp/README.md | 4 +- hive-agent-mcp/src/mcp/args.rs | 3 +- hive-agent-mcp/src/mcp/mod.rs | 70 +++++++++++++++++------------- hive-agent/prompts/system.md | 4 +- hive-c0re/src/socket_server/mod.rs | 6 +-- hive-core-agent-sock/src/lib.rs | 8 ++-- hive-sh4re/src/permissions.rs | 9 ++-- 10 files changed, 93 insertions(+), 66 deletions(-) diff --git a/docs/tools/README.md b/docs/tools/README.md index b3946dc0..441b9aab 100644 --- a/docs/tools/README.md +++ b/docs/tools/README.md @@ -38,9 +38,9 @@ debug agent behavior. - **[forge-cli](forge-cli.md)** — the exhaustive, autogenerated flag-by-flag reference for `hive-forge`, kept in lockstep with the binary by CI the same way `hivectl-cli.md` is. -- **[lifecycle](lifecycle.md)** — kill/start/restart/update for an - agent's own direct children, plus the approval-gated config-change - tools. +- **[lifecycle](lifecycle.md)** — kill/start/restart/update for the + agents in a caller's own subtree, plus the approval-gated + config-change tools. - **[matrix](matrix.md)** — the matrix MCP tool surface (`mcp__matrix__*`) for agents with a matrix account, multiple accounts per agent, and declaring extra MCP servers generally. diff --git a/docs/tools/lifecycle.md b/docs/tools/lifecycle.md index 5a0be58b..fd13b0b4 100644 --- a/docs/tools/lifecycle.md +++ b/docs/tools/lifecycle.md @@ -1,14 +1,15 @@ # Lifecycle and approvals tools Two tool groups govern agent lifecycle management and config changes. -The server scopes both to **direct children only** (topology-enforced: it -rejects any name that's not a direct child of the calling -agent per `topology.json`). Privileged agents (for example ruth) may operate -on any sub-agent — the topology scope applies to all others. +The server scopes both to the caller's **own subtree** (topology-enforced +per `topology.json`: a child, a child's child, every agent below them — +plus the caller itself). No privileged class exists to belong to; the +root agent reaches every agent purely because the check is transitive and +everything sits under it. ## `lifecycle` tool group -No operator approval required. Direct children only. +No operator approval required. The caller's own subtree. ### `kill(name)` @@ -31,17 +32,19 @@ to `needs_update` system events. ### `list_containers()` -List all **descendant** containers (children + their subtrees) with -running status. Topology-scoped to descendants only. +List the caller's whole **subtree** with running status — children, +their children, every agent below them. The calling agent is part of its +own subtree, so it appears in its own listing; a leaf agent gets a +one-row answer naming itself. ## `approvals` tool group Config changes and new-agent spawns route through the operator -approval queue. Direct children only (topology-enforced). +approval queue. Topology-enforced the same way. ### `request_init_config(name, description?)` -Step 1 of spawning a new direct child agent. Queues an `InitConfig` +Step 1 of spawning a new sub-agent. Queues an `InitConfig` approval; on operator approve, hive-c0re seeds the proposed config repo at `/agents//config/agent.nix` with a default template and delivers a `config_ready` system event. Then edit `agent.nix`, commit, @@ -54,6 +57,17 @@ Subsequent config changes go through a **forge PR** on the agent's open/update — no MCP tool involved), not a tool call. See `docs/agent-lifecycle/approvals.md`. +`name` must be either unused — in which case the caller becomes its +parent on approval — or an agent already in the caller's subtree whose +config is being re-seeded. The server refuses a name that exists outside +that subtree, so one agent can't hijack another's. + +Only **direct** children's config repos are bind-mounted into a parent's +container, though (`bind_child_agent_dirs`, driven by +`topology::children_of`). The server accepts re-seeding an agent further +down the subtree, and that still leaves the caller without a local copy +to edit afterwards. + Fails if a proposed config repo for `name` already exists. `name` is ≤ 9 characters. @@ -69,12 +83,12 @@ agents after the approval resolves. ## Boundary summary -| Operation | Requires approval? | Scope | -| --------------------------------------- | ------------------ | --------------------- | -| `kill` / `start` / `restart` / `update` | No | Direct children | -| `list_containers` | No | All descendants | -| `request_init_config` | Yes (InitConfig) | New direct child only | -| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | +| Operation | Requires approval? | Scope | +| --------------------------------------- | ------------------ | ---------------------------------- | +| `kill` / `start` / `restart` / `update` | No | Own subtree | +| `list_containers` | No | Own subtree, caller included | +| `request_init_config` | Yes (InitConfig) | Unused name, or one in own subtree | +| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | ## See also diff --git a/docs/turn-loop/mcp.md b/docs/turn-loop/mcp.md index ad45b561..7a08806d 100644 --- a/docs/turn-loop/mcp.md +++ b/docs/turn-loop/mcp.md @@ -81,8 +81,9 @@ at_unix_timestamp?)`. external MCP daemons (for example running bash tasks from `hive-bash-daemon`). Each row carries an id + kind for `cancel_loose_end`. Omit `agent` to list your own threads. Pass - `agent: ""` to inspect a direct child agent (always accessible - per topology enforcement); non-children require the + `agent: ""` to inspect any agent in your subtree — a child, a + child's child, every agent below them (always accessible per topology + enforcement); a name outside the subtree requires the `query_agent_state` capability. The `"*"` hive-wide query isn't available on the agent socket. - `cancel_loose_end` — hard-delete a `reminder`, cancel a pending diff --git a/hive-agent-mcp/README.md b/hive-agent-mcp/README.md index 171992cc..e154e58d 100644 --- a/hive-agent-mcp/README.md +++ b/hive-agent-mcp/README.md @@ -10,8 +10,8 @@ would hit. HTTP is the sole transport; there is no stdio mode here. This is where the core hyperhive tool surface lives: `send`, `recv`, `remind`, `get_loose_ends`, `set_status`, -`get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on -direct children), scheduling, and the approval-request tools. Reach +`get_agent_meta`, lifecycle (`kill`/`start`/`restart`/`update` on the +agent's own subtree), scheduling, and the approval-request tools. Reach for this crate when you're adding or changing a built-in tool rather than an `extraMcpServers` add-on — those are separate stdio bridges (see `hive-bash-mcp`, `hive-matrix-mcp`) that dial the harness socket diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 5a68820c..292d6844 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -172,7 +172,8 @@ pub struct CancelLooseEndArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AgentGetLooseEndsArgs { /// Whose loose ends to list. Omit (or `null`) for your own. You may - /// also pass a direct child agent's name without any extra capability. + /// also pass the name of any agent in your subtree — a child, a child's + /// child, and so on down — without any extra capability. /// Pass any other agent name to inspect their threads — requires the /// `query_agent_state` capability; without it the request is rejected /// with an error. The `"*"` hive-wide value is not available on the diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 46decbe0..525df488 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -560,11 +560,12 @@ impl AgentServer { // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check - // server-side: the call is rejected unless `name` is a direct child. + // server-side: the call is rejected unless `name` is in the caller's + // subtree. #[tool( - description = "Restart a direct child sub-agent container (stop + start). \ - Only succeeds if `name` is a direct child of this agent in the topology \ - tree — the server enforces this. No approval required." + description = "Restart a sub-agent container (stop + start). Only succeeds if `name` \ + is somewhere in this agent's subtree — a child, a child's child, and so on down \ + the topology tree — which the server enforces. No approval required." )] async fn restart(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -583,11 +584,14 @@ impl AgentServer { // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check - // server-side: the call is rejected unless `name` is a direct child. - #[tool(description = "Stop a direct child sub-agent container (graceful). \ - Only succeeds if `name` is a direct child of this agent in the topology \ - tree — the server enforces this. No approval required. \ - State dir is kept; recreating the agent reuses prior config + credentials.")] + // server-side: the call is rejected unless `name` is in the caller's + // subtree. + #[tool( + description = "Stop a sub-agent container (graceful). Only succeeds if `name` \ + is somewhere in this agent's subtree — a child, a child's child, and so on down \ + the topology tree — which the server enforces. No approval required. \ + State dir is kept; recreating the agent reuses prior config + credentials." + )] async fn kill(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); @@ -602,12 +606,14 @@ impl AgentServer { // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check - // server-side: the call is rejected unless `name` is a direct child. + // server-side: the call is rejected unless `name` is in the caller's + // subtree. #[tool( - description = "Rebuild a direct child sub-agent: re-applies the current hyperhive \ - flake + agent.nix and restarts the container. Only succeeds if `name` is a direct \ - child of this agent in the topology tree — the server enforces this. \ - No approval required. Idempotent — use when a child needs its config reapplied." + description = "Rebuild a sub-agent: re-applies the current hyperhive flake + agent.nix \ + and restarts the container. Only succeeds if `name` is somewhere in this agent's \ + subtree — a child, a child's child, and so on down the topology tree — which the \ + server enforces. No approval required. Idempotent — use when an agent needs its \ + config reapplied." )] async fn update(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -625,11 +631,12 @@ impl AgentServer { } // IMPORTANT: this tool is only available when the `lifecycle` tool group - // is granted to this agent. Returns all topological descendants of the - // calling agent with their running status. + // is granted to this agent. Returns the calling agent's whole subtree, + // itself included, with running status. #[tool( - description = "List all containers that are topological descendants of this agent \ - (direct children + their subtrees). Requires the `lifecycle` tool group. \ + description = "List this agent's whole subtree — children, their children, and so on \ + down — with running status. The calling agent is part of its own subtree, so it \ + appears in the listing too. Requires the `lifecycle` tool group. \ Returns every known descendant regardless of running state — check the `running` \ field to distinguish live from stopped containers. Ordered by topology depth \ (parents before children), then alphabetically within each tier." @@ -705,20 +712,20 @@ impl AgentServer { // IMPORTANT: this tool is only available when the `approvals` tool group // is configured for the agent (`HIVE_TOOL_GROUPS` contains `approvals`). - // hive-c0re performs a topology check server-side: only direct children - // of the calling agent are accepted; all other names are rejected. - #[tool( - description = "Create a brand-new direct child agent's config repo and queue an \ + // hive-c0re performs a topology check server-side: an unused `name` is + // accepted from any caller (the requester becomes its parent); an existing + // agent is accepted only from inside the caller's subtree. + #[tool(description = "Create a new agent's config repo and queue an \ `InitConfig` approval for the operator to review. Requires the `approvals` tool \ - group. `name` must be a direct child of this agent in the topology tree. Fails if a \ - config repo for that child already exists. This tool **creates the repo** and \ + group. `name` must be either unused — in which case you become its parent — or an \ + agent already in your subtree, whose config you are re-seeding. Fails if a \ + config repo for that agent already exists. This tool **creates the repo** and \ nothing else: on approval hive-c0re seeds it with a default `agent.nix`, and \ approving the follow-up `Spawn` creates the container. \ Every config change — the child's first one included — goes through a PR on its \ config repo, made from a clone you take yourself, reviewed + approved by the \ operator. `/agents//config` is a **read-only copy** for reading a config, \ - never an editing surface." - )] + never an editing surface.")] async fn request_init_config( &self, Parameters(args): Parameters, @@ -746,10 +753,13 @@ impl AgentServer { // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check - // server-side: the call is rejected unless `name` is a direct child. - #[tool(description = "Start a stopped direct child sub-agent container. \ - Only succeeds if `name` is a direct child of this agent in the topology \ - tree — the server enforces this. No approval required.")] + // server-side: the call is rejected unless `name` is in the caller's + // subtree. + #[tool( + description = "Start a stopped sub-agent container. Only succeeds if `name` \ + is somewhere in this agent's subtree — a child, a child's child, and so on down \ + the topology tree — which the server enforces. No approval required." + )] async fn start(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); let name = args.name.clone(); diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index f901441d..3ac0e709 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -4,7 +4,7 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too - **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at. - **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. -- **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. +- **Lifecycle** (_requires `lifecycle` tool group_, your own subtree — children, their children, and so on down, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_update_meta_inputs`. - **Scheduling** (_requires `scheduling` tool group_): `request_schedule_prompt` (queues an approval), `cancel_schedule`, `fire_schedule_now`, `edit_schedule`, `list_schedules` (these four don't need approval — you can manage schedules you own or that a sub-agent in your subtree owns). - **Diagnostics**: `get_logs` (_requires `diagnostics` tool group_), `get_host_journal` (_requires `read_host_journal` capability_). @@ -13,7 +13,7 @@ Need new packages, env vars, or other NixOS config for yourself? You can't edit Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line. -Approval boundary: lifecycle ops on _existing_ direct children (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent starts with `request_init_config` (requires `approvals` tool group), then the operator spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/` repo, which queues a `MergeConfigPr` approval on open/update. The operator only signs off on changes; you run the day-to-day. +Approval boundary: lifecycle ops on _existing_ agents in your subtree (`kill`, `start`, `restart`) are at your discretion — no operator approval needed (requires `lifecycle` tool group). _Creating_ a new agent starts with `request_init_config` (requires `approvals` tool group), then the operator spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/` repo, which queues a `MergeConfigPr` approval on open/update. The operator only signs off on changes; you run the day-to-day. Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `container_crash`, `needs_update`. Use these to react to lifecycle changes: diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 5bd47450..5cb0e37e 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -698,10 +698,10 @@ fn require_group(agent: &str, group: &str, action: &str) -> Option { /// Topology guard for `request_init_config`, which may legitimately target a /// child that does not exist *yet* (seeding a brand-new sub-agent's config /// repo). The caller may act on a -/// `target` that is EITHER already its direct child (re-init / config -/// update of an existing child) OR brand-new (absent from the topology +/// `target` that is EITHER already in its subtree (re-init / config +/// update of an agent it owns) OR brand-new (absent from the topology /// tree — the requester becomes its parent). A name that already -/// belongs to a *different* parent (or is a root agent) is refused so +/// exists outside the caller's subtree is refused so /// one agent can't hijack another's sub-tree. /// /// Also re-runs the agent-name format check (a traversal / malformed name diff --git a/hive-core-agent-sock/src/lib.rs b/hive-core-agent-sock/src/lib.rs index 5da4a737..df98ed50 100644 --- a/hive-core-agent-sock/src/lib.rs +++ b/hive-core-agent-sock/src/lib.rs @@ -193,10 +193,10 @@ pub enum Request { }, /// *(privileged)* List every schedule in the queue. ListSchedules, - /// List all containers that are topological descendants of the calling - /// agent (direct children + their subtrees). Scoped to the caller's - /// subtree; gated by the `lifecycle` tool group. The result includes all - /// known descendants regardless of whether the container is currently + /// List the calling agent's subtree — children, their children, and so + /// on down, plus the caller itself, which is part of its own subtree. + /// Gated by the `lifecycle` tool group. The result includes every known + /// member regardless of whether the container is currently /// running — use `running` to distinguish. ListDescendants, /// *(privileged)* Fire a scheduled prompt out of band immediately. diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 6fbb42f9..a79d944a 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -215,11 +215,12 @@ pub enum Capability { /// MCP tool `get_host_journal` is only registered in the harness /// when this capability is present. ReadHostJournal, - /// Agent can query non-child agents via `GetLooseEnds`, + /// Agent can query agents outside its own subtree via `GetLooseEnds`, /// `CountPendingReminders`, and `ReminderRollup` on the agent - /// socket. Without this capability, targeting a non-child agent is - /// rejected with an error (direct children are always accessible - /// without any capability). The `"*"` hive-wide value is not + /// socket. Without this capability, targeting an agent outside the + /// caller's subtree is rejected with an error (the caller itself and + /// every descendant are always accessible without any capability). + /// The `"*"` hive-wide value is not /// available on the agent socket even with this capability — use the /// manager socket for swarm-wide scans. QueryAgentState,