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
|
|
@ -1,75 +1,146 @@
|
|||
//! The one-shot move of the capabilities this hive's meta repo held
|
||||
//! (`meta/capabilities.json`) into the swarm's `agent-permissions` bucket.
|
||||
//! 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 file's grants
|
||||
//! into [`crate::capabilities`] when nothing is stored there yet, so agents
|
||||
//! 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 the file from the meta repo. Nothing
|
||||
//! reads the file after that.
|
||||
//! is none, and on its answer deletes both files from the meta repo. Nothing
|
||||
//! reads them after that.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_sh4re::permissions::Capability;
|
||||
use hive_sh4re::permissions::Permission;
|
||||
use swarm_queue_client::agent_permissions::{
|
||||
self, AgentPermissions, IMPORT_REPLY_DEADLINE, Import, ImportReply,
|
||||
};
|
||||
|
||||
/// The meta repo file the grants are moved out of.
|
||||
pub const META_FILE: &str = "capabilities.json";
|
||||
const CAPABILITIES_FILE: &str = "capabilities.json";
|
||||
const TOOL_GROUPS_FILE: &str = "tool-groups.json";
|
||||
|
||||
fn meta_path() -> PathBuf {
|
||||
crate::paths::meta_root().join(META_FILE)
|
||||
/// 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 grants the meta file holds, by agent, as the swarm stores them. A name
|
||||
/// that is not a [`Capability`] is dropped, with a warning.
|
||||
/// 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.
|
||||
///
|
||||
/// `ruth` with no entry in a file that exists had her grant revoked: the file
|
||||
/// was created holding it, and a revoke removed the entry. She is sent an
|
||||
/// empty list, since unset would give her the default back.
|
||||
fn grants(file: BTreeMap<String, Vec<String>>) -> BTreeMap<String, Vec<Capability>> {
|
||||
let mut out: BTreeMap<String, Vec<Capability>> = file
|
||||
.into_iter()
|
||||
.map(|(agent, names)| {
|
||||
let caps = names
|
||||
/// 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()
|
||||
.filter_map(|name| {
|
||||
let cap = Capability::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|c| name.eq_ignore_ascii_case(<&str>::from(*c)));
|
||||
if cap.is_none() {
|
||||
tracing::warn!(%agent, capability = %name, "import: dropping unrecognised capability");
|
||||
}
|
||||
cap
|
||||
})
|
||||
.collect();
|
||||
(agent, caps)
|
||||
})
|
||||
.collect();
|
||||
out.entry(crate::lifecycle::MANAGER_NAME.to_owned())
|
||||
.or_default();
|
||||
.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_grants(path: &Path) -> Result<Option<BTreeMap<String, Vec<Capability>>>> {
|
||||
fn read_file(path: &Path) -> Result<Option<FileMap>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let file: BTreeMap<String, Vec<String>> = serde_json::from_slice(
|
||||
let map = serde_json::from_slice(
|
||||
&std::fs::read(path).with_context(|| format!("reading {}", path.display()))?,
|
||||
)
|
||||
.with_context(|| format!("parsing {}", path.display()))?;
|
||||
Ok(Some(grants(file)))
|
||||
Ok(Some(map))
|
||||
}
|
||||
|
||||
/// Store the meta file's grants as the last-read swarm permissions, when the
|
||||
/// file is still there and nothing has been stored yet.
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// 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_path(), &crate::capabilities::path()) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
@ -83,39 +154,71 @@ fn seed_local_at(meta: &Path, stored: &Path) -> Result<()> {
|
|||
};
|
||||
let records = grants
|
||||
.into_iter()
|
||||
.map(|(agent, caps)| {
|
||||
.map(|(agent, perms)| {
|
||||
(
|
||||
agent,
|
||||
AgentPermissions {
|
||||
capabilities: Some(caps),
|
||||
permissions: Some(perms),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
crate::capabilities::write_at(stored, &records).context("storing the seeded permissions")?;
|
||||
tracing::info!("import: seeded the stored permissions from the meta repo's capabilities");
|
||||
crate::permissions::write_at(stored, &records).context("storing the seeded permissions")?;
|
||||
tracing::info!("import: seeded the stored permissions from the meta repo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send the meta file's grants to swarm-controller while the file is there,
|
||||
/// and delete it once the controller has answered that it handled them.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// The file is still there: the import failed, or deleting the file did. The
|
||||
/// next pass sends again; the controller keeps a key it already has.
|
||||
pub async fn send_pending(client: &async_nats::Client, hive: &str) -> Result<()> {
|
||||
send_at(client, hive, &meta_path())
|
||||
.await
|
||||
.context("importing the meta repo's capabilities")
|
||||
/// 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),
|
||||
}
|
||||
|
||||
async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result<()> {
|
||||
let Some(capabilities) = read_grants(path)? else {
|
||||
return Ok(());
|
||||
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 { capabilities })?;
|
||||
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()),
|
||||
|
|
@ -133,65 +236,96 @@ async fn send_at(client: &async_nats::Client, hive: &str, path: &Path) -> Result
|
|||
?written,
|
||||
?kept,
|
||||
?skipped,
|
||||
"import: the controller took the meta repo's capabilities"
|
||||
"import: the controller took the meta repo's permissions"
|
||||
);
|
||||
crate::meta::remove_capabilities_file()
|
||||
crate::meta::remove_permission_files()
|
||||
.await
|
||||
.context("deleting the imported capabilities file")
|
||||
}
|
||||
ImportReply::Failed { error } => {
|
||||
anyhow::bail!("the controller refused the import: {error}")
|
||||
.context("deleting the imported permission files")?;
|
||||
Ok(ImportOutcome::Imported)
|
||||
}
|
||||
ImportReply::Failed { error } => Ok(ImportOutcome::Refused(error)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use super::{FileMap, grants, seed_local_at};
|
||||
use hive_sh4re::permissions::Permission::{self, *};
|
||||
|
||||
use super::{grants, seed_local_at};
|
||||
use hive_sh4re::permissions::Capability;
|
||||
|
||||
fn file(json: &str) -> BTreeMap<String, Vec<String>> {
|
||||
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 grants_keep_known_names_and_a_revoked_ruth_stays_revoked() {
|
||||
let out = grants(file(
|
||||
r#"{"atlas": ["manage_root_agent", "fly_to_the_moon"], "iris": []}"#,
|
||||
));
|
||||
assert_eq!(out["atlas"], vec![Capability::ManageRootAgent]);
|
||||
assert_eq!(out["iris"], Vec::<Capability>::new());
|
||||
assert_eq!(out["ruth"], Vec::<Capability>::new());
|
||||
|
||||
let out = grants(file(r#"{"ruth": ["manage_root_agent"]}"#));
|
||||
assert_eq!(out["ruth"], vec![Capability::ManageRootAgent]);
|
||||
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());
|
||||
}
|
||||
|
||||
/// The seed writes once, from the file, and never over a stored read.
|
||||
/// Without a capabilities entry, ruth's existing file means a revoke; with
|
||||
/// no capabilities file at all, her default stands.
|
||||
#[test]
|
||||
fn the_seed_copies_the_file_only_when_nothing_is_stored() {
|
||||
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().join("capabilities.json");
|
||||
let meta = dir.path();
|
||||
let stored = dir.path().join("stored.json");
|
||||
|
||||
seed_local_at(&meta, &stored).expect("no file is no seed");
|
||||
seed_local_at(meta, &stored).expect("no file is no seed");
|
||||
assert!(!stored.exists());
|
||||
|
||||
std::fs::write(&meta, r#"{"atlas": ["manage_root_agent"]}"#).expect("seed");
|
||||
seed_local_at(&meta, &stored).expect("seeded");
|
||||
assert!(
|
||||
crate::capabilities::holds(&stored, "atlas", Capability::ManageRootAgent)
|
||||
.expect("read")
|
||||
);
|
||||
assert!(
|
||||
!crate::capabilities::holds(&stored, "ruth", Capability::ManageRootAgent)
|
||||
.expect("read")
|
||||
);
|
||||
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");
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue