agent permissions: drop hive-c0re's swarm-agent-permissions.json fallback, fail closed before the first read
Removes the HiveCopy hand-off: hive-c0re no longer writes each agent's entry from swarm-agent-permissions.json into its runtime dir as swarm-permissions.json, and the harness and subagent daemon no longer read it. hive-c0re keeps the file as its own last read of the swarm for the manage_root_agent mounts. Also removes permissions_import::pending(), its only caller being the hand-off. No nix provisioned the copy, so no nix option is removed and no tombstone is needed. Rule after this change: until an agent's first successful read of its key it holds no permissions (no execution, so no bash), and a subagent it starts holds the same. After a read a set key wins, [] holds none and an unset key is the default. A failed read keeps the last value, so an unreachable queue at start keeps the agent closed until a read succeeds. The harness logs once at warn that it starts closed. Fixes #5315
This commit is contained in:
parent
6e7b5f84e9
commit
2f00f9041f
11 changed files with 121 additions and 283 deletions
|
|
@ -7,18 +7,16 @@
|
|||
//! loop asks [`current`] for every turn's `--tools`, `--allowedTools` and MCP
|
||||
//! config, so a change reaches the next turn.
|
||||
//!
|
||||
//! Until the first successful read after a start, the agent holds what
|
||||
//! hive-c0re stored for it ([`HiveCopy::before_read`]), so a queue that is down
|
||||
//! or slow leaves it on its last known grant, not the default. A read is
|
||||
//! judged by [`HiveCopy::after_read`]: an unset key is the default, except
|
||||
//! while the hive's import is pending. A failed read keeps the last value.
|
||||
//! Until the first successful read after a start the agent holds nothing
|
||||
//! ([`BEFORE_FIRST_READ`]), so a queue that is down at start never leaves it
|
||||
//! wider than the swarm grants. After one, a set key wins, `[]` holds none and
|
||||
//! an unset key is the default. A failed read keeps the last value.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{PoisonError, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use hive_types::Permission;
|
||||
use swarm_queue_client::agent_permissions::{HIVE_COPY_PATH, HiveCopy, Read, read};
|
||||
use swarm_queue_client::agent_permissions::{BEFORE_FIRST_READ, Read, held, read};
|
||||
|
||||
/// How often the key is read. Also the longest a change can take to reach
|
||||
/// the agent's next turn.
|
||||
|
|
@ -34,40 +32,50 @@ pub fn current() -> Vec<Permission> {
|
|||
.read()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.clone();
|
||||
current_with(last, Path::new(HIVE_COPY_PATH))
|
||||
held_now(last)
|
||||
}
|
||||
|
||||
/// [`current`] with the last read and the hive's copy given.
|
||||
fn current_with(last: Option<Vec<Permission>>, copy: &Path) -> Vec<Permission> {
|
||||
last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read())
|
||||
/// What the agent holds given the last value read: that value, or nothing
|
||||
/// before the first read has succeeded.
|
||||
fn held_now(last: Option<Vec<Permission>>) -> Vec<Permission> {
|
||||
last.unwrap_or_else(|| BEFORE_FIRST_READ.to_vec())
|
||||
}
|
||||
|
||||
/// What one read of the key leaves the agent holding, judged against the
|
||||
/// hive's copy at `copy`; `None` (keep the last value) when the read failed
|
||||
/// or the bucket did not answer.
|
||||
fn after(copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> {
|
||||
/// What one read of the key leaves the agent holding; `None` (keep the last
|
||||
/// value) when the read failed or the bucket did not answer.
|
||||
fn after(read: Option<Read>) -> Option<Vec<Permission>> {
|
||||
match read? {
|
||||
Read::Current(record) => Some(HiveCopy::read_at(copy).after_read(&record)),
|
||||
Read::Current(record) => Some(held(&record)),
|
||||
Read::NoBucket => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The last value read once `read` has been applied to `last`.
|
||||
fn settle(last: Option<Vec<Permission>>, read: Option<Read>) -> Option<Vec<Permission>> {
|
||||
after(read).or(last)
|
||||
}
|
||||
|
||||
/// Run the poller for the harness's lifetime, if this agent has a label to
|
||||
/// name its key with.
|
||||
pub async fn run() {
|
||||
let agent = crate::identity::label();
|
||||
if agent.is_empty() {
|
||||
tracing::warn!("this agent has no label; not reading its swarm permissions");
|
||||
tracing::warn!("this agent has no label; it holds no swarm permissions");
|
||||
return;
|
||||
}
|
||||
let client = crate::swarm_queue::client().await;
|
||||
tracing::info!(%agent, "reading swarm permissions from the swarm queue");
|
||||
tracing::warn!(
|
||||
%agent,
|
||||
"permissions: holding none (no bash) until the first read of this agent's key succeeds"
|
||||
);
|
||||
loop {
|
||||
let read = read_once(&client, &agent).await;
|
||||
if let Some(perms) = after(Path::new(HIVE_COPY_PATH), read) {
|
||||
{
|
||||
let mut current = CURRENT.write().unwrap_or_else(PoisonError::into_inner);
|
||||
if current.as_ref() != Some(&perms) {
|
||||
tracing::info!(?perms, "permissions: the swarm's set changed");
|
||||
*current = Some(perms);
|
||||
let next = settle(current.clone(), read);
|
||||
if *current != next {
|
||||
tracing::info!(perms = ?next, "permissions: the swarm's set changed");
|
||||
*current = next;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
|
|
@ -95,77 +103,53 @@ mod tests {
|
|||
Read::Current(serde_json::from_str(json).expect("decodes"))
|
||||
}
|
||||
|
||||
/// A hive's copy holding `json`, in a tempdir kept alive by the guard.
|
||||
fn copy(json: &str) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let path = dir.path().join("swarm-permissions.json");
|
||||
std::fs::write(&path, json).expect("write the copy");
|
||||
(dir, path)
|
||||
/// What the agent holds after each read in turn, from a fresh start.
|
||||
fn held_after(reads: Vec<Option<Read>>) -> Vec<Permission> {
|
||||
held_now(reads.into_iter().fold(None, settle))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_sets_the_list_or_the_default_and_a_failure_keeps_the_last() {
|
||||
let none = Path::new("/nonexistent/swarm-permissions.json");
|
||||
assert_eq!(
|
||||
after(none, Some(record(r#"{"permissions":["web_tools"]}"#))),
|
||||
Some(vec![Permission::WebTools])
|
||||
fn before_the_first_read_execution_is_not_held() {
|
||||
let held = held_after(Vec::new());
|
||||
assert!(held.is_empty(), "held {held:?} before any read");
|
||||
assert!(!held.contains(&Permission::Execution));
|
||||
assert!(
|
||||
Permission::DEFAULT.contains(&Permission::Execution),
|
||||
"control: the default does hold it"
|
||||
);
|
||||
assert_eq!(
|
||||
after(none, Some(record(r#"{"permissions":[]}"#))),
|
||||
Some(Vec::new())
|
||||
);
|
||||
assert_eq!(
|
||||
after(none, Some(record("{}"))),
|
||||
Some(Permission::DEFAULT.to_vec())
|
||||
);
|
||||
assert_eq!(after(none, Some(Read::NoBucket)), None);
|
||||
assert_eq!(after(none, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn before_the_first_read_the_stored_grant_applies() {
|
||||
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
|
||||
assert_eq!(current_with(None, &path), vec![Permission::WebTools]);
|
||||
let (_dir, path) = copy(r#"{"permissions":[]}"#);
|
||||
assert_eq!(current_with(None, &path), Vec::new());
|
||||
assert_eq!(
|
||||
current_with(Some(vec![Permission::Meta]), &path),
|
||||
vec![Permission::Meta],
|
||||
"a read outranks the copy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn while_the_import_is_pending_an_unset_key_keeps_the_stored_grant() {
|
||||
let (_dir, path) = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#);
|
||||
assert_eq!(
|
||||
after(&path, Some(record("{}"))),
|
||||
Some(vec![Permission::WebTools])
|
||||
);
|
||||
assert_eq!(
|
||||
after(&path, Some(record(r#"{"permissions":["meta"]}"#))),
|
||||
Some(vec![Permission::Meta]),
|
||||
"a set key still wins"
|
||||
);
|
||||
let (_dir, path) = copy(r#"{"permissions":["web_tools"]}"#);
|
||||
assert_eq!(
|
||||
after(&path, Some(record("{}"))),
|
||||
Some(Permission::DEFAULT.to_vec()),
|
||||
"once imported, unset is the default"
|
||||
);
|
||||
}
|
||||
|
||||
/// No read ever succeeds: the last value stays unset and the agent holds
|
||||
/// the hive's stored grant.
|
||||
#[test]
|
||||
fn with_the_queue_down_the_agent_holds_the_stored_grant() {
|
||||
let (_dir, path) = copy(r#"{"permissions":["messaging"],"import_pending":true}"#);
|
||||
let mut last = None;
|
||||
for read in [None, Some(Read::NoBucket), None] {
|
||||
if let Some(perms) = after(&path, read) {
|
||||
last = Some(perms);
|
||||
}
|
||||
for failed in [None, Some(Read::NoBucket)] {
|
||||
assert!(
|
||||
held_after(vec![failed]).is_empty(),
|
||||
"a read that fails leaves the agent closed"
|
||||
);
|
||||
}
|
||||
assert_eq!(current_with(last, &path), vec![Permission::Messaging]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_with_the_key_unset_holds_the_default() {
|
||||
let held = held_after(vec![Some(record("{}"))]);
|
||||
assert_eq!(held, Permission::DEFAULT.to_vec());
|
||||
assert!(held.contains(&Permission::Execution));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_with_a_restricted_set_holds_exactly_that_set() {
|
||||
assert_eq!(
|
||||
held_after(vec![Some(record(r#"{"permissions":["web_tools"]}"#))]),
|
||||
vec![Permission::WebTools]
|
||||
);
|
||||
assert_eq!(
|
||||
held_after(vec![Some(record(r#"{"permissions":[]}"#))]),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_read_after_a_success_keeps_the_last_set() {
|
||||
let web = Some(record(r#"{"permissions":["web_tools"]}"#));
|
||||
assert_eq!(
|
||||
held_after(vec![web, None, Some(Read::NoBucket)]),
|
||||
vec![Permission::WebTools]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue