From 04598542b627327bb57e747eba91c279175a16c6 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 1 Nov 2026 17:40:09 +0100 Subject: [PATCH] logs and metrics permissions, tolerant record decode, hive capabilities --- Cargo.lock | 1 + hive-c0re/src/swarm_status.rs | 37 +++++++++++- hive-types/Cargo.toml | 1 + hive-types/src/lib.rs | 2 +- hive-types/src/permission.rs | 94 ++++++++++++++++++++++++++++- swarm-wire/src/agent_permissions.rs | 25 +++++++- swarm-wire/src/hive_status.rs | 73 ++++++++++++++++++++++ swarm-wire/src/lib.rs | 1 + 8 files changed, 226 insertions(+), 8 deletions(-) create mode 100644 swarm-wire/src/hive_status.rs diff --git a/Cargo.lock b/Cargo.lock index 8565cce9..d24d8d0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2092,6 +2092,7 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "tracing", ] [[package]] diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 33d191c7..48fb9aa0 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -396,15 +396,29 @@ pub(crate) fn queue_first_deploy( coord.job_queue.insert_first_deploy_once(agent) } -/// Offer one snapshot: this hive's current readiness, under its own key. +/// The snapshot a hive offers: its readiness, and what it can do (`swarm_wire::hive_status`). +#[derive(serde::Serialize)] +struct Snapshot { + #[serde(flatten)] + readiness: crate::warnings::Readiness, + capabilities: &'static [&'static str], +} + +fn snapshot() -> Snapshot { + Snapshot { + readiness: crate::warnings::readiness(), + capabilities: &[swarm_wire::hive_status::TOLERANT_PERMISSIONS], + } +} + +/// Offer one snapshot under this hive's own key. async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> { // An unconnected client does not fail a JetStream request, it hangs // on it — which here would hang the loop, shutdown arm included. swarm_queue_client::ensure_connected(client)?; let store = swarm_queue_client::status::open_or_create(client).await?; - let payload = - serde_json::to_vec(&crate::warnings::readiness()).context("serialising the readiness")?; + let payload = serde_json::to_vec(&snapshot()).context("serialising the snapshot")?; store .put(hive, payload.into()) .await @@ -427,3 +441,20 @@ mod rev_marker_tests { assert!(!marker_holds(&marker, "bbb"), "another rev still deploys"); } } + +#[cfg(test)] +mod snapshot_tests { + use super::snapshot; + + /// The controller reads the capability out of the published bytes with + /// the wire type, and the readiness keeps its own top-level fields. + #[test] + fn the_snapshot_advertises_tolerant_permissions_beside_the_readiness() { + let value = serde_json::to_value(snapshot()).unwrap(); + assert!( + swarm_wire::hive_status::Capabilities::of_snapshot(&value) + .has(swarm_wire::hive_status::TOLERANT_PERMISSIONS) + ); + assert!(value.get("status").is_some() && value.get("warnings").is_some()); + } +} diff --git a/hive-types/Cargo.toml b/hive-types/Cargo.toml index cea91cd5..f796ffd0 100644 --- a/hive-types/Cargo.toml +++ b/hive-types/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] serde.workspace = true +tracing.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/hive-types/src/lib.rs b/hive-types/src/lib.rs index 84d834d4..bc7d84dc 100644 --- a/hive-types/src/lib.rs +++ b/hive-types/src/lib.rs @@ -9,7 +9,7 @@ mod permission; -pub use permission::Permission; +pub use permission::{Permission, deserialize_known}; pub mod review; pub mod term_msg; diff --git a/hive-types/src/permission.rs b/hive-types/src/permission.rs index 83395480..eeb796a2 100644 --- a/hive-types/src/permission.rs +++ b/hive-types/src/permission.rs @@ -38,6 +38,15 @@ pub enum Permission { /// thing to manage. Kept under that spelling because every stored grant /// carries it. ManageRootAgent, + /// Read access to the log store: what `swarm-logs` queries. Checked by + /// the log gateway's `swarm-logs-auth` against the grant swarm-controller + /// mirrors into the secret store, so `tools()` is empty. Not in + /// [`Permission::DEFAULT`]. + Logs, + /// Read access to the metrics store: what `swarm-metrics` queries. + /// Checked like [`Permission::Logs`], for the metrics gateway. Not in + /// [`Permission::DEFAULT`]. + Metrics, } impl Permission { @@ -49,6 +58,8 @@ impl Permission { Self::Execution, Self::WebTools, Self::ManageRootAgent, + Self::Logs, + Self::Metrics, ]; /// What an agent the swarm sets nothing for holds, whatever its name. @@ -69,8 +80,13 @@ impl Permission { // — an out-of-process server has no later enforcement point, so // that gate is the actual security boundary. `WebTools` grants // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants - // bind mounts. - Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], + // bind mounts; `Logs` and `Metrics` are checked by the store + // gateways, not by a tool allowlist. + Self::Execution + | Self::WebTools + | Self::ManageRootAgent + | Self::Logs + | Self::Metrics => &[], } } @@ -115,8 +131,43 @@ impl Permission { Self::Execution => "execution", Self::WebTools => "web_tools", Self::ManageRootAgent => "manage_root_agent", + Self::Logs => "logs", + Self::Metrics => "metrics", } } + + /// The permission stored under `name`; `None` for a name this build does + /// not know. + #[must_use] + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|p| p.as_str() == name) + } +} + +/// Decodes a stored permission list, skipping names this build does not know. +/// For `#[serde(deserialize_with)]` on a stored record: a permission a newer +/// swarm adds must not cost an older reader the rest of the record. +/// +/// # Errors +/// +/// When the value is not a list of strings. +pub fn deserialize_known<'de, D>(d: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let names: Option> = Option::deserialize(d)?; + Ok(names.map(|names| { + names + .iter() + .filter_map(|name| { + let known = Permission::from_name(name); + if known.is_none() { + tracing::debug!(name, "skipping a permission this build does not know"); + } + known + }) + .collect() + })) } impl From for &'static str { @@ -142,11 +193,46 @@ mod tests { "inbox", "execution", "web_tools", - "manage_root_agent" + "manage_root_agent", + "logs", + "metrics" ] ); } + /// Reading stores a log or metric grant is never a default: an agent the + /// swarm sets nothing for does not hold them. + #[test] + fn logs_and_metrics_are_off_by_default() { + assert!(!Permission::DEFAULT.contains(&Permission::Logs)); + assert!(!Permission::DEFAULT.contains(&Permission::Metrics)); + assert!(Permission::ALL.contains(&Permission::Logs)); + assert!(Permission::ALL.contains(&Permission::Metrics)); + } + + #[derive(serde::Deserialize)] + struct Record { + #[serde(default, deserialize_with = "deserialize_known")] + permissions: Option>, + } + + #[test] + fn an_unknown_name_is_skipped_not_a_decode_failure() { + let r: Record = + serde_json::from_str(r#"{"permissions":["messaging","telepathy","logs"]}"#).unwrap(); + assert_eq!( + r.permissions, + Some(vec![Permission::Messaging, Permission::Logs]) + ); + // Presence control: an unset field stays unset, `[]` stays empty. + let r: Record = serde_json::from_str("{}").unwrap(); + assert_eq!(r.permissions, None); + let r: Record = serde_json::from_str(r#"{"permissions":[]}"#).unwrap(); + assert_eq!(r.permissions, Some(vec![])); + // A list of non-strings is still a decode failure. + assert!(serde_json::from_str::(r#"{"permissions":[1]}"#).is_err()); + } + /// The serde form of every variant, pinned independently of `as_str`: /// stored grants decode by it, so it must stay byte-identical. #[test] @@ -158,6 +244,8 @@ mod tests { (Permission::Execution, "\"execution\""), (Permission::WebTools, "\"web_tools\""), (Permission::ManageRootAgent, "\"manage_root_agent\""), + (Permission::Logs, "\"logs\""), + (Permission::Metrics, "\"metrics\""), ]; assert_eq!(want.len(), Permission::ALL.len()); for (perm, json) in want { diff --git a/swarm-wire/src/agent_permissions.rs b/swarm-wire/src/agent_permissions.rs index 8a8eb8c7..def893d5 100644 --- a/swarm-wire/src/agent_permissions.rs +++ b/swarm-wire/src/agent_permissions.rs @@ -69,7 +69,13 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from /// older hive the fields it knows. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AgentPermissions { - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Names this build does not know are skipped on decode, so a permission + /// a newer controller adds does not cost this reader the record. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "hive_types::deserialize_known" + )] pub permissions: Option>, /// Whether the agent's traffic leaves the host unfiltered. Absent is "on": /// an agent whose key predates the field keeps the open access it had, and @@ -155,6 +161,23 @@ mod tests { assert_eq!(ruth.permissions, Some(vec![Permission::ManageRootAgent])); } + /// A record naming a permission this build does not know still decodes, + /// with that name skipped and the other fields intact: the reader holds + /// what it knows instead of nothing. + #[test] + fn a_record_with_an_unknown_permission_still_decodes() { + use hive_types::Permission; + let record: AgentPermissions = serde_json::from_str( + r#"{"permissions":["messaging","from-the-future","logs"],"unrestricted_internet":false}"#, + ) + .unwrap(); + assert_eq!( + record.permissions, + Some(vec![Permission::Messaging, Permission::Logs]) + ); + assert_eq!(record.unrestricted_internet, Some(false)); + } + /// The flag is left out when unset, so a record that never named it /// encodes as before, and an explicit `false` is kept on the wire. #[test] diff --git a/swarm-wire/src/hive_status.rs b/swarm-wire/src/hive_status.rs new file mode 100644 index 00000000..82f8b65a --- /dev/null +++ b/swarm-wire/src/hive_status.rs @@ -0,0 +1,73 @@ +//! What a hive's status snapshot (`swarm_queue_client::status`) says about +//! what the hive can do, next to its readiness. +//! +//! The snapshot is opaque to the controller except for this field. A snapshot +//! from a hive that predates it has none, which reads as an empty list. + +/// The hive decodes a permission list that names a permission it does not +/// know by skipping that name, and runs agents on a harness that does too. +/// swarm-controller writes `logs` or `metrics` into an agent's record only +/// when the agent's hive advertises this. +pub const TOLERANT_PERMISSIONS: &str = "tolerant-permissions"; + +/// The part of a hive's status snapshot this module describes. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Capabilities { + /// Capability names; unknown names are ignored by every reader. + #[serde(default)] + pub capabilities: Vec, +} + +impl Capabilities { + /// Whether the hive advertises `name`. + #[must_use] + pub fn has(&self, name: &str) -> bool { + self.capabilities.iter().any(|c| c == name) + } + + /// The capabilities in a stored snapshot; none when it has no such field + /// or is not an object. + #[must_use] + pub fn of_snapshot(snapshot: &serde_json::Value) -> Self { + serde_json::from_value(snapshot.clone()).unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A snapshot from before the field existed: it parses, with nothing + /// advertised. + #[test] + fn a_snapshot_without_the_field_advertises_nothing() { + let old = serde_json::json!({"status": "ok", "warnings": []}); + let caps = Capabilities::of_snapshot(&old); + assert!(!caps.has(TOLERANT_PERMISSIONS)); + assert_eq!(caps, Capabilities::default()); + } + + #[test] + fn an_advertised_capability_is_found_among_unknown_ones() { + let new = serde_json::json!({ + "status": "ok", + "warnings": [], + "capabilities": ["from-the-future", TOLERANT_PERMISSIONS], + }); + assert!(Capabilities::of_snapshot(&new).has(TOLERANT_PERMISSIONS)); + assert!(!Capabilities::of_snapshot(&new).has("other")); + } + + /// A snapshot that is not an object, or whose field is mistyped, grants + /// nothing rather than failing the read. + #[test] + fn a_malformed_snapshot_advertises_nothing() { + for bad in [ + serde_json::json!(null), + serde_json::json!("ok"), + serde_json::json!({"capabilities": "tolerant-permissions"}), + ] { + assert!(!Capabilities::of_snapshot(&bad).has(TOLERANT_PERMISSIONS)); + } + } +} diff --git a/swarm-wire/src/lib.rs b/swarm-wire/src/lib.rs index 995fdf14..1ade532e 100644 --- a/swarm-wire/src/lib.rs +++ b/swarm-wire/src/lib.rs @@ -19,6 +19,7 @@ pub mod builder; pub mod claude_login; pub mod egress_allow; pub mod forge_events; +pub mod hive_status; pub mod notices; pub mod notify_flows; pub mod notify_router;