Watch
0
0
Fork
You've already forked hyperhive
0

logs and metrics permissions, tolerant record decode, hive capabilities

This commit is contained in:
atlas 2026-11-01 17:40:09 +01:00
commit 04598542b6
8 changed files with 226 additions and 8 deletions

1
Cargo.lock generated
View file

@ -2092,6 +2092,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tracing",
] ]
[[package]] [[package]]

View file

@ -396,15 +396,29 @@ pub(crate) fn queue_first_deploy(
coord.job_queue.insert_first_deploy_once(agent) 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<()> { async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> {
// An unconnected client does not fail a JetStream request, it hangs // An unconnected client does not fail a JetStream request, it hangs
// on it — which here would hang the loop, shutdown arm included. // on it — which here would hang the loop, shutdown arm included.
swarm_queue_client::ensure_connected(client)?; swarm_queue_client::ensure_connected(client)?;
let store = swarm_queue_client::status::open_or_create(client).await?; let store = swarm_queue_client::status::open_or_create(client).await?;
let payload = let payload = serde_json::to_vec(&snapshot()).context("serialising the snapshot")?;
serde_json::to_vec(&crate::warnings::readiness()).context("serialising the readiness")?;
store store
.put(hive, payload.into()) .put(hive, payload.into())
.await .await
@ -427,3 +441,20 @@ mod rev_marker_tests {
assert!(!marker_holds(&marker, "bbb"), "another rev still deploys"); 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());
}
}

View file

@ -9,6 +9,7 @@ workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
tracing.workspace = true
[dev-dependencies] [dev-dependencies]
serde_json.workspace = true serde_json.workspace = true

View file

@ -9,7 +9,7 @@
mod permission; mod permission;
pub use permission::Permission; pub use permission::{Permission, deserialize_known};
pub mod review; pub mod review;
pub mod term_msg; pub mod term_msg;

View file

@ -38,6 +38,15 @@ pub enum Permission {
/// thing to manage. Kept under that spelling because every stored grant /// thing to manage. Kept under that spelling because every stored grant
/// carries it. /// carries it.
ManageRootAgent, 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 { impl Permission {
@ -49,6 +58,8 @@ impl Permission {
Self::Execution, Self::Execution,
Self::WebTools, Self::WebTools,
Self::ManageRootAgent, Self::ManageRootAgent,
Self::Logs,
Self::Metrics,
]; ];
/// What an agent the swarm sets nothing for holds, whatever its name. /// 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 // — an out-of-process server has no later enforcement point, so
// that gate is the actual security boundary. `WebTools` grants // that gate is the actual security boundary. `WebTools` grants
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants // Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
// bind mounts. // bind mounts; `Logs` and `Metrics` are checked by the store
Self::Execution | Self::WebTools | Self::ManageRootAgent => &[], // 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::Execution => "execution",
Self::WebTools => "web_tools", Self::WebTools => "web_tools",
Self::ManageRootAgent => "manage_root_agent", 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> {
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<Option<Vec<Permission>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let names: Option<Vec<String>> = 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<Permission> for &'static str { impl From<Permission> for &'static str {
@ -142,11 +193,46 @@ mod tests {
"inbox", "inbox",
"execution", "execution",
"web_tools", "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<Vec<Permission>>,
}
#[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::<Record>(r#"{"permissions":[1]}"#).is_err());
}
/// The serde form of every variant, pinned independently of `as_str`: /// The serde form of every variant, pinned independently of `as_str`:
/// stored grants decode by it, so it must stay byte-identical. /// stored grants decode by it, so it must stay byte-identical.
#[test] #[test]
@ -158,6 +244,8 @@ mod tests {
(Permission::Execution, "\"execution\""), (Permission::Execution, "\"execution\""),
(Permission::WebTools, "\"web_tools\""), (Permission::WebTools, "\"web_tools\""),
(Permission::ManageRootAgent, "\"manage_root_agent\""), (Permission::ManageRootAgent, "\"manage_root_agent\""),
(Permission::Logs, "\"logs\""),
(Permission::Metrics, "\"metrics\""),
]; ];
assert_eq!(want.len(), Permission::ALL.len()); assert_eq!(want.len(), Permission::ALL.len());
for (perm, json) in want { for (perm, json) in want {

View file

@ -69,7 +69,13 @@ pub const IMPORT_REPLY_DEADLINE: std::time::Duration = std::time::Duration::from
/// older hive the fields it knows. /// older hive the fields it knows.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AgentPermissions { 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<Vec<hive_types::Permission>>, pub permissions: Option<Vec<hive_types::Permission>>,
/// Whether the agent's traffic leaves the host unfiltered. Absent is "on": /// 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 /// 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])); 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 /// 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. /// encodes as before, and an explicit `false` is kept on the wire.
#[test] #[test]

View file

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

View file

@ -19,6 +19,7 @@ pub mod builder;
pub mod claude_login; pub mod claude_login;
pub mod egress_allow; pub mod egress_allow;
pub mod forge_events; pub mod forge_events;
pub mod hive_status;
pub mod notices; pub mod notices;
pub mod notify_flows; pub mod notify_flows;
pub mod notify_router; pub mod notify_router;