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

@ -51,7 +51,7 @@ operator-profiles = ["async-nats/jetstream"]
# `agent_limits::read`, for hive-c0re, which reads the key of each agent it
# runs the same way. Explicit for the same reason as `notices`.
agent-limits = ["async-nats/jetstream"]
# `agent_permissions::read` and `HiveCopy`, for hive-c0re, the agent
# `agent_permissions::read` and `held`, for hive-c0re, the agent
# harness, the subagent daemon and swarm-controller. Pulls `hive-types` for the
# `Permission` names, so the crates that only need the bucket name do not link
# it.

View file

@ -1,5 +1,4 @@
//! Opening and reading the per-agent permissions KV bucket, and the copy
//! hive-c0re keeps of a record for the agent's own processes. The names, the
//! Opening and reading the per-agent permissions KV bucket. The names, the
//! stored type and the import messages are `swarm_wire::agent_permissions`;
//! reading needs the `agent-permissions` feature and writing needs `kv`.
@ -34,72 +33,21 @@ pub enum Read {
NoBucket,
}
/// The file hive-c0re writes an agent's [`HiveCopy`] to in that agent's
/// runtime dir.
/// What an agent holds until its first read of its key succeeds: nothing, the
/// narrowest set. Not the default, which includes `Execution`; a read of an
/// unset key is what makes an agent hold the default.
#[cfg(feature = "agent-permissions")]
pub const HIVE_COPY_FILE: &str = "swarm-permissions.json";
pub const BEFORE_FIRST_READ: &[hive_types::Permission] = &[];
/// Where the agent's own processes read [`HIVE_COPY_FILE`]: the runtime dir
/// is mounted at `/run/hive` in the container.
/// The permissions held under `record`: its list when set (`[]` holds none),
/// the default when unset.
#[cfg(feature = "agent-permissions")]
pub const HIVE_COPY_PATH: &str = "/run/hive/swarm-permissions.json";
/// What hive-c0re holds for one agent: its record as the hive last stored it
/// (read from this bucket, or taken from the meta repo before the import),
/// and whether the meta repo's import is still pending. The agent's harness
/// and subagent daemon hold [`HiveCopy::before_read`] until their own first
/// read of the key succeeds, and judge every read with
/// [`HiveCopy::after_read`].
#[cfg(feature = "agent-permissions")]
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HiveCopy {
#[serde(flatten)]
pub stored: AgentPermissions,
#[serde(default)]
pub import_pending: bool,
}
#[cfg(feature = "agent-permissions")]
impl HiveCopy {
/// The copy at `path`. A missing or unreadable file is the empty copy,
/// which holds the default and has no import pending.
#[must_use]
pub fn read_at(path: &std::path::Path) -> Self {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "permissions: the hive's copy is unreadable");
return Self::default();
}
};
serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!(path = %path.display(), error = %e, "permissions: the hive's copy does not decode");
Self::default()
})
}
/// What the agent holds before any read of its key has succeeded: the
/// stored list, or the default when the hive stored none.
#[must_use]
pub fn before_read(&self) -> Vec<hive_types::Permission> {
self.stored
.permissions
.clone()
.unwrap_or_else(|| hive_types::Permission::DEFAULT.to_vec())
}
/// What the agent holds after reading `read` from its key. An unset key
/// while the import is pending may only mean the grant is not imported
/// yet, so the stored list stands; otherwise unset is the default.
#[must_use]
pub fn after_read(&self, read: &AgentPermissions) -> Vec<hive_types::Permission> {
match &read.permissions {
Some(list) => list.clone(),
None if self.import_pending => self.before_read(),
None => hive_types::Permission::DEFAULT.to_vec(),
}
}
#[must_use]
pub fn held(record: &AgentPermissions) -> Vec<hive_types::Permission> {
record
.permissions
.clone()
.unwrap_or_else(|| hive_types::Permission::DEFAULT.to_vec())
}
/// Read `agent`'s current permissions with one direct get: the one subject
@ -162,28 +110,23 @@ fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result<AgentPe
mod tests {
use super::*;
/// Before a read the stored list stands; after one, a set key wins and an
/// unset key is the default unless the import is pending.
#[cfg(feature = "agent-permissions")]
/// A set key wins, `[]` holds none, and an unset key is the default.
#[test]
fn the_hive_copy_stands_until_read_and_while_the_import_is_pending() {
fn an_unset_key_holds_the_default() {
use hive_types::Permission;
let record = |json: &str| -> AgentPermissions { serde_json::from_str(json).unwrap() };
let copy = |json: &str| -> HiveCopy { serde_json::from_str(json).unwrap() };
let web = vec![Permission::WebTools];
let default = Permission::DEFAULT.to_vec();
assert_eq!(copy(r#"{"permissions":["web_tools"]}"#).before_read(), web);
assert_eq!(copy("{}").before_read(), default);
let pending = copy(r#"{"permissions":["web_tools"],"import_pending":true}"#);
assert_eq!(pending.after_read(&record("{}")), web);
assert_eq!(
pending.after_read(&record(r#"{"permissions":[]}"#)),
Vec::new()
held(&record(r#"{"permissions":["web_tools"]}"#)),
vec![Permission::WebTools]
);
assert_eq!(held(&record(r#"{"permissions":[]}"#)), Vec::new());
assert_eq!(held(&record("{}")), Permission::DEFAULT.to_vec());
assert_ne!(
Permission::DEFAULT.to_vec(),
Vec::new(),
"control: the default is not the empty list"
);
let settled = copy(r#"{"permissions":["web_tools"]}"#);
assert_eq!(settled.after_read(&record("{}")), default);
}
/// Fields a newer controller adds must not cost an older hive the ones it