agent permissions: one set, tool groups included, set at agent creation
mara on #5170: "dont split caps and perms. its all perms. / dont include the now empty tool groups / nothing set -> default / also add perms to agent creation screen". - hive-sh4re: one `Permission` enum (messaging, meta, inbox, execution, web_tools, manage_root_agent) replaces `ToolGroup` and `Capability`; `lifecycle`/`forge` are gone. `Permission::default_for` is the default for an agent the swarm sets nothing for. - The record is `{ "permissions": [...] }`. Missing key, unset field or no successful read yet = default; `[]` = none; a failed read after a good one keeps the last value. - hive-agent reads its own key every 10s and builds each turn's --tools, --allowedTools and MCP config (rewritten before every turn) from it; the subagent daemon reads it on each start/continue. Agents get a read-only grant on their own key. - hive-c0re restarts an agent only when its manage_root_agent changes. The import now merges meta/capabilities.json and meta/tool-groups.json into one list per agent and deletes both; the "import pending, don't read" decision is `ImportOutcome::read_allowed`, table-tested. - swarm-controller: `GET /api/permissions` (names + default) and an optional `permissions` on `POST /api/agents`, stored before the job is queued. swarm-ui's create-agent form gets a permissions field. - Deleted: HIVE_TOOL_GROUPS and its forward to the subagent daemon, tool_groups.rs, ruth's tool-group seed, the permissions rebuild job (PermPayload/WritePermFile/perm_change), the dashboard permissions tab and its stale-entries panel. Part of #5170.
This commit is contained in:
parent
f862354e43
commit
f0844f6da3
73 changed files with 1578 additions and 2742 deletions
|
|
@ -37,7 +37,6 @@ use crate::lifecycle;
|
|||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "mark-all-read, compose, spawn-request, hive stats"),
|
||||
(name = "permissions", description = "tool-group assignment for agents"),
|
||||
(name = "rebuild_queue", description = "rebuild-queue cancel"),
|
||||
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
|
||||
(name = "state_snapshot", description = "cold-load dashboard snapshot"),
|
||||
|
|
@ -56,7 +55,6 @@ mod health;
|
|||
mod lifecycle_ops;
|
||||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
pub(crate) mod permissions;
|
||||
mod rebuild_queue;
|
||||
mod state_files;
|
||||
mod state_snapshot;
|
||||
|
|
@ -124,11 +122,6 @@ fn app(coord: Arc<Coordinator>) -> axum::Router {
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
.routes(routes!(lifecycle_ops::post_kill))
|
||||
|
|
@ -376,11 +369,6 @@ mod router_build_probe {
|
|||
.routes(routes!(build_logs::get_build_logs_agent))
|
||||
.routes(routes!(build_logs::get_build_log_full))
|
||||
.routes(routes!(build_logs::get_build_log_raw))
|
||||
.routes(routes!(permissions::get_tool_groups))
|
||||
.routes(routes!(permissions::post_tool_groups))
|
||||
.routes(routes!(permissions::post_permissions))
|
||||
.routes(routes!(permissions::get_stale_permissions))
|
||||
.routes(routes!(permissions::delete_agent_permissions))
|
||||
.routes(routes!(rebuild_queue::post_rebuild_queue_cancel))
|
||||
.routes(routes!(lifecycle_ops::post_destroy))
|
||||
.routes(routes!(lifecycle_ops::post_kill))
|
||||
|
|
|
|||
|
|
@ -1,403 +0,0 @@
|
|||
//! Tool-group permission endpoints for the dashboard.
|
||||
//!
|
||||
//! Read endpoints return the full set of known groups plus
|
||||
//! descriptions and the per-agent assignment map (the UI never hard-codes
|
||||
//! the lists). Write endpoints validate, then enqueue a `PermChange` so the
|
||||
//! JSON file write is serialised through the FIFO rebuild worker.
|
||||
|
||||
use axum::{
|
||||
extract::{Path as AxumPath, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use problem_details::ProblemDetails;
|
||||
|
||||
use super::{AppState, Ident, guard_agent_name, strip_container_prefix};
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct ToolGroupsSnapshot {
|
||||
/// Ordered list of all known tool-group names. Drives the column
|
||||
/// headers in the tool-groups table — the UI does not hard-code them.
|
||||
groups: Vec<&'static str>,
|
||||
/// Short description for each group name. Keys match `groups` entries.
|
||||
descriptions: std::collections::BTreeMap<&'static str, &'static str>,
|
||||
/// Per-agent *explicit* assignment map. Absent agents use the role
|
||||
/// default — the UI uses presence here to badge an agent "(default)".
|
||||
assignments: std::collections::BTreeMap<String, Vec<String>>,
|
||||
/// Every agent the operator can manage (live roster ∪ explicit keys),
|
||||
/// sorted. The UI lists rows from this so default-perms agents always
|
||||
/// appear without depending on a separately-loaded container list.
|
||||
agents: Vec<String>,
|
||||
/// Per-agent *effective* groups: the explicit entry when present, else
|
||||
/// the role default the harness actually applies. Drives the checkbox
|
||||
/// state so default agents show their real groups, not blank.
|
||||
effective: std::collections::BTreeMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Every known tool-group name + description,
|
||||
/// plus the per-agent explicit/effective assignment maps.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/tool-groups",
|
||||
responses(
|
||||
(status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot),
|
||||
(status = 500, description = "tool-groups file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn get_tool_groups(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::Json<ToolGroupsSnapshot>, ProblemDetails> {
|
||||
let groups = hive_sh4re::permissions::ToolGroup::ALL
|
||||
.iter()
|
||||
.map(|g| <&str>::from(*g))
|
||||
.collect();
|
||||
let descriptions = hive_sh4re::permissions::ToolGroup::ALL
|
||||
.iter()
|
||||
.map(|g| (<&str>::from(*g), g.description()))
|
||||
.collect();
|
||||
let assignments = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
|
||||
let roster = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| c.name);
|
||||
let (agents, effective) =
|
||||
roster_and_effective(roster, &assignments, &tool_group_default_names());
|
||||
Ok(axum::Json(ToolGroupsSnapshot {
|
||||
groups,
|
||||
descriptions,
|
||||
assignments,
|
||||
agents,
|
||||
effective,
|
||||
}))
|
||||
}
|
||||
|
||||
/// A permission file that exists but can't be read or parsed. Surfaced
|
||||
/// as a 500 rather than an empty table, which would read as "nobody has
|
||||
/// any grants".
|
||||
fn unreadable(e: &std::io::Error) -> ProblemDetails {
|
||||
ProblemDetails::from_status_code(StatusCode::INTERNAL_SERVER_ERROR).with_detail(e.to_string())
|
||||
}
|
||||
|
||||
/// The role-default tool-group names the harness falls back to for an
|
||||
/// agent with no explicit entry (`ToolGroup::AGENT_DEFAULT`). Shared by
|
||||
/// the HTTP snapshot and the SSE emit so the effective values match what
|
||||
/// the container actually runs with.
|
||||
#[must_use]
|
||||
pub(crate) fn tool_group_default_names() -> Vec<&'static str> {
|
||||
hive_sh4re::permissions::ToolGroup::AGENT_DEFAULT
|
||||
.iter()
|
||||
.map(|g| <&str>::from(*g))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Build the `(agents, effective)` pair for a permissions snapshot.
|
||||
/// `agents` is the sorted union of the live roster and any agent that
|
||||
/// already has an explicit entry; `effective[agent]` is the explicit
|
||||
/// assignment when present, else `default` (the role fallback). Shared by
|
||||
/// the tool-groups snapshots on both the HTTP and SSE paths so the two
|
||||
/// surfaces never drift.
|
||||
#[must_use]
|
||||
pub(crate) fn roster_and_effective(
|
||||
roster: impl IntoIterator<Item = String>,
|
||||
explicit: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
default: &[&'static str],
|
||||
) -> (Vec<String>, std::collections::BTreeMap<String, Vec<String>>) {
|
||||
let mut names: std::collections::BTreeSet<String> = roster.into_iter().collect();
|
||||
names.extend(explicit.keys().cloned());
|
||||
let agents: Vec<String> = names.into_iter().collect();
|
||||
let effective = agents
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let v = explicit
|
||||
.get(a)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| default.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>());
|
||||
(a.clone(), v)
|
||||
})
|
||||
.collect();
|
||||
(agents, effective)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct SetToolGroupsBody {
|
||||
groups: Vec<String>,
|
||||
}
|
||||
|
||||
/// Replace `agent`'s explicit tool-group
|
||||
/// assignment (JSON body `{"groups": [...]}`).
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/tool-groups/{agent}",
|
||||
params(("agent" = String, Path, description = "agent name")),
|
||||
request_body = SetToolGroupsBody,
|
||||
responses(
|
||||
(status = 200, description = "tool-groups queued for write", body = String),
|
||||
(status = 400, description = "unknown group name, or no such agent"),
|
||||
(status = 404, description = "no such agent"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn post_tool_groups(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
axum::Json(body): axum::Json<SetToolGroupsBody>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
let logical = strip_container_prefix(&name);
|
||||
// `guard_agent_name` yields a ready-made rejection `Response`; pass it
|
||||
// through as `Ok` (axum sends it verbatim) rather than re-deriving a
|
||||
// `ProblemDetails` — the guard is shared with `-> Response` handlers.
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return Ok(reject);
|
||||
}
|
||||
// Validate group names before queuing — fail fast so the operator
|
||||
// sees the error immediately rather than waiting for the worker.
|
||||
if let Err(e) = crate::tool_groups::validate_groups(&body.groups) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
|
||||
}
|
||||
// Submit a PermChange DAG: the JSON file write commits under
|
||||
// META_LOCK inside the WritePermFile node, so concurrent
|
||||
// batch-apply actions for different agents never race on the
|
||||
// shared tool-groups.json.
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::perm_change(
|
||||
b,
|
||||
&logical,
|
||||
crate::job_queue::PermPayload::ToolGroups {
|
||||
groups: body.groups.clone(),
|
||||
},
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
tracing::info!(agent = %logical, groups = ?body.groups, "operator: set tool-groups via dashboard");
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// One agent's slice of a batch permission change. An omitted
|
||||
/// `tool_groups` leaves the agent untouched, an empty array clears it, a
|
||||
/// populated array fully replaces it (same replace semantics as the
|
||||
/// per-agent endpoint).
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct PermChangeBody {
|
||||
agent: String,
|
||||
#[serde(default)]
|
||||
tool_groups: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct BatchPermsBody {
|
||||
changes: Vec<PermChangeBody>,
|
||||
}
|
||||
|
||||
/// Batch permission apply — `POST /api/permissions`.
|
||||
///
|
||||
/// The save-all permissions UI sends only the agents whose groups
|
||||
/// changed; each gets one `PermChange`. The whole batch is atomic: every
|
||||
/// change is validated up front and on any validation error nothing is
|
||||
/// written or enqueued.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/permissions",
|
||||
request_body = BatchPermsBody,
|
||||
responses(
|
||||
(status = 200, description = "batch permission change queued", body = String),
|
||||
(status = 400, description = "unknown tool-group name, or no such agent"),
|
||||
(status = 404, description = "no such agent"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn post_permissions(
|
||||
State(state): State<AppState>,
|
||||
axum::Json(body): axum::Json<BatchPermsBody>,
|
||||
) -> Result<Response, ProblemDetails> {
|
||||
// Phase 1 — validate everything before touching any file or the
|
||||
// queue, so a bad entry fails the whole POST with zero side effects.
|
||||
// No-op rows (field omitted) are skipped, not errors.
|
||||
let mut staged: Vec<(String, Vec<String>)> = Vec::new();
|
||||
for change in &body.changes {
|
||||
let logical = strip_container_prefix(&change.agent);
|
||||
if let Some(reject) = guard_agent_name(&state, &logical).await {
|
||||
return Ok(reject);
|
||||
}
|
||||
if let Some(groups) = &change.tool_groups {
|
||||
if let Err(e) = crate::tool_groups::validate_groups(groups) {
|
||||
return Err(ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
|
||||
.with_detail(format!("invalid tool-groups for {logical}: {e}")));
|
||||
}
|
||||
staged.push((logical, groups.clone()));
|
||||
}
|
||||
}
|
||||
// Phase 2 — submit one PermChange DAG per affected agent.
|
||||
for (logical, groups) in staged {
|
||||
state
|
||||
.coord
|
||||
.job_queue
|
||||
.insert_job(|b| {
|
||||
crate::job_queue::templates::perm_change(
|
||||
b,
|
||||
&logical,
|
||||
crate::job_queue::PermPayload::ToolGroups { groups },
|
||||
);
|
||||
Vec::new()
|
||||
})
|
||||
.expect("template-declared shapes are acyclic");
|
||||
state.coord.emit_rebuild_queue_snapshot();
|
||||
tracing::info!(agent = %logical, "operator: batch perm change via dashboard");
|
||||
}
|
||||
Ok((StatusCode::OK, "ok").into_response())
|
||||
}
|
||||
|
||||
/// Agent names that have explicit tool-group entries but are
|
||||
/// not in the live container roster AND not in the kept-state directory
|
||||
/// list (i.e. truly gone — renamed or destroyed agents whose JSON entries
|
||||
/// persisted). The client uses this to drive the "stale permission entries"
|
||||
/// sub-section in K3PT ST4T3 without having to fetch three separate
|
||||
/// endpoints and perform set arithmetic on the client side.
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub(super) struct StalePermsResponse {
|
||||
/// Ghost agent names, sorted. Empty list → no stale entries.
|
||||
stale: Vec<String>,
|
||||
}
|
||||
|
||||
/// Agent names with explicit permission
|
||||
/// entries but no matching live container or kept-state dir.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/permissions/stale",
|
||||
responses(
|
||||
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
|
||||
(status = 500, description = "tool-groups file unreadable"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn get_stale_permissions(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<axum::Json<StalePermsResponse>, ProblemDetails> {
|
||||
// Live container names — includes stopped-but-configured containers.
|
||||
let live: std::collections::HashSet<String> = state
|
||||
.coord
|
||||
.containers_snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|c| c.name)
|
||||
.collect();
|
||||
// Kept-state directories: every agent that ever had a state dir on disk,
|
||||
// including both live containers (already in `live`) and soft-deleted
|
||||
// tombstones (destroyed but kept). A name present here is either live
|
||||
// or a properly-removed tombstone — neither is a ghost.
|
||||
let kept: std::collections::HashSet<String> =
|
||||
crate::coordinator::Coordinator::kept_state_names()
|
||||
.into_iter()
|
||||
.map(hive_types::Ident::into_string)
|
||||
.collect();
|
||||
// Known = live roster ∪ kept-state names.
|
||||
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
|
||||
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
|
||||
// `tgs` is a `BTreeMap`, so its keys are already sorted.
|
||||
let ghost_names: Vec<String> = tgs.keys().filter(|n| !known.contains(n)).cloned().collect();
|
||||
Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
|
||||
}
|
||||
|
||||
/// Clear all explicit permission entries for a named agent without
|
||||
/// requiring it to exist in the live roster.
|
||||
///
|
||||
/// Used by the P3RM1SS10NS tab's "remove" button for agents that have
|
||||
/// stale explicit entries in `tool-groups.json` but are no longer
|
||||
/// running (e.g. an agent that was renamed or destroyed while its JSON
|
||||
/// entries persisted).
|
||||
///
|
||||
/// Bypasses `guard_agent_name`'s live-roster check intentionally —
|
||||
/// the whole point is to remove entries for non-roster agents. Only
|
||||
/// the format check ([`Ident::parse`]) is applied. No rebuild is
|
||||
/// enqueued (the agent doesn't exist to rebuild); the SSE snapshot
|
||||
/// updates the P3RM1SS10NS tab live.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/permissions/{agent}",
|
||||
params(("agent" = String, Path, description = "agent name (need not be live)")),
|
||||
responses(
|
||||
(status = 200, description = "stale permission entries cleared", body = String),
|
||||
(status = 400, description = "bad agent name"),
|
||||
(status = 500, description = "tool-groups file write failed"),
|
||||
),
|
||||
tag = "permissions"
|
||||
)]
|
||||
pub(super) async fn delete_agent_permissions(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> Response {
|
||||
let logical = match Ident::parse(&strip_container_prefix(&name)) {
|
||||
Ok(n) => n,
|
||||
Err(reason) => {
|
||||
return (StatusCode::BAD_REQUEST, format!("bad agent name: {reason}")).into_response();
|
||||
}
|
||||
};
|
||||
let removed = crate::tool_groups::remove_agent(logical.as_str());
|
||||
state.coord.emit_tool_groups_snapshot();
|
||||
// Surface an I/O error as 500 so the frontend's `!resp.ok` path
|
||||
// fires and the operator sees a meaningful message rather than a
|
||||
// silent "success" followed by the row reappearing unchanged.
|
||||
if let Err(e) = removed {
|
||||
tracing::warn!(agent = %logical, error = ?e, "failed to remove tool-groups entry");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("failed to remove permission entries for {logical}: {e}"),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
tracing::info!(agent = %logical, "operator: cleared stale permission entries via dashboard");
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::roster_and_effective;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn effective_unions_roster_and_explicit_and_applies_default() {
|
||||
let mut explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
explicit.insert("iris".to_owned(), vec!["messaging".to_owned()]);
|
||||
// `ruth` has an explicit entry but isn't in the live roster (e.g.
|
||||
// momentarily not listed) — it must still appear and keep its
|
||||
// explicit value.
|
||||
explicit.insert(
|
||||
"ruth".to_owned(),
|
||||
vec!["messaging".to_owned(), "lifecycle".to_owned()],
|
||||
);
|
||||
let roster = ["damocles".to_owned(), "iris".to_owned()];
|
||||
let default = ["messaging", "meta", "inbox", "execution"];
|
||||
|
||||
let (agents, effective) = roster_and_effective(roster, &explicit, &default);
|
||||
|
||||
// Sorted union of roster + explicit keys.
|
||||
assert_eq!(agents, vec!["damocles", "iris", "ruth"]);
|
||||
// Entry-less agent (damocles) → role default.
|
||||
assert_eq!(
|
||||
effective["damocles"],
|
||||
vec!["messaging", "meta", "inbox", "execution"]
|
||||
);
|
||||
// Explicit entries kept verbatim.
|
||||
assert_eq!(effective["iris"], vec!["messaging"]);
|
||||
assert_eq!(effective["ruth"], vec!["messaging", "lifecycle"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_default_yields_empty_effective_for_entryless() {
|
||||
let explicit: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
let (agents, effective) = roster_and_effective(["atlas".to_owned()], &explicit, &[]);
|
||||
assert_eq!(agents, vec!["atlas"]);
|
||||
assert!(effective["atlas"].is_empty());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue