diff --git a/docs/tools/scheduling.md b/docs/tools/scheduling.md index c7857847..57e00328 100644 --- a/docs/tools/scheduling.md +++ b/docs/tools/scheduling.md @@ -47,10 +47,13 @@ consumes one-shot schedules and cancels them afterwards. ### `list_schedules()` -Snapshot every schedule (active + cancelled-but-not-reaped): id, -owner, body, per-target `last_fired_at` + `last_result`, -`next_fire_at_unix`, `interval_seconds`. Use to look up an id before -cancelling, or to audit upcoming wake-ups across the swarm. +Snapshot the schedules you're authorized to see (active, and cancelled +but not yet reaped) — same read scope as the rest of this group: your +own, plus any owned by a sub-agent in your subtree (everything, for +the operator). Returns id, owner, body, per-target `last_fired_at` +and `last_result`, `next_fire_at_unix`, `interval_seconds`. Use to +look up an id before cancelling, or to audit upcoming wake-ups in +your subtree. ## `diagnostics` tool group diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 525df488..cbb35631 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -977,11 +977,12 @@ impl AgentServer { } #[tool( - description = "List every scheduled prompt in the queue (active + cancelled but \ - not yet reaped). Returns the full snapshot — schedule id, owner, body, target set \ - with per-target last_fired_at + last_result, next fire time, recurring interval. \ + description = "List the scheduled prompts you're authorized to see — your own, plus \ + any owned by a sub-agent in your subtree (everything, for the operator) — active and \ + cancelled-but-not-yet-reaped. Returns schedule id, owner, body, target set with \ + per-target last_fired_at + last_result, next fire time, recurring interval. \ Use this to look up an id before calling `cancel_schedule`, or to audit what \ - the swarm is going to be woken up about next." + your subtree is going to be woken up about next." )] async fn list_schedules(&self) -> String { run_tool_envelope("list_schedules", String::new(), async move { diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 5cb0e37e..ec50aafe 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -633,7 +633,7 @@ async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc { if let Some(err) = require_group(agent, "scheduling", "fire a schedule") { diff --git a/hive-c0re/src/socket_server/schedules.rs b/hive-c0re/src/socket_server/schedules.rs index 41d015fb..987009a1 100644 --- a/hive-c0re/src/socket_server/schedules.rs +++ b/hive-c0re/src/socket_server/schedules.rs @@ -10,11 +10,20 @@ use hive_core_agent_sock::Response; use crate::coordinator::Coordinator; -/// `ListSchedules` — snapshot every scheduled prompt onto the wire. -pub(super) fn handle_list_schedules(coord: &Arc) -> Response { +/// `ListSchedules` — snapshot the scheduled prompts `requester` is +/// authorized to see: its own schedules, ones owned by an agent in its +/// subtree, or (for the operator) everything. Uses the same +/// `schedule_authorized` rule `CancelSchedule`/`EditSchedule`/ +/// `FireScheduleNow` already enforce, applied here as a per-row filter +/// instead of a hard reject. +pub(super) fn handle_list_schedules(coord: &Arc, requester: &str) -> Response { match coord.scheduled_prompts.list() { Ok(schedules) => Response::Schedules { - schedules: schedules.into_iter().map(schedule_to_wire).collect(), + schedules: schedules + .into_iter() + .filter(|s| schedule_authorized(requester, &s.owner)) + .map(schedule_to_wire) + .collect(), }, Err(e) => Response::Err { message: format!("list scheduled prompts: {e:#}"), @@ -115,7 +124,7 @@ pub(super) fn handle_cancel_schedule( }; } }; - if !cancel_authorized(requester, &schedule.owner) { + if !schedule_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot cancel schedule owned by {owner}", @@ -165,7 +174,7 @@ pub(super) async fn handle_fire_schedule_now( }; } }; - if !cancel_authorized(requester, &schedule.owner) { + if !schedule_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot fire schedule owned by {owner}", @@ -239,7 +248,7 @@ pub(super) fn handle_edit_schedule( }; } }; - if !cancel_authorized(requester, &schedule.owner) { + if !schedule_authorized(requester, &schedule.owner) { return Response::Err { message: format!( "not authorized: {requester} cannot edit schedule owned by {owner}", @@ -266,21 +275,22 @@ pub(super) fn handle_edit_schedule( } } -/// Permission check for `CancelSchedule` on the manager surface. -/// `requester` (always `ruth` here) can cancel its own schedules. -/// Sub-agent ownership is delegated to topology — see -/// `crate::topology::is_descendant_of`. Also reused by -/// `handle_fire_schedule_now` — fire-auth follows the same shape. -fn cancel_authorized(requester: &str, owner: &str) -> bool { +/// Permission check shared by the whole scheduling surface: `requester` +/// can act on a schedule it owns, on one owned by an agent in its +/// subtree (delegated to topology — see `crate::topology::is_descendant_of`), +/// or, as the operator, on anything. `CancelSchedule`/`EditSchedule`/ +/// `FireScheduleNow` apply it as a hard reject; `ListSchedules` applies +/// it as a per-row filter. +fn schedule_authorized(requester: &str, owner: &str) -> bool { if requester == owner { return true; } if requester == hive_sh4re::manager::OPERATOR_RECIPIENT { return true; } - // Manager can cancel anything owned by an agent in its subtree. - // For the current single-manager topology that covers everything, - // but the check stays correct as the tree grows. + // Manager can act on anything owned by an agent in its subtree. For + // the current single-manager topology that covers everything, but + // the check stays correct as the tree grows. crate::topology::is_descendant_of(owner, requester) } @@ -302,11 +312,12 @@ pub fn schedule_to_wire_public( /// last `nixos-container list` scan (stopped agents included, destroyed /// ones absent); the `operator` pseudo-target is always retained since /// it isn't a container. Applied only to the dashboard wire paths -/// (`api_schedules` + the `SchedulesChanged` SSE emit) — the -/// manager-facing `list_schedules` stays unfiltered so agents can still -/// see and cancel stale targets. This is a view filter: the underlying -/// schedule rows keep every target, so a re-spawned agent's targets -/// reappear on their own. +/// (`api_schedules` + the `SchedulesChanged` SSE emit) — `list_schedules` +/// doesn't apply this filter (it still returns every live target, ghost +/// or not, on the schedules the requester is authorized to see) so an +/// agent can still see and cancel its own stale targets. This is a view +/// filter: the underlying schedule rows keep every target, so a +/// re-spawned agent's targets reappear on their own. pub(crate) fn filter_ghost_schedule_targets( schedules: &mut [hive_sh4re::schedule::WireSchedule], live: &std::collections::HashSet, @@ -379,6 +390,32 @@ mod tests { } } + #[test] + fn schedule_authorized_self_is_true() { + // Disk-free branch — never reaches `is_descendant_of`. + assert!(schedule_authorized("iris", "iris")); + } + + #[test] + fn schedule_authorized_operator_is_true() { + // Disk-free branch — never reaches `is_descendant_of`. + assert!(schedule_authorized( + hive_sh4re::manager::OPERATOR_RECIPIENT, + "iris" + )); + } + + #[test] + fn schedule_authorized_unrelated_requester_is_false() { + // Control: neither self nor operator, and no on-disk topology in + // a test sandbox connects these two names — pins that the + // predicate isn't accidentally stuck at `true`. + assert!(!schedule_authorized( + "definitely-not-the-owner", + "also-not-the-requester" + )); + } + #[test] fn ghost_filter_drops_dead_agents_keeps_live_and_operator() { let live: std::collections::HashSet = ["iris".to_owned(), "damocles".to_owned()] diff --git a/hive-core-agent-sock/src/lib.rs b/hive-core-agent-sock/src/lib.rs index df98ed50..690af6cb 100644 --- a/hive-core-agent-sock/src/lib.rs +++ b/hive-core-agent-sock/src/lib.rs @@ -191,7 +191,9 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] targets: Option>, }, - /// *(privileged)* List every schedule in the queue. + /// List the schedules the requester is authorized to see: its own, + /// plus any owned by an agent in its subtree (everything, for the + /// operator). ListSchedules, /// List the calling agent's subtree — children, their children, and so /// on down, plus the caller itself, which is part of its own subtree. @@ -294,7 +296,8 @@ pub enum Response { /// requested filters. Returned on the agent socket when the agent /// holds the `read_host_journal` capability. HostJournal { content: String }, - /// `ListSchedules` result. Snapshot of every schedule. + /// `ListSchedules` result. Snapshot of the schedules the requester is + /// authorized to see — see `ListSchedules`'s own doc comment. /// Returned on the manager socket only. Schedules { schedules: Vec }, /// `CreateRepo` result: the new repo's full name (`agents/`)