diff --git a/hive-c0re/src/agent_config/capabilities.rs b/hive-c0re/src/agent_config/capabilities.rs index 90e9f57b..c520b0e6 100644 --- a/hive-c0re/src/agent_config/capabilities.rs +++ b/hive-c0re/src/agent_config/capabilities.rs @@ -66,8 +66,9 @@ fn prune_unknown(map: &mut BTreeMap>) -> bool { /// that exists but can't be read or parsed is an error. Any entry that /// isn't a recognised [`Capability`] is dropped (with a `warn!`) from /// what's returned — a stale or typo'd name is never honoured — but -/// `read` itself never writes; the on-disk file only gets repaired the -/// next time something calls `set_caps`/`remove_agent` anyway. +/// `read` itself never writes. Unknown names stay on disk until the next +/// write: `set_caps` always rewrites the file, `remove_agent` only when +/// the agent had an entry. pub fn read() -> std::io::Result>> { read_from(&capabilities_path()) } @@ -78,16 +79,21 @@ fn read_from(path: &Path) -> std::io::Result>> { Ok(map) } -/// Look up the configured capabilities for one agent. Returns an empty -/// vec when the agent has no entry. Errors as [`read`] does. -pub fn caps_for(name: &str) -> std::io::Result> { - Ok(read()?.get(name).cloned().unwrap_or_default()) +/// Look up the configured capabilities for one agent in the file at +/// `path` (normally [`capabilities_path`]). Returns an empty vec when +/// the agent has no entry. Errors as [`read`] does. +pub fn caps_for(path: &Path, name: &str) -> std::io::Result> { + Ok(read_from(path)?.get(name).cloned().unwrap_or_default()) } -/// Check whether an agent holds a specific capability. Errors as -/// [`read`] does. -pub fn has_cap(name: &str, cap: hive_sh4re::permissions::Capability) -> std::io::Result { - Ok(caps_for(name)? +/// Check whether an agent holds a specific capability, per the file at +/// `path` (normally [`capabilities_path`]). Errors as [`read`] does. +pub fn has_cap( + path: &Path, + name: &str, + cap: hive_sh4re::permissions::Capability, +) -> std::io::Result { + Ok(caps_for(path, name)? .iter() .any(|s| s.eq_ignore_ascii_case(<&str>::from(cap)))) } @@ -154,7 +160,7 @@ fn remove_agent_at(path: &Path, name: &str) -> std::io::Result<()> { mod tests { use super::*; - // `read`/`set_caps`/`remove_agent` shell out to `crate::paths::meta_root`, + // `read`/`set_caps`/`remove_agent` call `crate::paths::meta_root`, // which is hardcoded to `/var/lib/hyperhive` (no test override) — so // these tests pin the pure decision logic (`prune_unknown`, // `apply_known_caps`) against in-memory maps, and the file handling @@ -178,15 +184,18 @@ mod tests { #[test] fn set_caps_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); - set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]) + let err = set_caps_at(&path, "ruth", &["manage_root_agent".to_owned()]) .expect_err("a corrupt file must not be overwritten"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn remove_agent_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); - remove_agent_at(&path, "atlas").expect_err("a corrupt file must not be overwritten"); + let err = + remove_agent_at(&path, "atlas").expect_err("a corrupt file must not be overwritten"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } diff --git a/hive-c0re/src/agent_config/tool_groups.rs b/hive-c0re/src/agent_config/tool_groups.rs index 04d5b9ec..7213ae29 100644 --- a/hive-c0re/src/agent_config/tool_groups.rs +++ b/hive-c0re/src/agent_config/tool_groups.rs @@ -127,15 +127,21 @@ mod tests { #[test] fn set_groups_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); - set_groups_at(&path, "ruth", &["messaging".to_owned()]) + let err = set_groups_at(&path, "ruth", &["messaging".to_owned()]) .expect_err("a corrupt file must not be overwritten"); + let kind = err + .downcast_ref::() + .map(std::io::Error::kind); + assert_eq!(kind, Some(std::io::ErrorKind::InvalidData)); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } #[test] fn remove_agent_leaves_a_corrupt_file_untouched() { let (_dir, path) = corrupt_file(); - remove_agent_at(&path, "alice").expect_err("a corrupt file must not be overwritten"); + let err = + remove_agent_at(&path, "alice").expect_err("a corrupt file must not be overwritten"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); assert_eq!(std::fs::read(&path).expect("read"), TRUNCATED.as_bytes()); } diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs index e1bb5bc4..2c581fff 100644 --- a/hive-c0re/src/lifecycle/host_config.rs +++ b/hive-c0re/src/lifecycle/host_config.rs @@ -135,6 +135,24 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec) { } } +/// Whether `agent_name` holds `ManageRootAgent` per the capabilities file +/// at `caps_path`. An unreadable file denies: the error is logged and the +/// agent starts without the cross-agent mounts, so one corrupt file never +/// blocks any agent's start, spawn or swap. +fn holds_manage_root_agent(agent_name: &str, caps_path: &Path) -> bool { + crate::capabilities::has_cap(caps_path, agent_name, Capability::ManageRootAgent).unwrap_or_else( + |e| { + tracing::error!( + agent = %agent_name, + path = %caps_path.display(), + error = ?e, + "capabilities unreadable — no ManageRootAgent mounts" + ); + false + }, + ) +} + /// Env var naming the host directory `swarm-bao-queue-agent.service` lands /// this hive's agent queue credential in. Set by the hive-c0re NixOS module /// from `deploy.hive-controller.queue.agentCredentialDir`; absent means this @@ -339,7 +357,7 @@ async fn set_nspawn_flags( // parent field took with it the unconditional grant every // agent used to get over its own direct children — so an agent with // no capability now sees its own dirs and nothing else. - if crate::capabilities::has_cap(agent_name, Capability::ManageRootAgent)? { + if holds_manage_root_agent(agent_name, &crate::capabilities::capabilities_path()) { // Skipping self is a no-op, not a narrowing: `agent_notes_dir` is // `agent_state_dir/state` and `config_bind_source` is shared, so // binding the holder as its own virtual child reproduced the two @@ -408,7 +426,7 @@ async fn set_nspawn_flags( mod tests { use super::{ BindMount, QUEUE_CLIENT_ID_CREDENTIAL, QUEUE_SECRET_CREDENTIAL, bind_child_agent_dirs, - queue_agent_credentials, + holds_manage_root_agent, queue_agent_credentials, }; fn child_binds() -> Vec { @@ -524,4 +542,23 @@ mod tests { ] ); } + + #[test] + fn a_corrupt_capabilities_file_denies_without_failing() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("capabilities.json"); + let truncated = "{\n \"ruth\": [\"manage_root_ag"; + std::fs::write(&path, truncated).expect("seed"); + assert!(!holds_manage_root_agent("ruth", &path)); + assert_eq!(std::fs::read(&path).expect("read"), truncated.as_bytes()); + } + + #[test] + fn a_readable_grant_is_honoured() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("capabilities.json"); + std::fs::write(&path, r#"{"ruth": ["manage_root_agent"]}"#).expect("seed"); + assert!(holds_manage_root_agent("ruth", &path)); + assert!(!holds_manage_root_agent("alice", &path)); + } } diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 42fce888..ddd7b3bd 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -1316,6 +1316,9 @@ where nixosConfigurations = { "#, ); + // Propagated, never read as empty: an agent with no tool-groups entry + // renders `toolGroups = null` and gets `AGENT_DEFAULT`, which fails + // open for any agent whose explicit entry is narrower. let tool_groups_map = crate::tool_groups::read()?; let capabilities_map = crate::capabilities::read()?; let resource_limits_map = crate::resource_limits::read();