From d35b7ab9b492ac4b35d4db0cdee831f09eed62fd Mon Sep 17 00:00:00 2001 From: damocles Date: Mon, 1 Jun 2026 21:26:42 +0200 Subject: [PATCH] fix(#1021): error on unauthorized target (not silent ignore); allow child targeting without cap --- hive-ag3nt/src/mcp.rs | 18 +++++++-------- hive-c0re/src/agent_server.rs | 21 ++++++++++++----- hive-sh4re/src/lib.rs | 43 ++++++++++++++++------------------- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index fd24e7e4..c96491ca 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -662,9 +662,9 @@ impl AgentServer { inbox history. Output is a short bulleted list with ids, ages in seconds, and \ the relevant context. Each `question` or `reminder` row can be cancelled by \ passing its id + kind to `cancel_loose_end`. Empty result is reported clearly.\n\ - Pass `agent: \"\"` to inspect a specific peer agent's threads — requires \ - the `query_agent_state` capability; without it the field is ignored and results \ - are scoped to you." + Pass `agent: \"\"` to inspect a specific peer agent's threads. Direct \ + child agents are always accessible. For non-children, the `query_agent_state` \ + capability is required — without it the request is rejected with an error." )] async fn get_loose_ends(&self, Parameters(args): Parameters) -> String { run_tool_envelope("get_loose_ends", String::new(), async move { @@ -1061,12 +1061,12 @@ pub struct GetLooseEndsArgs { #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct AgentGetLooseEndsArgs { - /// Whose loose ends to list. Omit (or `null`) for your own. Pass a - /// specific agent name to inspect that agent's threads — requires - /// the `query_agent_state` capability; without it the field is - /// ignored and results are scoped to you. The `"*"` hive-wide value - /// is not available on the agent socket; use the manager socket for - /// swarm-wide scans. + /// 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. + /// 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 + /// agent socket; use the manager socket for swarm-wide scans. #[serde(default)] pub agent: Option, } diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 2401b53d..79e4b7b4 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -625,10 +625,15 @@ fn auto_reminder_path(agent: &str) -> String { } /// Resolve the target agent name for `GetLooseEnds`, `CountPendingReminders`, -/// and `ReminderRollup` on the agent socket. Returns the caller's own name -/// when no target is specified or when the caller lacks `query_agent_state`. -/// Returns an error string when the caller requests `"*"` (hive-wide scans -/// are manager-only) or requests another agent without the capability. +/// and `ReminderRollup` on the agent socket. Rules: +/// +/// - `None` → caller's own threads (always allowed). +/// - `Some(caller)` → same as `None`. +/// - `Some("")` where child is a direct descendant of caller per +/// `topology.json` → allowed without any extra capability. +/// - `Some("")` where other is not a child → requires the +/// `query_agent_state` capability; returns an error otherwise. +/// - `Some("*")` → always rejected (hive-wide scans are manager-only). fn resolve_agent_state_target<'a>(caller: &'a str, target: Option<&'a str>) -> Result<&'a str, String> { match target { None => Ok(caller), @@ -640,12 +645,16 @@ fn resolve_agent_state_target<'a>(caller: &'a str, target: Option<&'a str>) -> R if name == caller { return Ok(caller); } + // Direct children are visible to their parent without extra capability. + if crate::topology::children_of(caller).iter().any(|c| c == name) { + return Ok(name); + } if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { Ok(name) } else { Err(format!( - "agent `{caller}` does not have the query_agent_state capability; \ - agent field ignored — grant the capability to query other agents" + "agent `{caller}` cannot query `{name}`: not a direct child and \ + `query_agent_state` capability is not granted" )) } } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index e596fb36..1b9ef753 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -372,22 +372,20 @@ pub enum Request { #[serde(default)] file_path: Option, }, - /// Loose-ends view. On the agent socket, scoped to the calling agent - /// unless the agent holds the `query_agent_state` capability, in - /// which case `Some("")` targets a specific agent's threads - /// (the `"*"` hive-wide value is manager-only). On the manager - /// socket, `agent = None` scopes to the manager itself, - /// `Some("*")` is hive-wide, `Some("")` is that agent's - /// loose ends. See `docs/conventions.md::Loose-ends wire shape`. + /// Loose-ends view. On the agent socket: `None` = self; direct + /// children are always accessible; non-children require the + /// `query_agent_state` capability — rejected with an error otherwise; + /// `"*"` is always rejected (use the manager socket). On the manager + /// socket: `None` = manager self, `"*"` = hive-wide, any name = + /// that agent. See `docs/conventions.md::Loose-ends wire shape`. GetLooseEnds { #[serde(default, skip_serializing_if = "Option::is_none")] agent: Option, }, - /// Count of pending (un-delivered) reminders. On the agent socket, - /// scoped to the calling agent unless the agent holds - /// `query_agent_state`, in which case `Some("")` targets - /// that agent. On the manager socket, `agent = None` means self, - /// `Some("")` means that agent. + /// Count of pending (un-delivered) reminders. On the agent socket: + /// same target rules as `GetLooseEnds` (self/children free; + /// non-children require `query_agent_state`; `"*"` rejected). + /// On the manager socket: `None` = self, any name = that agent. /// Used by the harness's per-turn stats sink. CountPendingReminders { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -395,11 +393,10 @@ pub enum Request { }, /// Reminder statistics: counts of scheduled, delivered, and pending /// reminders over a time window. `since_secs` filters to reminders - /// created in the last N seconds (0 = all). On the agent socket, - /// scoped to the calling agent unless the agent holds - /// `query_agent_state`, in which case `Some("")` targets - /// that agent. On the manager socket, `agent = None` means self, - /// `Some("")` means that agent. + /// created in the last N seconds (0 = all). On the agent socket: + /// same target rules as `GetLooseEnds` (self/children free; + /// non-children require `query_agent_state`; `"*"` rejected). + /// On the manager socket: `None` = self, any name = that agent. ReminderRollup { /// Only count reminders created in the last N seconds from now. /// Pass 0 to include all reminders. @@ -917,13 +914,13 @@ pub enum Capability { /// MCP tool `get_host_journal` is only registered in the harness /// when this capability is present. ReadHostJournal, - /// Agent can query another agent's state via `GetLooseEnds`, + /// Agent can query non-child agents via `GetLooseEnds`, /// `CountPendingReminders`, and `ReminderRollup` on the agent - /// socket. Without this capability the `agent` field in those - /// requests is ignored and results are scoped to the caller. - /// The `"*"` hive-wide value is not available on the agent socket - /// even with this capability — use the manager socket for swarm-wide - /// scans. + /// 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 + /// available on the agent socket even with this capability — use the + /// manager socket for swarm-wide scans. QueryAgentState, }