Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-21 01:56:37 +02:00 • committed by mara
commit 2f00f9041f
11 changed files with 121 additions and 283 deletions

View file

@ -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]
);
}
}