Until its first successful read of its key, the harness read its permissions as the default set, `execution` included. So did the subagent daemon. An agent the operator had narrowed ran wider than granted after every restart while the queue was unreachable, and in the gap before the first 10 s poll. During the meta-repo import, every non-ruth key is still unset, and the agent read that as the default too, for as long as the import stayed pending. mara picked option (a) on #5293: "a, removal of the old path as a follow up in 1w". hive-c0re now writes each agent's entry from swarm-agent-permissions.json into the agent's runtime dir as swarm-permissions.json (/run/hive in the container), together with whether the import is still pending. It writes the file at every start (write_dropins) and after every 30 s pass of the permissions worker. The harness and the subagent daemon hold that stored list until their own first read succeeds. While the import is pending, an unset key keeps the stored list rather than reverting it to the default. A queue that is down never blocks a start: the agent runs on the stored grant. The decision lives in swarm_queue_client::agent_permissions::HiveCopy, shared by both readers. Part of #5170.
338 lines
12 KiB
Rust
338 lines
12 KiB
Rust
//! The one-shot move of the permissions this hive's meta repo held
|
|
//! (`meta/capabilities.json` and `meta/tool-groups.json`) into the swarm's
|
|
//! `agent-permissions` bucket, as one list per agent.
|
|
//!
|
|
//! At boot, before any agent starts, [`seed_local`] copies the files' grants
|
|
//! into [`crate::permissions`] when nothing is stored there yet, so agents
|
|
//! keep their mounts until the swarm answers. [`send_pending`] then sends the
|
|
//! grants to swarm-controller, which writes each agent's key only where there
|
|
//! is none, and on its answer deletes both files from the meta repo. Nothing
|
|
//! reads them after that.
|
|
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use hive_sh4re::permissions::Permission;
|
|
use swarm_queue_client::agent_permissions::{
|
|
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
|
|
};
|
|
|
|
const CAPABILITIES_FILE: &str = "capabilities.json";
|
|
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
|
|
|
|
/// The meta repo files the grants are moved out of.
|
|
pub const META_FILES: &[&str] = &[CAPABILITIES_FILE, TOOL_GROUPS_FILE];
|
|
|
|
/// Tool groups the files may name that give nothing and have no
|
|
/// [`Permission`]: dropped.
|
|
const EMPTY_TOOL_GROUPS: &[&str] = &["lifecycle", "forge"];
|
|
|
|
type Grants = BTreeMap<String, Vec<Permission>>;
|
|
type FileMap = BTreeMap<String, Vec<String>>;
|
|
|
|
fn parse(agent: &str, name: &str) -> Option<Permission> {
|
|
let found = Permission::ALL
|
|
.iter()
|
|
.copied()
|
|
.find(|p| name.eq_ignore_ascii_case(<&str>::from(*p)));
|
|
if found.is_none()
|
|
&& !EMPTY_TOOL_GROUPS
|
|
.iter()
|
|
.any(|g| name.eq_ignore_ascii_case(g))
|
|
{
|
|
tracing::warn!(%agent, permission = %name, "import: dropping unrecognised name");
|
|
}
|
|
found
|
|
}
|
|
|
|
/// The one list per agent the two files add up to, as the swarm stores it.
|
|
/// `None` for either file means it does not exist.
|
|
///
|
|
/// Each half is what the agent held under the old readers:
|
|
/// - tool groups: the entry's known names; an absent entry, or one naming
|
|
/// only unknown names, was the default groups. `lifecycle`/`forge` count as
|
|
/// known (they parsed) and are then dropped, so an entry of only those
|
|
/// held no tools and imports as none.
|
|
/// - capabilities: the entry's names. With the file present, `ruth` having no
|
|
/// entry means her grant was revoked: the file was created holding it, and
|
|
/// a revoke removed the entry. With no file, the default.
|
|
///
|
|
/// An agent whose list equals [`Permission::default_for`] is left out, so its
|
|
/// key stays unset and follows the default.
|
|
fn grants(caps: Option<&FileMap>, groups: Option<&FileMap>) -> Grants {
|
|
let mut agents: BTreeSet<&str> = BTreeSet::new();
|
|
agents.extend(caps.iter().flat_map(|m| m.keys().map(String::as_str)));
|
|
agents.extend(groups.iter().flat_map(|m| m.keys().map(String::as_str)));
|
|
if caps.is_some() {
|
|
agents.insert(crate::lifecycle::MANAGER_NAME);
|
|
}
|
|
let mut out = Grants::new();
|
|
for agent in agents {
|
|
let default = Permission::default_for(agent);
|
|
let tool_default: Vec<Permission> = default
|
|
.iter()
|
|
.copied()
|
|
.filter(|p| *p != Permission::ManageRootAgent)
|
|
.collect();
|
|
let tool_part = match groups.and_then(|m| m.get(agent)) {
|
|
Some(names)
|
|
if names.iter().any(|n| {
|
|
parse(agent, n).is_some()
|
|
|| EMPTY_TOOL_GROUPS.iter().any(|g| n.eq_ignore_ascii_case(g))
|
|
}) =>
|
|
{
|
|
names.iter().filter_map(|n| parse(agent, n)).collect()
|
|
}
|
|
_ => tool_default,
|
|
};
|
|
let cap_part: Vec<Permission> = match caps {
|
|
Some(m) => m
|
|
.get(agent)
|
|
.map(|names| names.iter().filter_map(|n| parse(agent, n)).collect())
|
|
.unwrap_or_default(),
|
|
None => default
|
|
.iter()
|
|
.copied()
|
|
.filter(|p| *p == Permission::ManageRootAgent)
|
|
.collect(),
|
|
};
|
|
let mut list: Vec<Permission> = Vec::new();
|
|
for perm in tool_part.into_iter().chain(cap_part) {
|
|
if !list.contains(&perm) {
|
|
list.push(perm);
|
|
}
|
|
}
|
|
let same_as_default =
|
|
list.len() == default.len() && default.iter().all(|p| list.contains(p));
|
|
if !same_as_default {
|
|
out.insert(agent.to_owned(), list);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn read_file(path: &Path) -> Result<Option<FileMap>> {
|
|
if !path.exists() {
|
|
return Ok(None);
|
|
}
|
|
let map = serde_json::from_slice(
|
|
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
|
|
)
|
|
.with_context(|| format!("parsing {}", path.display()))?;
|
|
Ok(Some(map))
|
|
}
|
|
|
|
/// The grants the files in `meta` hold, or `None` when neither exists.
|
|
fn read_grants(meta: &Path) -> Result<Option<Grants>> {
|
|
let caps = read_file(&meta.join(CAPABILITIES_FILE))?;
|
|
let groups = read_file(&meta.join(TOOL_GROUPS_FILE))?;
|
|
if caps.is_none() && groups.is_none() {
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(grants(caps.as_ref(), groups.as_ref())))
|
|
}
|
|
|
|
fn meta_dir() -> PathBuf {
|
|
crate::paths::meta_root()
|
|
}
|
|
|
|
/// Whether a meta repo file is still waiting to be imported.
|
|
#[must_use]
|
|
pub fn pending() -> bool {
|
|
let meta = meta_dir();
|
|
META_FILES.iter().any(|f| meta.join(f).exists())
|
|
}
|
|
|
|
/// Store the files' grants as the last-read swarm permissions, when a file
|
|
/// is still there and nothing has been stored yet.
|
|
pub fn seed_local() {
|
|
if let Err(e) = seed_local_at(&meta_dir(), &crate::permissions::path()) {
|
|
tracing::warn!(error = ?e, "import: seeding the stored permissions from the meta repo failed");
|
|
}
|
|
}
|
|
|
|
fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
|
|
if stored.exists() {
|
|
return Ok(());
|
|
}
|
|
let Some(grants) = read_grants(meta)? else {
|
|
return Ok(());
|
|
};
|
|
let records = grants
|
|
.into_iter()
|
|
.map(|(agent, perms)| {
|
|
(
|
|
agent,
|
|
AgentPermissions {
|
|
permissions: Some(perms),
|
|
},
|
|
)
|
|
})
|
|
.collect();
|
|
crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
|
|
tracing::info!("import: seeded the stored permissions from the meta repo");
|
|
Ok(())
|
|
}
|
|
|
|
/// What one attempt to send the import came to.
|
|
#[derive(Debug)]
|
|
pub enum ImportOutcome {
|
|
/// No meta file is left: nothing to import.
|
|
NothingPending,
|
|
/// The controller took the grants and the files are deleted.
|
|
Imported,
|
|
/// The controller answered with a refusal; the files stay.
|
|
Refused(String),
|
|
/// No answer (the controller or the queue is down), or a file could not
|
|
/// be read or deleted; the files stay.
|
|
Unsettled(anyhow::Error),
|
|
}
|
|
|
|
impl ImportOutcome {
|
|
/// Whether the swarm's keys may be read after this outcome. While a file
|
|
/// is left, an unset key may only mean its grants are not imported yet,
|
|
/// and reading it as the default would undo them.
|
|
#[must_use]
|
|
pub fn read_allowed(&self) -> bool {
|
|
matches!(self, Self::NothingPending | Self::Imported)
|
|
}
|
|
|
|
/// Why reading is not allowed, for the caller's log and banner.
|
|
#[must_use]
|
|
pub fn into_error(self) -> Option<anyhow::Error> {
|
|
match self {
|
|
Self::NothingPending | Self::Imported => None,
|
|
Self::Refused(why) => Some(anyhow::anyhow!("the controller refused the import: {why}")),
|
|
Self::Unsettled(e) => Some(e.context("importing the meta repo's permissions")),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Send the files' grants to swarm-controller while a file is there, and
|
|
/// delete both once the controller has answered that it handled them. The
|
|
/// next pass sends again after anything but [`ImportOutcome::Imported`]; the
|
|
/// controller keeps a key it already has.
|
|
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> ImportOutcome {
|
|
match send_at(client, hive, &meta_dir()).await {
|
|
Ok(outcome) => outcome,
|
|
Err(e) => ImportOutcome::Unsettled(e),
|
|
}
|
|
}
|
|
|
|
async fn send_at(client: &async_nats::Client, hive: &str, meta: &Path) -> Result<ImportOutcome> {
|
|
let Some(permissions) = read_grants(meta)? else {
|
|
return Ok(ImportOutcome::NothingPending);
|
|
};
|
|
swarm_queue_client::ensure_connected(client)?;
|
|
let payload = serde_json::to_vec(&Import { permissions })?;
|
|
let reply = tokio::time::timeout(
|
|
IMPORT_REPLY_DEADLINE,
|
|
client.request(agent_permissions::import_subject(hive), payload.into()),
|
|
)
|
|
.await
|
|
.context("the controller did not answer the import")?
|
|
.context("sending the import")?;
|
|
match serde_json::from_slice(&reply.payload).context("decoding the controller's answer")? {
|
|
ImportReply::Imported {
|
|
written,
|
|
kept,
|
|
skipped,
|
|
} => {
|
|
tracing::info!(
|
|
?written,
|
|
?kept,
|
|
?skipped,
|
|
"import: the controller took the meta repo's permissions"
|
|
);
|
|
crate::meta::remove_permission_files()
|
|
.await
|
|
.context("deleting the imported permission files")?;
|
|
Ok(ImportOutcome::Imported)
|
|
}
|
|
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{FileMap, grants, seed_local_at};
|
|
use hive_sh4re::permissions::Permission::{self, *};
|
|
|
|
fn file(json: &str) -> FileMap {
|
|
serde_json::from_str(json).expect("decodes")
|
|
}
|
|
|
|
/// Both files add up per agent; the empty groups and unknown names drop;
|
|
/// an agent left on the default is not sent.
|
|
#[test]
|
|
fn the_two_files_merge_into_one_list() {
|
|
let caps = file(r#"{"ruth": ["manage_root_agent"], "atlas": ["manage_root_agent"]}"#);
|
|
let groups = file(
|
|
r#"{"ruth": ["messaging", "meta", "inbox", "lifecycle", "execution"],
|
|
"atlas": ["messaging", "web_tools", "forge"],
|
|
"iris": ["messaging", "fly_to_the_moon"],
|
|
"sock": ["lifecycle"]}"#,
|
|
);
|
|
let out = grants(Some(&caps), Some(&groups));
|
|
assert!(!out.contains_key("ruth"), "ruth's seed is her default");
|
|
assert_eq!(out["atlas"], vec![Messaging, WebTools, ManageRootAgent]);
|
|
assert_eq!(out["iris"], vec![Messaging]);
|
|
assert_eq!(out["sock"], Vec::<Permission>::new());
|
|
}
|
|
|
|
/// Without a capabilities entry, ruth's existing file means a revoke; with
|
|
/// no capabilities file at all, her default stands.
|
|
#[test]
|
|
fn a_revoked_ruth_stays_revoked() {
|
|
let out = grants(Some(&file("{}")), None);
|
|
assert_eq!(out["ruth"], vec![Messaging, Meta, Inbox, Execution]);
|
|
|
|
let out = grants(None, Some(&file(r#"{"atlas": ["messaging"]}"#)));
|
|
assert!(!out.contains_key("ruth"));
|
|
assert_eq!(out["atlas"], vec![Messaging]);
|
|
}
|
|
|
|
/// The seed writes once, from the files, and never over a stored read.
|
|
#[test]
|
|
fn the_seed_copies_the_files_only_when_nothing_is_stored() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let meta = dir.path();
|
|
let stored = dir.path().join("stored.json");
|
|
|
|
seed_local_at(meta, &stored).expect("no file is no seed");
|
|
assert!(!stored.exists());
|
|
|
|
std::fs::write(
|
|
meta.join("capabilities.json"),
|
|
r#"{"atlas": ["manage_root_agent"]}"#,
|
|
)
|
|
.expect("seed");
|
|
seed_local_at(meta, &stored).expect("seeded");
|
|
assert!(crate::permissions::holds(&stored, "atlas", ManageRootAgent).expect("read"));
|
|
assert!(!crate::permissions::holds(&stored, "ruth", ManageRootAgent).expect("read"));
|
|
|
|
std::fs::write(&stored, "{}").expect("a later read");
|
|
seed_local_at(meta, &stored).expect("left alone");
|
|
assert_eq!(std::fs::read_to_string(&stored).expect("read"), "{}");
|
|
}
|
|
|
|
/// The read gate, one row per outcome: only a settled import lets the
|
|
/// swarm's keys be read.
|
|
#[test]
|
|
fn keys_are_read_only_once_the_import_is_settled() {
|
|
use super::ImportOutcome;
|
|
for (outcome, allowed) in [
|
|
(ImportOutcome::NothingPending, true),
|
|
(ImportOutcome::Imported, true),
|
|
(ImportOutcome::Refused("not a hive".to_owned()), false),
|
|
(
|
|
ImportOutcome::Unsettled(anyhow::anyhow!("no responders")),
|
|
false,
|
|
),
|
|
] {
|
|
assert_eq!(outcome.read_allowed(), allowed, "{outcome:?}");
|
|
assert_eq!(outcome.into_error().is_none(), allowed);
|
|
}
|
|
}
|
|
}
|