hive-c0re: drop the subtree check from the scheduling verbs

The topology predicate `is_descendant_of` gated the four schedule-
managing verbs: a caller could only name a schedule owned by an agent at
or below itself in `topology.json`. Those gates now permit any requester,
so the predicate, its pure `_in` form and the `schedule_authorized`
wrapper built on it are gone rather than left returning a constant. The
other two wrappers went earlier with the verbs they served —
`require_descendant` with the lifecycle MCP verbs in 87970a8c, and
`resolve_agent_state_target` with `get_loose_ends`'s agent parameter.

`require_group(agent, "scheduling", ...)` is untouched and still fires at
dispatch for every one of the five scheduling verbs, so holding the tool
group remains the gate; what goes is the ownership restriction layered on
top of it.

The three schedule-mutating verbs keep their row lookup as a plain
existence check, so a caller naming a schedule that does not exist still
gets `not found` rather than a message from deeper in the cancel path.
`list_schedules` stops filtering per row: it would only have hidden rows
the requester may act on anyway.

Error messages, tool descriptions and docs that described the subtree
relation are reworded — a refusal message naming a topology that no
longer decides anything is worse than none.

The six `is_descendant_of_in` unit tests go with the function they test;
the permit behaviour they leave unasserted is picked up by the next
commit.

Refs #4472
This commit is contained in:
atlas 2026-09-17 19:27:15 +02:00 committed by mara
commit 4121e11d87
8 changed files with 67 additions and 277 deletions

View file

@ -4,10 +4,10 @@
//! you are `foo`; the manager socket simply serves as `ruth`. There is no
//! privilege flag — both transports run the same [`serve`] / [`dispatch`]
//! code, and authority derives uniformly from the caller's identity:
//! topology (`is_descendant_of`) for subtree-relational verbs, capabilities
//! 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.
//! capabilities for the hive-wide queries and tool-group membership for the
//! orchestration verbs. An agent-targeting verb may name any agent, so `ruth`
//! reaches every agent exactly the way every other agent does, not via any
//! hardcoded name match.
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -87,10 +87,9 @@ pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result
/// Bind + serve the manager socket. This is now **pure transport**: it grants
/// no authority of its own — it just serves requests as `agent = MANAGER_AGENT`
/// ("ruth"), and ruth's reach comes entirely from being the topology root
/// (`is_descendant_of` covers every agent) plus the capabilities / tool-groups
/// it holds, identical to connecting on a per-agent socket — ruth uses the
/// standard per-agent runtime dir + socket, with no dedicated helpers.
/// ("ruth"), and ruth's reach comes entirely from the capabilities /
/// tool-groups it holds, identical to connecting on a per-agent socket — ruth
/// uses the standard per-agent runtime dir + socket, with no dedicated helpers.
pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let dir = crate::paths::agent_runtime_dir(crate::lifecycle::MANAGER_NAME);

View file

@ -1,6 +1,6 @@
//! Scheduled-prompt request handlers (`ListSchedules` /
//! `RequestSchedulePrompt` / `CancelSchedule` / `EditSchedule` /
//! `FireScheduleNow`), their shared ownership check, and the
//! `FireScheduleNow`) and the
//! schedule-to-wire mapping reused by the dashboard
//! (`schedule_to_wire_public` / `filter_ghost_schedule_targets`).
@ -10,20 +10,15 @@ use hive_core_agent_sock::Response;
use crate::coordinator::Coordinator;
/// `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.
/// `ListSchedules` — snapshot every scheduled prompt. Ownership no longer
/// narrows the view: `CancelSchedule`/`EditSchedule`/`FireScheduleNow` accept
/// any requester, so filtering the listing would only hide rows the requester
/// can act on anyway.
pub(super) fn handle_list_schedules(coord: &Arc<Coordinator>, requester: &str) -> Response {
tracing::debug!(%requester, "list schedules");
match coord.scheduled_prompts.list() {
Ok(schedules) => Response::Schedules {
schedules: schedules
.into_iter()
.filter(|s| schedule_authorized(requester, &s.owner))
.map(schedule_to_wire)
.collect(),
schedules: schedules.into_iter().map(schedule_to_wire).collect(),
},
Err(e) => Response::Err {
message: format!("list scheduled prompts: {e:#}"),
@ -108,39 +103,20 @@ pub(super) fn handle_request_schedule_prompt(
Response::Ok
}
/// Cancel a schedule (whole or per-target). Manager-surface
/// authorization: a manager can cancel its own schedules + any
/// schedule whose owner is one of its sub-agents (topology-walked).
/// The operator surface bypasses this and can cancel anything;
/// agents reaching this path through the manager get the
/// topology-scoped check.
/// Cancel a schedule (whole or per-target). Any requester may cancel any
/// schedule, whoever owns it.
pub(super) fn handle_cancel_schedule(
coord: &Arc<Coordinator>,
requester: &str,
schedule_id: i64,
targets: Option<&[String]>,
) -> Response {
let schedule = match coord.scheduled_prompts.get(schedule_id) {
Ok(Some(s)) => s,
Ok(None) => {
return Response::Err {
message: format!("schedule {schedule_id} not found"),
};
}
Err(e) => {
return Response::Err {
message: format!("read schedule {schedule_id}: {e:#}"),
};
}
};
if !schedule_authorized(requester, &schedule.owner) {
return Response::Err {
message: format!(
"not authorized: {requester} cannot cancel schedule owned by {owner}",
owner = schedule.owner
),
};
// Existence check only — it buys the caller a clean "not found" instead
// of whatever the cancel itself would say about an absent row.
if let Some(err) = require_schedule(coord, schedule_id) {
return err;
}
tracing::info!(%requester, %schedule_id, "cancel schedule");
let result = match targets {
Some(list) if !list.is_empty() => coord
.scheduled_prompts
@ -160,37 +136,18 @@ pub(super) fn handle_cancel_schedule(
}
}
/// Authorize + dispatch a `FireScheduleNow` request from the
/// manager surface. Same ownership rules as `CancelSchedule`:
/// requester can fire its own schedules + any owned by an agent
/// in its subtree. The actual fan-out lives in
/// Dispatch a `FireScheduleNow` request. Same rule as `CancelSchedule`: any
/// requester, any schedule. The actual fan-out lives in
/// `scheduled_prompts_worker::fire_now`.
pub(super) async fn handle_fire_schedule_now(
coord: &Arc<Coordinator>,
requester: &str,
schedule_id: i64,
) -> Response {
let schedule = match coord.scheduled_prompts.get(schedule_id) {
Ok(Some(s)) => s,
Ok(None) => {
return Response::Err {
message: format!("schedule {schedule_id} not found"),
};
}
Err(e) => {
return Response::Err {
message: format!("read schedule {schedule_id}: {e:#}"),
};
}
};
if !schedule_authorized(requester, &schedule.owner) {
return Response::Err {
message: format!(
"not authorized: {requester} cannot fire schedule owned by {owner}",
owner = schedule.owner
),
};
if let Some(err) = require_schedule(coord, schedule_id) {
return err;
}
tracing::info!(%requester, %schedule_id, "fire schedule now");
// MCP fire_schedule_now stays no-reset (cadence intact); the
// reset-timer option is a dashboard-dialog affordance.
match crate::scheduled_prompts_worker::fire_now(coord, schedule_id, false).await {
@ -222,13 +179,11 @@ pub(super) struct EditSchedulePatch {
pub(super) targets_remove: Option<Vec<String>>,
}
/// Authorize + dispatch a `EditSchedule` patch. Same ownership
/// rules as `CancelSchedule` — the manager can edit
/// schedules it owns + any owned by an agent in its subtree.
/// Forwards the partial payload to
/// Dispatch an `EditSchedule` patch. Same rule as `CancelSchedule`: any
/// requester, any schedule. Forwards the partial payload to
/// `ScheduledPrompts::update` which enforces the cancelled-row /
/// zero-interval validation. Returns `Ok` on a clean update;
/// `Err` with the underlying message on any auth / validation
/// `Err` with the underlying message on any validation
/// failure so the dashboard can surface it verbatim.
pub(super) fn handle_edit_schedule(
coord: &Arc<Coordinator>,
@ -244,27 +199,10 @@ pub(super) fn handle_edit_schedule(
targets_add,
targets_remove,
} = patch;
let schedule = match coord.scheduled_prompts.get(schedule_id) {
Ok(Some(s)) => s,
Ok(None) => {
return Response::Err {
message: format!("schedule {schedule_id} not found"),
};
}
Err(e) => {
return Response::Err {
message: format!("read schedule {schedule_id}: {e:#}"),
};
}
};
if !schedule_authorized(requester, &schedule.owner) {
return Response::Err {
message: format!(
"not authorized: {requester} cannot edit schedule owned by {owner}",
owner = schedule.owner
),
};
if let Some(err) = require_schedule(coord, schedule_id) {
return err;
}
tracing::info!(%requester, %schedule_id, "edit schedule");
let patch = crate::scheduled_prompts::UpdateSchedule {
body,
description,
@ -284,23 +222,19 @@ pub(super) fn handle_edit_schedule(
}
}
/// 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;
/// Shared existence guard for the three schedule-mutating verbs: `None` when
/// the row is there, `Some(Err)` to short-circuit when it isn't or the read
/// failed. Not a permission check — any requester may act on any schedule.
fn require_schedule(coord: &Arc<Coordinator>, schedule_id: i64) -> Option<Response> {
match coord.scheduled_prompts.get(schedule_id) {
Ok(Some(_)) => None,
Ok(None) => Some(Response::Err {
message: format!("schedule {schedule_id} not found"),
}),
Err(e) => Some(Response::Err {
message: format!("read schedule {schedule_id}: {e:#}"),
}),
}
if requester == hive_sh4re::manager::OPERATOR_RECIPIENT {
return true;
}
// 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)
}
/// Map a `scheduled_prompts::Schedule` to its public wire shape.
@ -396,32 +330,6 @@ 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() {
let live: std::collections::HashSet<String> = ["iris".to_owned(), "damocles".to_owned()]