refactor(#1865): replace the privileged flag with topology + capability gating

Per operator direction (no privileged mode; everything is perms /
capabilities), remove the socket-derived `privileged: bool` from the
unified dispatch and gate every verb on the caller's identity instead:

- serve/dispatch/dispatch_shared/dispatch_orchestration + all lifecycle
  handlers drop the `privileged` param.
- lifecycle (start/kill/restart/update/init_config/apply_commit) + get_logs
  gate on `topology::is_descendant_of` (a parent owns its whole subtree; the
  root covers every agent as a consequence, no positional privilege). The
  restart infra-branch stays InfraAdmin-gated (orthogonal).
- agent-state queries (loose-ends / reminder count + rollup): own subtree is
  free, other agents + the hive-wide `"*"` sweep require QueryAgentState.
  require_new_child + resolve_agent_state_target widened direct-child -> subtree.
- hive-wide orchestration verbs gate on the grantable tool-group via
  tool_groups::groups_for: schedules -> `scheduling`, meta-inputs +
  cancel-approval -> `approvals`. update_meta_inputs now attributes the
  approval to the caller, not a hardcoded MANAGER_AGENT.
- #1834 cancel-guard unwind: handle_cancel_loose_end drops `privileged`
  (agent path is never privileged); question/reminder cancels are
  ownership-only, approval cancel checks the `approvals` tool-group.

The manager socket stays as pure transport (serves agent=ruth, no authority
of its own); collapsing it into ruth's per-agent socket is the #1825
follow-up. No is_root here — root-identity primitives are #1825's.
This commit is contained in:
atlas 2026-06-22 13:34:29 +02:00 committed by mara
commit 53b4e752ef
2 changed files with 282 additions and 312 deletions

View file

@ -141,26 +141,27 @@ pub fn handle_answer(
Ok(()) Ok(())
} }
/// Handle `CancelLooseEnd` from either surface. Dispatches by kind to /// Handle `CancelLooseEnd` from a per-agent socket. Dispatches by kind, each
/// the per-kind cancel, each of which does its own auth check /// with its own auth check: question / reminder cancels are ownership-only
/// (canceller == owner / asker, or `operator`, or `privileged`). /// (an agent cancels its own), and approval cancels require the `approvals`
/// `privileged` is `true` when the request arrived on the manager /// tool-group (the grantable capability) — no positional / hardcoded
/// socket — privilege derives from the socket (the trust boundary), /// privilege. (The operator's cancel-anything path is a separate handler.)
/// not from matching a hardcoded agent name. On question cancel, fires /// On question cancel, fires the `QuestionAnswered` event back to the asker
/// the `QuestionAnswered` event back to the asker so the harness /// so the harness loop can react (mirrors the operator-cancel dashboard path).
/// loop can react (mirrors the operator-cancel dashboard path).
pub fn handle_cancel_loose_end( pub fn handle_cancel_loose_end(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
canceller: &str, canceller: &str,
privileged: bool,
kind: hive_sh4re::CancelLooseEndKind, kind: hive_sh4re::CancelLooseEndKind,
id: i64, id: i64,
) -> Result<(), String> { ) -> Result<(), String> {
match kind { match kind {
hive_sh4re::CancelLooseEndKind::Question => { hive_sh4re::CancelLooseEndKind::Question => {
// Agent-socket path: never privileged — an agent may only cancel
// its own question (ownership). The operator's cancel-anything
// path goes through a separate handler with `privileged = true`.
let (question, asker, target) = coord let (question, asker, target) = coord
.questions .questions
.cancel(id, canceller, privileged) .cancel(id, canceller, false)
.map_err(|e| format!("{e:#}"))?; .map_err(|e| format!("{e:#}"))?;
let sentinel = format!("[cancelled by {canceller}]"); let sentinel = format!("[cancelled by {canceller}]");
tracing::info!(%id, %canceller, %asker, "question cancelled"); tracing::info!(%id, %canceller, %asker, "question cancelled");
@ -184,19 +185,21 @@ pub fn handle_cancel_loose_end(
Ok(()) Ok(())
} }
hive_sh4re::CancelLooseEndKind::Reminder => { hive_sh4re::CancelLooseEndKind::Reminder => {
// Agent-socket path: ownership-only (cancel your own reminder).
let owner = coord let owner = coord
.broker .broker
.cancel_reminder_as(id, canceller, privileged) .cancel_reminder_as(id, canceller, false)
.map_err(|e| format!("{e:#}"))?; .map_err(|e| format!("{e:#}"))?;
tracing::info!(%id, %canceller, %owner, "reminder cancelled"); tracing::info!(%id, %canceller, %owner, "reminder cancelled");
coord.emit_reminders_snapshot(); coord.emit_reminders_snapshot();
Ok(()) Ok(())
} }
hive_sh4re::CancelLooseEndKind::Approval => { hive_sh4re::CancelLooseEndKind::Approval => {
// Privileged-only: only a caller on the manager socket (which // Withdrawing an approval is a hive-wide orchestration action,
// is the sole approval submitter) may withdraw approvals. // gated on the grantable `approvals` tool-group (held by the
// Sub-agents have no pending approvals of their own anyway. // orchestrator that submits approvals) — not on a positional /
check_can_cancel_approval(privileged)?; // hardcoded privilege.
check_can_cancel_approval(canceller)?;
let approval = coord let approval = coord
.approvals .approvals
.mark_cancelled(id, canceller) .mark_cancelled(id, canceller)
@ -220,21 +223,26 @@ pub fn handle_cancel_loose_end(
} }
} }
/// Privileged-only guard on the `Approval` cancel arm. Pulled out so /// Capability guard on the `Approval` cancel arm: the caller must hold the
/// the auth check has its own focused unit test — testing the full /// `approvals` tool-group (the grantable capability for approval-submitting
/// `handle_cancel_loose_end` flow would need a `Coordinator` fixture /// orchestrators), checked server-side via `tool_groups::groups_for`. Pulled
/// (broker + sqlite + in-memory questions), which we don't have /// out so the auth check has its own focused unit test — exercising the full
/// today. Privilege is a property of the socket the request arrived /// `handle_cancel_loose_end` flow would need a `Coordinator` fixture (broker +
/// on (the manager socket), threaded in as `privileged` — not a match /// sqlite + in-memory questions) we don't have. Keys on a grantable capability,
/// against a hardcoded agent name. /// not a positional / hardcoded privilege.
fn check_can_cancel_approval(privileged: bool) -> Result<(), String> { fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
if !privileged { const APPROVALS_GROUP: &str = "approvals";
return Err( if crate::tool_groups::groups_for(canceller)
"cancel_loose_end: only a privileged (manager-socket) caller can cancel approval rows" .iter()
.any(|g| g == APPROVALS_GROUP)
{
Ok(())
} else {
Err(
"cancel_loose_end: cancelling approval rows requires the `approvals` tool group"
.to_owned(), .to_owned(),
); )
} }
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@ -242,18 +250,14 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn approval_cancel_rejects_unprivileged_callers() { fn approval_cancel_rejects_callers_without_the_approvals_group() {
// A non-privileged caller (any regular agent socket) must not be // A caller that doesn't hold the `approvals` tool-group must not be
// able to cancel approval rows even if it invents an id. The guard // able to cancel approval rows even if it invents an id. The guard is
// is server-side so client cooperation is irrelevant — and it keys // server-side so client cooperation is irrelevant — and it keys on a
// on the socket-derived `privileged` flag, not on any agent name. // grantable capability (the tool-group), not on any agent name.
let err = check_can_cancel_approval(false).unwrap_err(); // `groups_for` of a name with no tool_groups.json entry is empty.
assert!(err.contains("only a privileged"), "{err}"); let err = check_can_cancel_approval("nobody-with-no-groups").unwrap_err();
} assert!(err.contains("approvals` tool group"), "{err}");
#[test]
fn approval_cancel_allows_privileged() {
check_can_cancel_approval(true).expect("a privileged caller must pass the guard");
} }
} }

View file

@ -1,11 +1,13 @@
//! Unix-socket request server, shared by the per-agent sockets and the //! Unix-socket request server, shared by the per-agent sockets and the
//! manager socket. The socket file's existence on disk authenticates the //! (pure-transport) manager socket. The socket file's existence on disk
//! caller: connecting to `<.../agents/foo/mcp.sock>` means you are `foo` //! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
//! (non-privileged); connecting to the manager socket grants privileged //! you are `foo`; the manager socket simply serves as `ruth`. There is no
//! authority. Both transports run the same [`serve`] / [`dispatch`] code, //! privilege flag — both transports run the same [`serve`] / [`dispatch`]
//! parameterised by a `privileged: bool` carried from the listener — the //! code, and authority derives uniformly from the caller's identity:
//! privilege gate (and the topology guards on lifecycle verbs) keys off //! topology (`is_descendant_of`) for subtree-relational verbs, capabilities
//! that flag, not off matching the `MANAGER_AGENT` name. //! for hive-wide queries, and tool-group membership for the orchestration
//! verbs. `ruth` reaches every agent only as a consequence of being the
//! topology root, not via any hardcoded name match.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@ -54,8 +56,7 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
let agent = agent.clone(); let agent = agent.clone();
let coord = coord.clone(); let coord = coord.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Per-agent socket: never privileged. if let Err(e) = serve(stream, agent, coord).await {
if let Err(e) = serve(stream, agent, false, coord).await {
tracing::warn!(error = ?e, "agent connection failed"); tracing::warn!(error = ?e, "agent connection failed");
} }
}); });
@ -70,11 +71,12 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
Ok(AgentSocket { path, handle }) Ok(AgentSocket { path, handle })
} }
/// Bind + serve the manager socket. The manager socket is the privileged /// Bind + serve the manager socket. This is now **pure transport**: it grants
/// trust boundary: every connection that lands here runs `dispatch` with /// no authority of its own — it just serves requests as `agent = MANAGER_AGENT`
/// `privileged = true`. `MANAGER_AGENT` is passed as the actor name for /// ("ruth"), and ruth's reach comes entirely from being the topology root
/// attribution/routing (notifications, ownership), not for authorisation — /// (`is_descendant_of` covers every agent) plus the capabilities / tool-groups
/// authority comes from the socket itself. /// it holds, identical to connecting on a per-agent socket. (The redundant
/// path — ruth using the standard per-agent socket — is collapsed in #1825.)
pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> { pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
let dir = Coordinator::manager_dir(); let dir = Coordinator::manager_dir();
@ -98,8 +100,8 @@ pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
Ok((stream, _)) => { Ok((stream, _)) => {
let coord = coord.clone(); let coord = coord.clone();
tokio::spawn(async move { tokio::spawn(async move {
// Manager socket: privileged. // Pure transport: serve as `ruth`, no privilege grant.
if let Err(e) = serve(stream, MANAGER_AGENT.to_owned(), true, coord).await { if let Err(e) = serve(stream, MANAGER_AGENT.to_owned(), coord).await {
tracing::warn!(error = ?e, "manager connection failed"); tracing::warn!(error = ?e, "manager connection failed");
} }
}); });
@ -114,12 +116,7 @@ pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
Ok(()) Ok(())
} }
async fn serve( async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Result<()> {
stream: UnixStream,
agent: String,
privileged: bool,
coord: Arc<Coordinator>,
) -> Result<()> {
let (read, mut write) = stream.into_split(); let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read); let mut reader = BufReader::new(read);
let mut line = String::new(); let mut line = String::new();
@ -130,7 +127,7 @@ async fn serve(
return Ok(()); return Ok(());
} }
let resp = match serde_json::from_str::<AgentRequest>(line.trim()) { let resp = match serde_json::from_str::<AgentRequest>(line.trim()) {
Ok(req) => dispatch(&req, &agent, privileged, &coord).await, Ok(req) => dispatch(&req, &agent, &coord).await,
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
message: format!("parse error: {e}"), message: format!("parse error: {e}"),
}, },
@ -174,12 +171,11 @@ pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
/// semantics (e.g. `GetLooseEnds` / `CountPendingReminders` / `ReminderRollup` /// semantics (e.g. `GetLooseEnds` / `CountPendingReminders` / `ReminderRollup`
/// where the manager can target other agents) or for manager-only variants. /// where the manager can target other agents) or for manager-only variants.
/// ///
/// The unified `dispatch` calls this first (for both the privileged and /// The unified `dispatch` calls this first; the remaining arms (which gate
/// non-privileged paths); the remaining arms are handled there. /// on topology / capabilities / tool-groups) are handled there.
pub(crate) async fn dispatch_shared( pub(crate) async fn dispatch_shared(
req: &hive_sh4re::Request, req: &hive_sh4re::Request,
agent: &str, agent: &str,
privileged: bool,
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
) -> Option<hive_sh4re::Response> { ) -> Option<hive_sh4re::Response> {
Some(match req { Some(match req {
@ -234,11 +230,10 @@ pub(crate) async fn dispatch_shared(
handle_get_agent_meta(coord, agent, name.as_deref()).await handle_get_agent_meta(coord, agent, name.as_deref()).await
} }
hive_sh4re::Request::CancelLooseEnd { kind, id } => { hive_sh4re::Request::CancelLooseEnd { kind, id } => {
crate::questions::handle_cancel_loose_end(coord, agent, privileged, *kind, *id) crate::questions::handle_cancel_loose_end(coord, agent, *kind, *id).map_or_else(
.map_or_else( |message| hive_sh4re::Response::Err { message },
|message| hive_sh4re::Response::Err { message }, |()| hive_sh4re::Response::Ok,
|()| hive_sh4re::Response::Ok, )
)
} }
hive_sh4re::Request::CreateRepo { repo } => handle_create_repo(agent, repo).await, hive_sh4re::Request::CreateRepo { repo } => handle_create_repo(agent, repo).await,
hive_sh4re::Request::AckTurn => handle_ack_turn(coord, agent), hive_sh4re::Request::AckTurn => handle_ack_turn(coord, agent),
@ -489,30 +484,28 @@ fn handle_requeue_inflight(coord: &Arc<Coordinator>, agent: &str) -> hive_sh4re:
} }
} }
/// Unified dispatch for both the per-agent sockets (`privileged = false`) /// Unified dispatch for every socket connection — per-agent sockets and the
/// and the manager socket (`privileged = true`). Shared variants go through /// (now pure-transport) manager socket alike. There is no privilege bit;
/// [`dispatch_shared`]; lifecycle/config + agent-state-query verbs apply the /// authority derives uniformly from the caller's identity: subtree-relational
/// topology/capability guards on the non-privileged path and skip them when /// verbs (lifecycle/config/logs) require the caller to be an ancestor of the
/// privileged; the schedule / meta-input / log verbs are privileged-only. /// target (`is_descendant_of`, so the root covers all); hive-wide agent-state
async fn dispatch( /// queries require the `QueryAgentState` capability; hive-wide orchestration
req: &AgentRequest, /// verbs (schedules / meta-inputs) require the matching tool-group (the
agent: &str, /// grantable capability).
privileged: bool, async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) -> AgentResponse {
coord: &Arc<Coordinator>, if let Some(resp) = dispatch_shared(req, agent, coord).await {
) -> AgentResponse {
if let Some(resp) = dispatch_shared(req, agent, privileged, coord).await {
return resp; return resp;
} }
match req { match req {
// Lifecycle + config: topology-gated when `!privileged`, ungated // Lifecycle + config: caller must be an ancestor of the target
// (any agent) when privileged. // (a parent owns its whole subtree; the root covers every agent).
AgentRequest::Start { name } => handle_start(coord, agent, name, privileged).await, AgentRequest::Start { name } => handle_start(coord, agent, name).await,
AgentRequest::Restart { name } => handle_restart(coord, agent, name, privileged).await, AgentRequest::Restart { name } => handle_restart(coord, agent, name).await,
AgentRequest::Kill { name } => handle_kill(coord, agent, name, privileged).await, AgentRequest::Kill { name } => handle_kill(coord, agent, name).await,
AgentRequest::Update { name } => handle_update(coord, agent, name, privileged), AgentRequest::Update { name } => handle_update(coord, agent, name),
AgentRequest::ListDescendants => handle_list_descendants(agent).await, AgentRequest::ListDescendants => handle_list_descendants(agent).await,
AgentRequest::RequestInitConfig { name, description } => { AgentRequest::RequestInitConfig { name, description } => {
handle_request_init_config(coord, agent, name, description.clone(), privileged) handle_request_init_config(coord, agent, name, description.clone())
} }
AgentRequest::RequestApplyCommit { AgentRequest::RequestApplyCommit {
agent: target_agent, agent: target_agent,
@ -525,54 +518,58 @@ async fn dispatch(
target_agent, target_agent,
commit_ref, commit_ref,
description.as_deref(), description.as_deref(),
privileged,
) )
.await .await
} }
// Agent-state queries: own/child/cap-gated when `!privileged`; // Agent-state queries: own subtree is free; other agents + the
// any-agent + hive-wide (`"*"`) when privileged. // hive-wide `"*"` sweep require `QueryAgentState`.
AgentRequest::GetLooseEnds { agent: target } => { AgentRequest::GetLooseEnds { agent: target } => {
handle_get_loose_ends(coord, agent, target.as_deref(), privileged) handle_get_loose_ends(coord, agent, target.as_deref())
} }
AgentRequest::CountPendingReminders { agent: target } => { AgentRequest::CountPendingReminders { agent: target } => {
handle_count_pending_reminders(coord, agent, target.as_deref(), privileged) handle_count_pending_reminders(coord, agent, target.as_deref())
} }
AgentRequest::ReminderRollup { AgentRequest::ReminderRollup {
since_secs, since_secs,
agent: target, agent: target,
} => handle_reminder_rollup(coord, agent, target.as_deref(), *since_secs, privileged), } => handle_reminder_rollup(coord, agent, target.as_deref(), *since_secs),
// Everything else is privileged-only (scheduling / meta-inputs / // Orchestration / diagnostics verbs — gated per-verb on tool-group
// logs) or unknown — gated + handled in `dispatch_privileged_only`. // membership or topology (see `dispatch_orchestration`).
_ => dispatch_privileged_only(req, agent, privileged, coord).await, _ => dispatch_orchestration(req, agent, coord).await,
} }
} }
/// Handle the privileged-only verbs (scheduling, meta-input updates, /// Handle the hive-wide orchestration verbs (scheduling, meta-input updates)
/// container logs). Reached as the fallback arm of [`dispatch`]: rejects the /// plus container-log reads. No blanket socket gate: each verb gates on the
/// whole group up front when `!privileged` (these never appear on a per-agent /// grantable capability that authorises it — the matching tool-group
/// socket), then matches the individual verbs. Any other variant is a /// (`scheduling` / `approvals`), or `is_descendant_of` for `get_logs`
/// host-admin-only / unknown request that's invalid on either socket. /// (a parent reads its subtree's logs). Any other variant is a host-admin /
async fn dispatch_privileged_only( /// unknown request invalid on either socket.
async fn dispatch_orchestration(
req: &AgentRequest, req: &AgentRequest,
agent: &str, agent: &str,
privileged: bool,
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
) -> AgentResponse { ) -> AgentResponse {
if !privileged {
return AgentResponse::Err {
message: "request not available on the agent socket (privileged / manager-only)"
.to_owned(),
};
}
match req { match req {
AgentRequest::RequestUpdateMetaInputs { AgentRequest::RequestUpdateMetaInputs {
inputs, inputs,
description, description,
} => handle_request_update_meta_inputs(coord, inputs, description.as_deref()), } => {
if let Some(err) = require_group(agent, "approvals", "request update_meta_inputs") {
return err;
}
handle_request_update_meta_inputs(coord, agent, inputs, description.as_deref())
}
AgentRequest::RequestSchedulePrompt(payload) => { AgentRequest::RequestSchedulePrompt(payload) => {
if let Some(err) = require_group(agent, "scheduling", "schedule a prompt") {
return err;
}
handle_request_schedule_prompt(coord, agent, payload) handle_request_schedule_prompt(coord, agent, payload)
} }
AgentRequest::CancelSchedule { id, targets } => { AgentRequest::CancelSchedule { id, targets } => {
if let Some(err) = require_group(agent, "scheduling", "cancel a schedule") {
return err;
}
handle_cancel_schedule(coord, agent, *id, targets.as_deref()) handle_cancel_schedule(coord, agent, *id, targets.as_deref())
} }
AgentRequest::EditSchedule { AgentRequest::EditSchedule {
@ -583,25 +580,45 @@ async fn dispatch_privileged_only(
next_fire_at_unix, next_fire_at_unix,
targets_add, targets_add,
targets_remove, targets_remove,
} => handle_edit_schedule( } => {
coord, if let Some(err) = require_group(agent, "scheduling", "edit a schedule") {
agent, return err;
*id, }
EditSchedulePatch { handle_edit_schedule(
body: body.clone(), coord,
description: description.clone(), agent,
interval_seconds: *interval_seconds, *id,
next_fire_at_unix: *next_fire_at_unix, EditSchedulePatch {
targets_add: targets_add.clone(), body: body.clone(),
targets_remove: targets_remove.clone(), description: description.clone(),
}, interval_seconds: *interval_seconds,
), next_fire_at_unix: *next_fire_at_unix,
AgentRequest::ListSchedules => handle_list_schedules(coord), targets_add: targets_add.clone(),
AgentRequest::FireScheduleNow { id } => handle_fire_schedule_now(coord, agent, *id).await, targets_remove: targets_remove.clone(),
},
)
}
AgentRequest::ListSchedules => {
if let Some(err) = require_group(agent, "scheduling", "list schedules") {
return err;
}
handle_list_schedules(coord)
}
AgentRequest::FireScheduleNow { id } => {
if let Some(err) = require_group(agent, "scheduling", "fire a schedule") {
return err;
}
handle_fire_schedule_now(coord, agent, *id).await
}
AgentRequest::GetLogs { AgentRequest::GetLogs {
agent: target, agent: target,
lines, lines,
} => handle_get_logs(target, *lines).await, } => {
if let Some(err) = require_descendant(agent, target, "read logs of") {
return err;
}
handle_get_logs(target, *lines).await
}
// Host-admin-only / unknown variants: never valid on either socket. // Host-admin-only / unknown variants: never valid on either socket.
_ => AgentResponse::Err { _ => AgentResponse::Err {
message: "request not handled on this socket".to_owned(), message: "request not handled on this socket".to_owned(),
@ -609,27 +626,44 @@ async fn dispatch_privileged_only(
} }
} }
/// Topology guard for the agent-socket lifecycle/config tools: the /// Topology guard for the subtree-relational lifecycle/config/log tools: the
/// caller must be the direct parent of `target`. Returns `Some(Err)` /// `target` must be the caller itself or one of its topology descendants — a
/// to short-circuit the dispatch arm when it isn't, `None` when the /// parent owns its whole subtree, and the root (`ruth`) covers every agent as
/// call is authorised. `action` is the verb phrase for the message /// a consequence, with no positional/hardcoded privilege. Returns `Some(Err)`
/// (e.g. `"start"`, `"request_apply_commit for"`). /// to short-circuit the dispatch arm when it isn't, `None` when authorised.
fn require_child(agent: &str, target: &str, action: &str) -> Option<AgentResponse> { /// `action` is the verb phrase for the message (e.g. `"start"`).
if crate::topology::children_of(agent) fn require_descendant(agent: &str, target: &str, action: &str) -> Option<AgentResponse> {
.iter() if crate::topology::is_descendant_of(target, agent) {
.any(|c| c == target)
{
None None
} else { } else {
Some(AgentResponse::Err { Some(AgentResponse::Err {
message: format!( message: format!(
"agent `{agent}` cannot {action} `{target}`: \ "agent `{agent}` cannot {action} `{target}`: \
not a direct child in the topology tree" not in its subtree (topology)"
), ),
}) })
} }
} }
/// Capability guard for the hive-wide orchestration verbs: the caller must
/// hold the given tool-group. The tool-group (c0re-owned `tool_groups.json`,
/// read server-side via [`crate::tool_groups::groups_for`]) is the grantable
/// capability — granting it to an orchestrator (e.g. the root) authorises
/// these verbs without any positional/hardcoded privilege. `action` is the
/// verb phrase for the message.
fn require_group(agent: &str, group: &str, action: &str) -> Option<AgentResponse> {
if crate::tool_groups::groups_for(agent)
.iter()
.any(|g| g == group)
{
None
} else {
Some(AgentResponse::Err {
message: format!("agent `{agent}` cannot {action}: requires the `{group}` tool group"),
})
}
}
/// Topology guard for `request_init_config` / `request_apply_commit`, /// Topology guard for `request_init_config` / `request_apply_commit`,
/// which may legitimately target a child that does not exist *yet* /// which may legitimately target a child that does not exist *yet*
/// (spawning a brand-new sub-agent). The caller may act on a /// (spawning a brand-new sub-agent). The caller may act on a
@ -639,57 +673,54 @@ fn require_child(agent: &str, target: &str, action: &str) -> Option<AgentRespons
/// belongs to a *different* parent (or is a root agent) is refused so /// belongs to a *different* parent (or is a root agent) is refused so
/// one agent can't hijack another's sub-tree. /// one agent can't hijack another's sub-tree.
/// ///
/// Also re-runs the agent-name format check that `require_child` /// Also re-runs the agent-name format check (a traversal / malformed name
/// implicitly provided (a traversal / malformed name could never be a /// could never be a descendant): a brand-new name now flows straight to
/// child): a brand-new name now flows straight to `submit_init_config`, /// `submit_init_config`, which builds filesystem paths from it, so validate
/// which builds filesystem paths from it, so validate before that. /// before that.
fn require_new_child(agent: &str, target: &str, action: &str) -> Option<AgentResponse> { fn require_new_child(agent: &str, target: &str, action: &str) -> Option<AgentResponse> {
if let Some(reason) = crate::dashboard::validate_agent_name(target) { if let Some(reason) = crate::dashboard::validate_agent_name(target) {
return Some(AgentResponse::Err { return Some(AgentResponse::Err {
message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"), message: format!("agent `{agent}` cannot {action} `{target}`: {reason}"),
}); });
} }
match crate::topology::read().get(target) { // brand-new name (absent from topology) — requester becomes the parent on
// brand-new name — requester becomes the parent on approval. // approval; allowed for any caller.
None => None, if !crate::topology::read().contains_key(target) {
// already our direct child — re-init / config update path. return None;
Some(Some(p)) if p == agent => None, }
// owned by someone else, or a root agent — refuse. // existing agent — allowed only if it's in the caller's subtree
Some(_) => Some(AgentResponse::Err { // (re-init / config update of an agent the caller owns; the root owns
// every existing agent). Refuses an agent outside the caller's subtree
// so one agent can't hijack another's config.
if crate::topology::is_descendant_of(target, agent) {
None
} else {
Some(AgentResponse::Err {
message: format!( message: format!(
"agent `{agent}` cannot {action} `{target}`: it already exists \ "agent `{agent}` cannot {action} `{target}`: it already exists \
under a different parent in the topology tree" outside its subtree in the topology tree"
), ),
}), })
} }
} }
/// `GetLooseEnds` — read the target's loose ends. The privileged (manager) /// `GetLooseEnds` — read the target's loose ends. `None` / own / a subtree
/// socket may target any named agent, defaults to itself, and may pass /// descendant resolve freely (a parent sees its subtree, the root sees all);
/// `"*"` for a hive-wide sweep (gated on `query_agent_state`). The /// any other named agent needs `QueryAgentState`; `"*"` is a hive-wide sweep
/// non-privileged path resolves through `resolve_agent_state_target` /// gated on `QueryAgentState`.
/// (own / direct-child free; other agents need `query_agent_state`; `"*"`
/// is rejected).
fn handle_get_loose_ends( fn handle_get_loose_ends(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
target: Option<&str>, target: Option<&str>,
privileged: bool,
) -> AgentResponse { ) -> AgentResponse {
let result = if privileged { let result = if target == Some("*") {
match target { if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::QueryAgentState) {
Some("*") => { return AgentResponse::Err {
if !crate::capabilities::has_cap(agent, hive_sh4re::Capability::QueryAgentState) { message: "query_agent_state capability required for hive-wide loose ends"
return AgentResponse::Err { .to_owned(),
message: "query_agent_state capability required for hive-wide loose ends" };
.to_owned(),
};
}
crate::loose_ends::hive_wide(coord)
}
Some(name) => crate::loose_ends::for_agent(coord, name),
None => crate::loose_ends::for_agent(coord, agent),
} }
crate::loose_ends::hive_wide(coord)
} else { } else {
match resolve_agent_state_target(agent, target) { match resolve_agent_state_target(agent, target) {
Ok(name) => crate::loose_ends::for_agent(coord, name), Ok(name) => crate::loose_ends::for_agent(coord, name),
@ -704,31 +735,14 @@ fn handle_get_loose_ends(
} }
} }
/// Resolve the target agent for `CountPendingReminders` / `ReminderRollup`. /// `CountPendingReminders` — resolve the target (own / subtree free, else
/// Privileged (manager) callers may name any agent (or default to /// `QueryAgentState`) then count its pending reminders.
/// themselves); non-privileged callers go through the topology/capability
/// gate in `resolve_agent_state_target`.
fn resolve_reminder_target<'a>(
caller: &'a str,
target: Option<&'a str>,
privileged: bool,
) -> Result<&'a str, String> {
if privileged {
Ok(target.unwrap_or(caller))
} else {
resolve_agent_state_target(caller, target)
}
}
/// `CountPendingReminders` — resolve the target then count its pending
/// reminders.
fn handle_count_pending_reminders( fn handle_count_pending_reminders(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
target: Option<&str>, target: Option<&str>,
privileged: bool,
) -> AgentResponse { ) -> AgentResponse {
match resolve_reminder_target(agent, target, privileged) { match resolve_agent_state_target(agent, target) {
Ok(name) => match coord.broker.count_pending_reminders_for(name) { Ok(name) => match coord.broker.count_pending_reminders_for(name) {
Ok(count) => AgentResponse::PendingRemindersCount { count }, Ok(count) => AgentResponse::PendingRemindersCount { count },
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
@ -739,16 +753,16 @@ fn handle_count_pending_reminders(
} }
} }
/// `ReminderRollup` — resolve the target then roll up its reminders /// `ReminderRollup` — resolve the target (own / subtree free, else
/// fired in the last `since_secs`. /// `QueryAgentState`) then roll up its reminders fired in the last
/// `since_secs`.
fn handle_reminder_rollup( fn handle_reminder_rollup(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
target: Option<&str>, target: Option<&str>,
since_secs: u64, since_secs: u64,
privileged: bool,
) -> AgentResponse { ) -> AgentResponse {
match resolve_reminder_target(agent, target, privileged) { match resolve_agent_state_target(agent, target) {
Ok(name) => match coord.broker.reminder_rollup_for(name, since_secs) { Ok(name) => match coord.broker.reminder_rollup_for(name, since_secs) {
Ok(stats) => AgentResponse::ReminderRollup(stats), Ok(stats) => AgentResponse::ReminderRollup(stats),
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
@ -759,19 +773,13 @@ fn handle_reminder_rollup(
} }
} }
/// `Start` — start a container, kicking its next turn. Non-privileged /// `Start` — start a container, kicking its next turn. The caller must be an
/// callers may only start a direct child; the privileged (manager) socket /// ancestor of `name` in the topology (the root covers every agent).
/// may start any agent. async fn handle_start(coord: &Arc<Coordinator>, agent: &str, name: &str) -> AgentResponse {
async fn handle_start( if let Some(err) = require_descendant(agent, name, "start") {
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
privileged: bool,
) -> AgentResponse {
if !privileged && let Some(err) = require_child(agent, name, "start") {
return err; return err;
} }
tracing::info!(%agent, %name, %privileged, "start container"); tracing::info!(%agent, %name, "start container");
match crate::lifecycle::start(name).await { match crate::lifecycle::start(name).await {
Ok(()) => { Ok(()) => {
coord.kick_agent(name, "container started"); coord.kick_agent(name, "container started");
@ -783,17 +791,11 @@ async fn handle_start(
} }
} }
/// `Restart` — enqueue a restart for a container. Non-privileged callers /// `Restart` — enqueue a restart for a container. The caller must be an
/// may only restart a direct child; the privileged (manager) socket may /// ancestor of `name` in the topology. The infra-container branch is
/// restart any agent. The infra-container branch is orthogonal: it is gated /// orthogonal: it is gated on the `infra_admin` capability and audited, so it
/// on the `infra_admin` capability (applies to privileged + non-privileged /// stays ahead of the topology guard.
/// callers alike) and audited, so it stays ahead of the topology guard. async fn handle_restart(coord: &Arc<Coordinator>, agent: &str, name: &str) -> AgentResponse {
async fn handle_restart(
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
privileged: bool,
) -> AgentResponse {
// Infra-container restart: an agent holding the `infra_admin` // Infra-container restart: an agent holding the `infra_admin`
// capability can restart a hive infrastructure container (hive-ci / // capability can restart a hive infrastructure container (hive-ci /
// hive-gateway / hive-forge / hive-matrix) by passing its name to the // hive-gateway / hive-forge / hive-matrix) by passing its name to the
@ -803,10 +805,10 @@ async fn handle_restart(
if let Ok(container) = name.parse::<hive_sh4re::priv_proto::InfraContainer>() { if let Ok(container) = name.parse::<hive_sh4re::priv_proto::InfraContainer>() {
return handle_restart_infra(coord, agent, container).await; return handle_restart_infra(coord, agent, container).await;
} }
if !privileged && let Some(err) = require_child(agent, name, "restart") { if let Some(err) = require_descendant(agent, name, "restart") {
return err; return err;
} }
tracing::info!(%agent, %name, %privileged, "enqueue restart"); tracing::info!(%agent, %name, "enqueue restart");
coord.rebuild_queue.enqueue( coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Restart, crate::rebuild_queue::QueueKind::Restart,
name.to_owned(), name.to_owned(),
@ -869,19 +871,13 @@ async fn handle_restart_infra(
} }
} }
/// `Kill` — kill a container, unregister it, notify the manager. /// `Kill` — kill a container, unregister it, notify the manager. The caller
/// Non-privileged callers may only kill a direct child; the privileged /// must be an ancestor of `name` in the topology.
/// (manager) socket may kill any agent. async fn handle_kill(coord: &Arc<Coordinator>, agent: &str, name: &str) -> AgentResponse {
async fn handle_kill( if let Some(err) = require_descendant(agent, name, "kill") {
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
privileged: bool,
) -> AgentResponse {
if !privileged && let Some(err) = require_child(agent, name, "kill") {
return err; return err;
} }
tracing::info!(%agent, %name, %privileged, "kill container"); tracing::info!(%agent, %name, "kill container");
let result: anyhow::Result<()> = async { let result: anyhow::Result<()> = async {
crate::lifecycle::kill(name).await?; crate::lifecycle::kill(name).await?;
coord.unregister_agent(name); coord.unregister_agent(name);
@ -901,19 +897,13 @@ async fn handle_kill(
} }
} }
/// `Update` — enqueue a rebuild for a container. Non-privileged callers may /// `Update` — enqueue a rebuild for a container. The caller must be an
/// only rebuild a direct child; the privileged (manager) socket may rebuild /// ancestor of `name` in the topology.
/// any agent. fn handle_update(coord: &Arc<Coordinator>, agent: &str, name: &str) -> AgentResponse {
fn handle_update( if let Some(err) = require_descendant(agent, name, "rebuild") {
coord: &Arc<Coordinator>,
agent: &str,
name: &str,
privileged: bool,
) -> AgentResponse {
if !privileged && let Some(err) = require_child(agent, name, "rebuild") {
return err; return err;
} }
tracing::info!(%agent, %name, %privileged, "enqueue rebuild"); tracing::info!(%agent, %name, "enqueue rebuild");
coord.rebuild_queue.enqueue( coord.rebuild_queue.enqueue(
crate::rebuild_queue::QueueKind::Rebuild, crate::rebuild_queue::QueueKind::Rebuild,
name.to_owned(), name.to_owned(),
@ -963,24 +953,22 @@ async fn handle_list_descendants(agent: &str) -> AgentResponse {
AgentResponse::Containers { containers } AgentResponse::Containers { containers }
} }
/// `RequestInitConfig` — queue an `InitConfig` approval for an agent. /// `RequestInitConfig` — queue an `InitConfig` approval for an agent. The
/// Non-privileged callers may only init a (brand-new or existing) direct /// `name` must be brand-new (absent from the topology) or already in the
/// child and are recorded as its parent; the privileged (manager) socket /// caller's subtree; the requester is recorded as the new agent's parent (the
/// may init any agent and records no explicit parent edge (the new agent /// root requesting a new agent → a top-level agent, matching reconcile's
/// lands at `topology::reconcile`'s default position on first spawn). /// default).
fn handle_request_init_config( fn handle_request_init_config(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
name: &str, name: &str,
description: Option<String>, description: Option<String>,
privileged: bool,
) -> AgentResponse { ) -> AgentResponse {
if !privileged && let Some(err) = require_new_child(agent, name, "request_init_config for") { if let Some(err) = require_new_child(agent, name, "request_init_config for") {
return err; return err;
} }
tracing::info!(%agent, %name, %privileged, "request_init_config"); tracing::info!(%agent, %name, "request_init_config");
let parent = if privileged { None } else { Some(agent) }; match submit_init_config(coord, name, Some(agent), description) {
match submit_init_config(coord, name, parent, description) {
Ok(_id) => AgentResponse::Ok, Ok(_id) => AgentResponse::Ok,
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
message: format!("{e:#}"), message: format!("{e:#}"),
@ -988,23 +976,19 @@ fn handle_request_init_config(
} }
} }
/// `RequestApplyCommit` — queue an apply-commit approval for an agent. /// `RequestApplyCommit` — queue an apply-commit approval for an agent. The
/// Non-privileged callers may only target a direct child; the privileged /// target must be in the caller's subtree (the root covers every agent).
/// (manager) socket may target any agent.
async fn handle_request_apply_commit( async fn handle_request_apply_commit(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
target_agent: &str, target_agent: &str,
commit_ref: &str, commit_ref: &str,
description: Option<&str>, description: Option<&str>,
privileged: bool,
) -> AgentResponse { ) -> AgentResponse {
if !privileged if let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for") {
&& let Some(err) = require_new_child(agent, target_agent, "request_apply_commit for")
{
return err; return err;
} }
tracing::info!(%agent, %target_agent, %commit_ref, %privileged, "request_apply_commit"); tracing::info!(%agent, %target_agent, %commit_ref, "request_apply_commit");
match submit_apply_commit(coord, target_agent, commit_ref, description).await { match submit_apply_commit(coord, target_agent, commit_ref, description).await {
Ok((id, sha)) => { Ok((id, sha)) => {
tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued"); tracing::info!(%id, %target_agent, %sha, "apply_commit approval queued");
@ -1352,16 +1336,13 @@ fn auto_reminder_path(agent: &str) -> String {
format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md") format!("/agents/{agent}/state/reminders/auto-{ts_ns}.md")
} }
/// Resolve the target agent name for `GetLooseEnds`, `CountPendingReminders`, /// Resolve the target agent name for a *named* `GetLooseEnds` /
/// and `ReminderRollup` on the agent socket. Rules: /// `CountPendingReminders` / `ReminderRollup` query. Rules:
/// ///
/// - `None` → caller's own threads (always allowed). /// - `None` (or `Some(caller)`) → the caller's own threads (always allowed).
/// - `Some(caller)` → same as `None`. /// - `Some(descendant)` in the caller's subtree (the root's subtree is the whole hive) → allowed, no extra capability.
/// - `Some("<child>")` where child is a direct descendant of caller per /// - `Some(other)` outside the subtree → requires the `query_agent_state` capability; error otherwise.
/// `topology.json` → allowed without any extra capability. /// - `Some("*")` → rejected here; the hive-wide sweep is handled by `handle_get_loose_ends` under the same `query_agent_state` gate.
/// - `Some("<other>")` 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>( fn resolve_agent_state_target<'a>(
caller: &'a str, caller: &'a str,
target: Option<&'a str>, target: Option<&'a str>,
@ -1369,26 +1350,21 @@ fn resolve_agent_state_target<'a>(
match target { match target {
None => Ok(caller), None => Ok(caller),
Some("*") => Err( Some("*") => Err(
"hive-wide query (agent=\"*\") is not available on the agent socket; \ "hive-wide query (agent=\"*\") is only valid for loose-ends; \
use the manager socket for swarm-wide scans" not available for this query"
.to_owned(), .to_owned(),
), ),
Some(name) => { Some(name) => {
if name == caller { // Own subtree (the root covers all) is visible without extra
return Ok(caller); // capability; `is_descendant_of` returns true for `name == caller`.
} if crate::topology::is_descendant_of(name, 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); return Ok(name);
} }
if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) { if crate::capabilities::has_cap(caller, hive_sh4re::Capability::QueryAgentState) {
Ok(name) Ok(name)
} else { } else {
Err(format!( Err(format!(
"agent `{caller}` cannot query `{name}`: not a direct child and \ "agent `{caller}` cannot query `{name}`: not in its subtree and \
`query_agent_state` capability is not granted" `query_agent_state` capability is not granted"
)) ))
} }
@ -1421,11 +1397,11 @@ fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result<i64> {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Privileged-only handlers + submit/schedule helpers (manager socket). // Orchestration handlers + submit/schedule helpers.
// Reached only via the `require_privileged!()`-gated arms in `dispatch`, or // The schedule / meta-input handlers are reached via the tool-group-gated arms
// re-used by the agent-socket lifecycle handlers (`submit_init_config` / // in `dispatch_orchestration`; `submit_init_config` / `submit_apply_commit`
// `submit_apply_commit`) and the dashboard (`schedule_to_wire_public` / // are re-used by the lifecycle handlers; `schedule_to_wire_public` /
// `filter_ghost_schedule_targets`). // `filter_ghost_schedule_targets` are re-used by the dashboard.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval /// `RequestUpdateMetaInputs` — queue an `UpdateMetaInputs` approval
@ -1433,6 +1409,7 @@ fn resolve_due_at(timing: &hive_sh4re::ReminderTiming) -> anyhow::Result<i64> {
/// is involved; the field is the payload the approval handler decodes). /// is involved; the field is the payload the approval handler decodes).
fn handle_request_update_meta_inputs( fn handle_request_update_meta_inputs(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
requester: &str,
inputs: &[String], inputs: &[String],
description: Option<&str>, description: Option<&str>,
) -> AgentResponse { ) -> AgentResponse {
@ -1441,12 +1418,12 @@ fn handle_request_update_meta_inputs(
} else { } else {
inputs.join(", ") inputs.join(", ")
}; };
tracing::info!(%label, "manager: request_update_meta_inputs"); tracing::info!(%requester, %label, "request_update_meta_inputs");
let commit_ref = serde_json::to_string(inputs).unwrap_or_default(); let commit_ref = serde_json::to_string(inputs).unwrap_or_default();
let id = match coord let id = match coord
.approvals .approvals
.submit_kind( .submit_kind(
MANAGER_AGENT, requester,
hive_sh4re::ApprovalKind::UpdateMetaInputs, hive_sh4re::ApprovalKind::UpdateMetaInputs,
&commit_ref, &commit_ref,
description, description,
@ -1463,7 +1440,7 @@ fn handle_request_update_meta_inputs(
tracing::info!(%id, %label, "update_meta_inputs approval queued"); tracing::info!(%id, %label, "update_meta_inputs approval queued");
coord.emit_approval_added( coord.emit_approval_added(
id, id,
MANAGER_AGENT, requester,
"update_meta_inputs", "update_meta_inputs",
None, None,
None, None,
@ -1797,10 +1774,10 @@ pub(crate) fn validate_commit_ref(commit_ref: &str) -> Result<()> {
/// field (unused for `InitConfig` otherwise — same pattern /// field (unused for `InitConfig` otherwise — same pattern
/// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in /// `UpdateMetaInputs` uses to carry its inputs JSON) and consumed in
/// `run_approval_init_config` to write the `child -> parent` topology /// `run_approval_init_config` to write the `child -> parent` topology
/// edge. The agent socket passes the requesting agent. `None` (the /// edge. Callers pass the requesting agent, so the requester becomes the
/// privileged manager socket) writes no explicit edge — the new agent /// new agent's parent (the root requesting a new agent → a top-level agent,
/// lands at `topology::reconcile`'s default position when it first /// matching `topology::reconcile`'s default). `None` writes no explicit
/// spawns, so no caller has to name a specific root agent here. /// edge (reconcile-default placement) — retained for that fallback.
pub(crate) fn submit_init_config( pub(crate) fn submit_init_config(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
name: &str, name: &str,
@ -2172,24 +2149,13 @@ mod tests {
} }
#[test] #[test]
fn reminder_target_privileged_resolves_any_or_self() { fn resolve_agent_state_target_self_and_default_are_free() {
// Privileged (manager) callers may name any agent, or default to // No topology/capability state needed for these: `None` and the
// themselves — no topology/capability gate on this path. // caller's own name resolve to the caller (`is_descendant_of` short-
assert_eq!( // circuits to true when candidate == ancestor); `"*"` is rejected
resolve_reminder_target("ruth", Some("iris"), true), // (the hive-wide sweep is handled by the loose-ends caller instead).
Ok("iris") assert_eq!(resolve_agent_state_target("iris", None), Ok("iris"));
); assert_eq!(resolve_agent_state_target("iris", Some("iris")), Ok("iris"));
assert_eq!(resolve_reminder_target("ruth", None, true), Ok("ruth")); assert!(resolve_agent_state_target("iris", Some("*")).is_err());
}
#[test]
fn reminder_target_non_privileged_self_is_free() {
// The non-privileged path defers to `resolve_agent_state_target`;
// the self / default case needs no topology state.
assert_eq!(resolve_reminder_target("iris", None, false), Ok("iris"));
assert_eq!(
resolve_reminder_target("iris", Some("iris"), false),
Ok("iris")
);
} }
} }