Watch
0
0
Fork
You've already forked hyperhive
0

agent permissions: hold hive-c0re's stored grant until the agent's own read

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.
This commit is contained in:
atlas 2026-10-15 13:39:51 +02:00 • committed by mara
commit 89013b5408
11 changed files with 329 additions and 51 deletions

View file

@ -7,45 +7,50 @@
//! 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, and for an unset key, the
//! agent holds [`Permission::default_for`]. A failed read keeps the last
//! value.
//! 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.
use std::path::Path;
use std::sync::{PoisonError, RwLock};
use std::time::Duration;
use hive_sh4re::permissions::Permission;
use swarm_queue_client::agent_permissions::{AgentPermissions, Read, read};
use swarm_queue_client::agent_permissions::{HIVE_COPY_PATH, HiveCopy, Read, read};
/// How often the key is read. Also the longest a change can take to reach
/// the agent's next turn.
const POLL_INTERVAL: Duration = Duration::from_secs(10);
/// The last value read; `None` until the first read, which is the default.
/// The last value read; `None` until the first read succeeds.
static CURRENT: RwLock<Option<Vec<Permission>>> = RwLock::new(None);
/// The permissions this agent holds now.
#[must_use]
pub fn current() -> Vec<Permission> {
CURRENT
let last = CURRENT
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
.unwrap_or_else(|| Permission::default_for(&crate::identity::label()))
.clone();
current_with(last, Path::new(HIVE_COPY_PATH), &crate::identity::label())
}
/// What one read of the key leaves the agent holding: the swarm's list when
/// it sets one, the default when the key is unset, and `None` (keep the last
/// value) when the read failed or the bucket did not answer.
fn after(agent: &str, read: Option<Read>) -> Option<Vec<Permission>> {
/// [`current`] with the last read and the hive's copy given.
fn current_with(last: Option<Vec<Permission>>, copy: &Path, agent: &str) -> Vec<Permission> {
last.unwrap_or_else(|| HiveCopy::read_at(copy).before_read(agent))
}
/// 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(agent: &str, copy: &Path, read: Option<Read>) -> Option<Vec<Permission>> {
match read? {
Read::Current(AgentPermissions { permissions }) => {
Some(permissions.unwrap_or_else(|| Permission::default_for(agent)))
}
Read::Current(record) => Some(HiveCopy::read_at(copy).after_read(agent, &record)),
Read::NoBucket => None,
}
}
/// Run the poller for the harness's lifetime, if this agent has a label to
/// name its key with.
pub async fn run() {
@ -57,7 +62,8 @@ pub async fn run() {
let client = crate::swarm_queue::client().await;
tracing::info!(%agent, "reading swarm permissions from the swarm queue");
loop {
if let Some(perms) = after(&agent, read_once(&client, &agent).await) {
let read = read_once(&client, &agent).await;
if let Some(perms) = after(&agent, 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");
@ -89,21 +95,87 @@ 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)
}
#[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("atlas", Some(record(r#"{"permissions":["web_tools"]}"#))),
after(
"atlas",
none,
Some(record(r#"{"permissions":["web_tools"]}"#))
),
Some(vec![Permission::WebTools])
);
assert_eq!(
after("atlas", Some(record(r#"{"permissions":[]}"#))),
after("atlas", none, Some(record(r#"{"permissions":[]}"#))),
Some(Vec::new())
);
assert_eq!(
after("atlas", Some(record("{}"))),
after("atlas", none, Some(record("{}"))),
Some(Permission::DEFAULT.to_vec())
);
assert_eq!(after("atlas", Some(Read::NoBucket)), None);
assert_eq!(after("atlas", None), None);
assert_eq!(after("atlas", none, Some(Read::NoBucket)), None);
assert_eq!(after("atlas", 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, "atlas"),
vec![Permission::WebTools]
);
let (_dir, path) = copy(r#"{"permissions":[]}"#);
assert_eq!(current_with(None, &path, "atlas"), Vec::new());
assert_eq!(
current_with(Some(vec![Permission::Meta]), &path, "atlas"),
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("atlas", &path, Some(record("{}"))),
Some(vec![Permission::WebTools])
);
assert_eq!(
after("atlas", &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("atlas", &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("atlas", &path, read) {
last = Some(perms);
}
}
assert_eq!(
current_with(last, &path, "atlas"),
vec![Permission::Messaging]
);
}
}