Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: fail on an unparseable permission file, write it atomically

tool_groups::read and capabilities::read returned an empty map when
their file existed but didn't parse. Every set_*/remove_agent is a
read-modify-write, and write() rewrote the file in place, so a crash or
ENOSPC mid-write left a truncated file, and the next write (e.g. the
manager-spawn seed of ruth's tool groups) replaced it with a map holding
only one agent. The scheduling and approval gates then denied every
other agent, recoverable only from meta git history.

- Both registries now read through agent_config::read_map: a missing
  file is still the empty map, any other read failure or a parse
  failure is an io::Error. set_groups / set_caps / remove_agent fail
  without writing.
- Writes go through agent_config::write_map: temp file in the same
  directory, fsync, rename, fsync the directory. hive-c0re had no
  shared atomic-write helper (the existing tmp+rename sites are inline
  and don't fsync).
- Callers of read / groups_for / has_cap now handle the error:
  * dashboard GET /api/tool-groups, /api/capabilities,
    /api/permissions/stale return 500 instead of an empty table;
  * the SSE permission snapshots are skipped with a warn;
  * render_flake returns Result, so sync_agents fails instead of
    rendering every agent without its tool groups / capabilities;
  * set_nspawn_flags propagates has_cap's error;
  * the socket tool-group gates deny with the read error as message;
  * seed_manager_tool_groups logs and does not seed.
- capabilities::write had no callers left once set_caps writes through
  write_map, and is removed.

Closes #4719
This commit is contained in:
atlas 2026-09-26 02:17:54 +02:00 • committed by mara
commit e0b08fe362
9 changed files with 341 additions and 129 deletions

View file

@ -42,12 +42,15 @@ pub(super) struct ToolGroupsSnapshot {
#[utoipa::path(
get,
path = "/api/tool-groups",
responses((status = 200, description = "tool-group catalogue + assignments", body = ToolGroupsSnapshot)),
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>,
) -> axum::Json<ToolGroupsSnapshot> {
) -> Result<axum::Json<ToolGroupsSnapshot>, ProblemDetails> {
let groups = hive_sh4re::permissions::ToolGroup::ALL
.iter()
.map(|g| <&str>::from(*g))
@ -56,7 +59,7 @@ pub(super) async fn get_tool_groups(
.iter()
.map(|g| (<&str>::from(*g), g.description()))
.collect();
let assignments = crate::tool_groups::read();
let assignments = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
let roster = state
.coord
.containers_snapshot()
@ -65,13 +68,20 @@ pub(super) async fn get_tool_groups(
.map(|c| c.name);
let (agents, effective) =
roster_and_effective(roster, &assignments, &tool_group_default_names());
axum::Json(ToolGroupsSnapshot {
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
@ -198,19 +208,22 @@ pub(super) struct CapabilitiesSnapshot {
#[utoipa::path(
get,
path = "/api/capabilities",
responses((status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot)),
responses(
(status = 200, description = "capability catalogue + assignments", body = CapabilitiesSnapshot),
(status = 500, description = "capabilities file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_capabilities(
State(state): State<AppState>,
) -> axum::Json<CapabilitiesSnapshot> {
) -> Result<axum::Json<CapabilitiesSnapshot>, ProblemDetails> {
use hive_sh4re::permissions::Capability;
let caps = Capability::ALL.iter().map(|c| <&str>::from(*c)).collect();
let descriptions = Capability::ALL
.iter()
.map(|c| (<&str>::from(*c), c.description()))
.collect();
let assignments = crate::capabilities::read();
let assignments = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let roster = state
.coord
.containers_snapshot()
@ -219,13 +232,13 @@ pub(super) async fn get_capabilities(
.map(|c| c.name);
// Capability default is "no extra caps" — empty default slice.
let (agents, effective) = roster_and_effective(roster, &assignments, &[]);
axum::Json(CapabilitiesSnapshot {
Ok(axum::Json(CapabilitiesSnapshot {
caps,
descriptions,
assignments,
agents,
effective,
})
}))
}
#[derive(Deserialize, ToSchema)]
@ -406,12 +419,15 @@ pub(super) struct StalePermsResponse {
#[utoipa::path(
get,
path = "/api/permissions/stale",
responses((status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse)),
responses(
(status = 200, description = "ghost agent names with stale permission entries", body = StalePermsResponse),
(status = 500, description = "tool-groups/capabilities file unreadable"),
),
tag = "permissions"
)]
pub(super) async fn get_stale_permissions(
State(state): State<AppState>,
) -> axum::Json<StalePermsResponse> {
) -> Result<axum::Json<StalePermsResponse>, ProblemDetails> {
// Live container names — includes stopped-but-configured containers.
let live: std::collections::HashSet<String> = state
.coord
@ -432,8 +448,8 @@ pub(super) async fn get_stale_permissions(
// Known = live roster ∪ kept-state names.
let known: std::collections::HashSet<&String> = live.iter().chain(kept.iter()).collect();
// Explicit entries in either JSON file.
let caps = crate::capabilities::read();
let tgs = crate::tool_groups::read();
let caps = crate::capabilities::read().map_err(|e| unreadable(&e))?;
let tgs = crate::tool_groups::read().map_err(|e| unreadable(&e))?;
let mut ghost_names: Vec<String> = caps
.keys()
.chain(tgs.keys())
@ -443,7 +459,7 @@ pub(super) async fn get_stale_permissions(
.into_iter()
.collect();
ghost_names.sort();
axum::Json(StalePermsResponse { stale: ghost_names })
Ok(axum::Json(StalePermsResponse { stale: ghost_names }))
}
/// Clear all explicit permission entries for a named agent without