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

@ -598,15 +598,14 @@ async fn dispatch_orchestration(req: &Request, agent: &str, coord: &Arc<Coordina
/// 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<Response> {
if crate::tool_groups::groups_for(agent)
.iter()
.any(|g| g == group)
{
None
} else {
Some(Response::Err {
match crate::tool_groups::groups_for(agent) {
Ok(groups) if groups.iter().any(|g| g == group) => None,
Ok(_) => Some(Response::Err {
message: format!("agent `{agent}` cannot {action}: requires the `{group}` tool group"),
})
}),
Err(e) => Some(Response::Err {
message: format!("agent `{agent}` cannot {action}: {e}"),
}),
}
}
@ -699,6 +698,7 @@ fn handle_cancel_loose_end(
fn check_can_cancel_approval(canceller: &str) -> Result<(), String> {
const APPROVALS_GROUP: &str = "approvals";
if crate::tool_groups::groups_for(canceller)
.map_err(|e| format!("cancel_loose_end: {e}"))?
.iter()
.any(|g| g == APPROVALS_GROUP)
{