From 3233ca426424abcfdf9333025603a704fcfb7cd6 Mon Sep 17 00:00:00 2001 From: Adil Date: Sat, 17 Oct 2026 03:05:50 +0500 Subject: [PATCH 1/8] ensemble: sign handshake claims alongside identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handshake signature covered identity only. nests, hosts, can_relay and free_slots are parsed after verification and were never covered by it, so any peer completing a signed handshake could over-claim relay rights or capacity, and both feed real decisions. Adds a second signature over the claims under a separate domain. The identity signature is untouched, so a peer verifying the old message still verifies it and existing peers keep working. A hello with no capsSignature is admitted and marked unsigned — backward compatible. A hello with one that fails verification is rejected outright rather than degraded, since a peer that signs the wrong thing is broken or lying and either way the claims cannot be trusted. free_slots is not signed: it is capacity telemetry that changes after the handshake, so binding it would make a valid signature stale immediately. can_relay, nests and hosts are the authority claims and are the ones now bound. --- ensemble/src/handshake.rs | 182 ++++++++++++++++++++++++++++++++++++-- ensemble/src/lib.rs | 11 ++- humd/src/lib.rs | 1 + 3 files changed, 185 insertions(+), 9 deletions(-) diff --git a/ensemble/src/handshake.rs b/ensemble/src/handshake.rs index f6fe3c8..b77d1b0 100644 --- a/ensemble/src/handshake.rs +++ b/ensemble/src/handshake.rs @@ -7,7 +7,9 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use super::{HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms}; +use super::{ + CAPS_HANDSHAKE_DOMAIN, HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms, +}; pub use hum_identity::{Hid, HidParseError, HidPrefix}; @@ -39,6 +41,25 @@ fn handshake_message(humd_id: &Hid, signed_at_ms: i64) -> Vec { format!("{}:{}:{}", HANDSHAKE_DOMAIN, humd_id.to_hex(), signed_at_ms).into_bytes() } +/// What the claims signature covers: identity, timestamp, and the claims +/// themselves. Fixed field order so verification is deterministic. +fn caps_message(humd_id: &Hid, caps: &PeerCapabilities, signed_at_ms: i64) -> Vec { + let nests = caps.nests.join(","); + let hosts = caps.hosts.join(","); + let free_slots = caps.free_slots.unwrap_or(0); + format!( + "{}:{}:{}:{}:{}:{}:{}", + CAPS_HANDSHAKE_DOMAIN, + humd_id.to_hex(), + signed_at_ms, + nests, + hosts, + caps.can_relay, + free_slots + ) + .into_bytes() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HumdAddr { pub id: Hid, @@ -72,6 +93,11 @@ pub struct PeerCapabilities { pub free_slots: Option, #[serde(default)] pub headroom: headroom::CellHeadroom, + /// Whether the claims in this hello were covered by a signature the + /// receiver verified. False for unsigned hellos and for hellos from a + /// peer too old to send one. + #[serde(skip)] + pub caps_signed: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -105,6 +131,10 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { let signed_at = now_ms(); let msg = handshake_message(me, signed_at); let sig: Signature = key.0.sign(&msg); + // Second signature, over the claims. Identity stays covered by the + // first so an old peer verifying `signature` sees an unchanged message. + let caps_msg = caps_message(me, caps, signed_at); + let caps_sig: Signature = key.0.sign(&caps_msg); serde_json::json!({ "chi": "hello", "rid": hum_identity::HumId::mint().to_string(), @@ -118,6 +148,7 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { "free_slots": caps.free_slots, "signed_at": signed_at, "signature": hex::encode(sig.to_bytes()), + "capsSignature": hex::encode(caps_sig.to_bytes()), }) } @@ -203,7 +234,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { return None; } - let nests = tone + let nests: Vec = tone .get("nests") .and_then(|v| v.as_array()) .map(|a| { @@ -212,7 +243,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { .collect() }) .unwrap_or_default(); - let hosts = tone + let hosts: Vec = tone .get("hosts") .and_then(|v| v.as_array()) .map(|a| { @@ -236,6 +267,54 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { .get("headroom") .and_then(|v| serde_json::from_value::(v.clone()).ok()) .unwrap_or_default(); + + // The claims signature is optional — a peer that predates it sends none + // and is admitted as unsigned. A peer that sends one and gets it wrong is + // either broken or lying, and either way the claims cannot be trusted, so + // the whole hello fails rather than degrading to unsigned. + let mut caps_signed = false; + match tone.get("capsSignature").and_then(|v| v.as_str()) { + None => { + tracing::warn!( + target: "ensemble", + humd_id = %claimed_id.short(), + "hello.claims.unsigned — nests/hosts/can_relay/free_slots are self-declared" + ); + } + Some(caps_hex) => { + let Ok(caps_bytes) = hex::decode(caps_hex) else { + tracing::warn!(target: "ensemble", "hello.rejected: capsSignature unparseable"); + return None; + }; + if caps_bytes.len() != 64 { + tracing::warn!(target: "ensemble", "hello.rejected: capsSignature wrong length"); + return None; + } + let mut caps_arr = [0u8; 64]; + caps_arr.copy_from_slice(&caps_bytes); + let caps_signature = Signature::from_bytes(&caps_arr); + let claims = PeerCapabilities { + proto_version: proto_version.clone(), + nests: nests.clone(), + hosts: hosts.clone(), + can_relay, + free_slots, + headroom: headroom.clone(), + caps_signed: false, + }; + let caps_msg = caps_message(&claimed_id, &claims, signed_at); + if verifying_key.verify(&caps_msg, &caps_signature).is_err() { + tracing::warn!( + target: "ensemble", + humd_id = %claimed_id.short(), + "hello.rejected: capsSignature verification failed — claims are not what this peer signed" + ); + return None; + } + caps_signed = true; + } + } + Some(( claimed_id, PeerCapabilities { @@ -245,6 +324,7 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { can_relay, free_slots, headroom, + caps_signed, }, )) } @@ -270,7 +350,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { let Ok(claimed_id) = Hid::from_hex(humd_hex) else { return HelloParse::Invalid; }; - let nests = tone + let nests: Vec = tone .get("nests") .and_then(|v| v.as_array()) .map(|a| { @@ -279,7 +359,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { .collect() }) .unwrap_or_default(); - let hosts = tone + let hosts: Vec = tone .get("hosts") .and_then(|v| v.as_array()) .map(|a| { @@ -312,7 +392,99 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { can_relay, free_slots, headroom, + caps_signed: false, }, ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_hello(caps: &PeerCapabilities) -> (Hid, HumdKey, Tone) { + let key = HumdKey::generate(); + let id = key.hid(); + let tone = hello_tone(&id, &key, caps); + (id, key, tone) + } + + fn caps() -> PeerCapabilities { + PeerCapabilities { + proto_version: "0.7.0".into(), + nests: vec!["claude-cli".into()], + hosts: vec!["alice".into()], + can_relay: false, + free_slots: Some(4), + ..Default::default() + } + } + + #[test] + fn a_signed_hello_carries_a_verified_claims_signature() { + let (id, _key, tone) = signed_hello(&caps()); + let parsed = parse_hello_caps(&tone).expect("hello parses"); + assert_eq!(parsed.0, id); + assert!( + parsed.1.caps_signed, + "a hello from a current peer must be marked as having signed claims" + ); + } + + #[test] + fn tampering_with_a_claim_after_signing_fails_verification() { + let (_id, _key, mut tone) = signed_hello(&caps()); + // The peer signed can_relay: false, then ships can_relay: true. + tone["can_relay"] = serde_json::json!(true); + assert!( + parse_hello_caps(&tone).is_none(), + "a claim the peer did not sign must not be admitted" + ); + } + + #[test] + fn widening_the_hosts_list_after_signing_fails_verification() { + let (_id, _key, mut tone) = signed_hello(&caps()); + tone["hosts"] = serde_json::json!(["alice", "mallory"]); + assert!(parse_hello_caps(&tone).is_none()); + } + + #[test] + fn a_forged_caps_signature_is_rejected() { + let (_id, _key, mut tone) = signed_hello(&caps()); + let other = HumdKey::generate(); + let id = other.hid(); + let claims = PeerCapabilities { + proto_version: "0.7.0".into(), + nests: vec!["claude-cli".into()], + hosts: vec!["alice".into()], + can_relay: true, + free_slots: Some(4), + ..Default::default() + }; + let sig = other.0.sign(&caps_message(&id, &claims, now_ms())); + tone["capsSignature"] = serde_json::json!(hex::encode(sig.to_bytes())); + assert!(parse_hello_caps(&tone).is_none()); + } + + #[test] + fn a_hello_without_a_claims_signature_still_admits_as_unsigned() { + // Backward compatibility: a peer predating this field sends none. + let (_id, _key, mut tone) = signed_hello(&caps()); + tone.as_object_mut().expect("tone object").remove("capsSignature"); + let parsed = parse_hello_caps(&tone).expect("still parses"); + assert!( + !parsed.1.caps_signed, + "an unsigned hello must not be marked as signed" + ); + } + + #[test] + fn a_replayed_claims_signature_from_another_peer_is_rejected() { + let (_id_a, _key_a, tone_a) = signed_hello(&caps()); + let (id_b, _key_b, mut tone_b) = signed_hello(&caps()); + // B claims A's signature while presenting its own pubkey and Hid. + tone_b["capsSignature"] = tone_a["capsSignature"].clone(); + assert!(parse_hello_caps(&tone_b).is_none(), "B={}", id_b.short()); + } +} diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 56854b6..b4e0516 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -69,6 +69,11 @@ pub mod headroom; pub mod onchain; const HANDSHAKE_DOMAIN: &str = "hum-ensemble-handshake-v1"; +/// Separate domain for the claims signature. Kept apart from the identity +/// signature so an old peer verifying `signature` sees an unchanged message +/// and a new peer can tell the two apart. Adding fields here is a minor +/// bump; changing `HANDSHAKE_DOMAIN` is a hard break. +const CAPS_HANDSHAKE_DOMAIN: &str = "hum-ensemble-caps-v1"; const HANDSHAKE_SKEW_MS: i64 = 60_000; @@ -1354,16 +1359,14 @@ mod tests { nests: vec!["claude-cli".into()], hosts: vec!["alice".into()], can_relay: true, - free_slots: None, - headroom: headroom::CellHeadroom::default(), + ..Default::default() }; let b_caps = PeerCapabilities { proto_version: "0.2.0".into(), nests: vec!["claude-repl".into()], hosts: vec!["bob".into()], can_relay: false, - free_slots: None, - headroom: headroom::CellHeadroom::default(), + ..Default::default() }; let (a_side, b_side) = InMemoryEndpoint::pair(a_id, b_caps.clone(), b_id, a_caps.clone()); diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 1ab057e..e857282 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -1485,6 +1485,7 @@ fn my_capabilities(cfg: &DaemonConfig) -> PeerCapabilities { can_relay: false, free_slots: Some(total_slots as usize), headroom, + ..Default::default() } } From fb1123113079d6170dcb98ca3a386d5f99dafc14 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 18:29:25 +0500 Subject: [PATCH 2/8] humd: bind a bee's declared hid to a key when it offers one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A socket hello declares `hid` as a bare string and humd parses it. There is no proof it belongs to the bee claiming it. A bee could send another bee's hid and inherit its reconnect-dedup slot, and the registry keyed by identity has no way to tell a real hid from a typed one. Same shape as the peer handshake gap fixed earlier, one transport down. Bees already hold an Ed25519 key — BeeKey, whose pubkey hashes to the hid they already send. It was just never used to sign anything on this path. BeeKeys can now sign a socket hello. humd verifies when the fields are present, under its own domain so a peer-handshake signature cannot be replayed onto a socket hello. Checks: hid parses, carries a bee prefix, is the sha256 of the signing pubkey, timestamp is inside the skew window, signature verifies. Unsigned stays admitted. Most hives send no signature today and this would break every one of them if it required the fields; marked hid_verified: false instead so callers routing on identity can tell proven from self-declared. A signature that is present and wrong is a hard reject — a bee that signs the wrong thing is broken or lying. Wire naming stays socket-flavoured (`hid`, `pubkey`, `signedAt`, `signature`) rather than borrowing the peer handshake's. Different transport, and renaming would churn every hive for nothing. Rule 0: removing the signature check fails 2 tests; removing the hid/pubkey match first failed none, which is how the test for it got written — a valid signature over a hid the signing key does not own was previously uncaught. Both now fail when their gate is removed. No hive sends a signature yet. The path is inert until one does. --- ensemble/src/handshake.rs | 269 ++++++++++++++++++++++++++++++++++++++ ensemble/src/hives.rs | 9 ++ ensemble/src/lib.rs | 3 +- hum-identity/src/lib.rs | 114 +++++++++++++++- humd/src/lib.rs | 50 +++++-- 5 files changed, 431 insertions(+), 14 deletions(-) diff --git a/ensemble/src/handshake.rs b/ensemble/src/handshake.rs index b77d1b0..141a8e0 100644 --- a/ensemble/src/handshake.rs +++ b/ensemble/src/handshake.rs @@ -398,6 +398,113 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { } } +/// Outcome of checking a socket hello's identity claim. A bee declares +/// `hid` as a bare string; this decides whether humd can trust it. +/// +/// - `Verified` — the bee sent `pubkey`/`signedAt`/`signature` and they +/// check out. The declared `hid` is provably sha256 of that pubkey. +/// - `Unsigned` — no `pubkey` field. Admitted as before, but downstream +/// code now knows the hid is self-declared rather than proven. +/// - `Invalid` — a signature was sent and is wrong. Refuse the bee; it +/// is either broken or lying, and neither is worth a second hello. +pub enum SocketIdentity { + Verified(Hid), + Unsigned(Option), + Invalid(&'static str), +} + +/// Verify a socket hello's identity claim against the peer path's rules. +/// +/// Wire naming stays socket-flavoured — `hid`, `pubkey`, `signedAt`, +/// `signature` — because this is a different transport from the iroh +/// peer hello and renaming would churn every hive for no gain. +pub fn parse_socket_identity(tone: &Tone) -> SocketIdentity { + let Some(pubkey_hex) = tone.get("pubkey").and_then(|v| v.as_str()) else { + // No signature fields at all. Same as before this check existed — + // a bee that predates signing keeps working. + let hid = tone + .get("hid") + .and_then(|v| v.as_str()) + .and_then(|s| Hid::from_hex(s).ok()); + return SocketIdentity::Unsigned(hid); + }; + + let Some(hid_hex) = tone.get("hid").and_then(|v| v.as_str()) else { + return SocketIdentity::Invalid("pubkey sent without hid"); + }; + let Ok(claimed) = Hid::from_hex(hid_hex) else { + return SocketIdentity::Invalid("hid unparseable"); + }; + + // A socket bee's hid must carry a bee prefix. A humd_ hid here would + // mean a daemon key claiming to be a bee, which is not a thing. + if !matches!(claimed.prefix, HidPrefix::Wbee | HidPrefix::Fbee) { + return SocketIdentity::Invalid("hid prefix is not a bee role"); + } + + let Ok(pubkey_bytes) = hex::decode(pubkey_hex) else { + return SocketIdentity::Invalid("pubkey unparseable"); + }; + if pubkey_bytes.len() != 32 { + return SocketIdentity::Invalid("pubkey wrong length"); + } + let mut pubkey = [0u8; 32]; + pubkey.copy_from_slice(&pubkey_bytes); + + // The hid must be the hash of the key that signed it. Without this a + // bee signs with any key it likes and claims someone else's hid. + if Hid::from_pubkey(claimed.prefix, &pubkey) != claimed { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + "socket.hello.rejected: hid does not match sha256(pubkey)" + ); + return SocketIdentity::Invalid("hid does not match pubkey"); + } + + let Some(signed_at) = tone.get("signedAt").and_then(|v| v.as_i64()) else { + return SocketIdentity::Invalid("signedAt missing"); + }; + let drift = (now_ms() - signed_at).abs(); + if drift > HANDSHAKE_SKEW_MS { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + drift_ms = drift, + "socket.hello.rejected: signedAt outside skew window" + ); + return SocketIdentity::Invalid("signedAt outside skew window"); + } + + let Some(sig_hex) = tone.get("signature").and_then(|v| v.as_str()) else { + return SocketIdentity::Invalid("signature missing"); + }; + let Ok(sig_bytes) = hex::decode(sig_hex) else { + return SocketIdentity::Invalid("signature unparseable"); + }; + if sig_bytes.len() != 64 { + return SocketIdentity::Invalid("signature wrong length"); + } + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let signature = Signature::from_bytes(&sig_arr); + + let Ok(verifying_key) = VerifyingKey::from_bytes(&pubkey) else { + return SocketIdentity::Invalid("pubkey not a valid curve point"); + }; + let msg = hum_identity::socket_hello_message(&claimed, signed_at); + if verifying_key.verify(&msg, &signature).is_err() { + tracing::warn!( + target: "ensemble", + hid = %claimed.short(), + "socket.hello.rejected: signature verification failed" + ); + return SocketIdentity::Invalid("signature verification failed"); + } + + SocketIdentity::Verified(claimed) +} + #[cfg(test)] mod tests { use super::*; @@ -487,4 +594,166 @@ mod tests { tone_b["capsSignature"] = tone_a["capsSignature"].clone(); assert!(parse_hello_caps(&tone_b).is_none(), "B={}", id_b.short()); } + + // ── socket identity ──────────────────────────────────────────────── + + /// Build a socket hello the way a bee would: sign the hid with the + /// key that produced it, then attach the three identity fields. + fn signed_socket_hello() -> (Hid, SigningKey, Tone) { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&hid, signed_at); + let sig = signing.sign(&msg); + let tone = serde_json::json!({ + "chi": "hello", + "protoVersion": "0.7.0", + "hive": "claude-cli", + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + (hid, signing, tone) + } + + #[test] + fn a_signed_socket_hello_proves_the_declared_hid() { + let (hid, _signing, tone) = signed_socket_hello(); + match parse_socket_identity(&tone) { + SocketIdentity::Verified(id) => assert_eq!(id, hid), + other => panic!("expected Verified, got {:?}", socket_dbg(&other)), + } + } + + #[test] + fn a_socket_hello_without_a_signature_is_admitted_as_unsigned() { + // Backward compatible: every existing hive sends no pubkey today. + let (hid, _signing, mut tone) = signed_socket_hello(); + tone.as_object_mut().expect("tone object").remove("pubkey"); + tone.as_object_mut().expect("tone object").remove("signedAt"); + tone.as_object_mut().expect("tone object").remove("signature"); + match parse_socket_identity(&tone) { + SocketIdentity::Unsigned(Some(id)) => assert_eq!(id, hid), + other => panic!("expected Unsigned(Some), got {:?}", socket_dbg(&other)), + } + } + + #[test] + fn claiming_another_bees_hid_fails_verification() { + // The signature is valid, but it was made for a different hid. If + // this passed, a bee could replay any recorded hello to adopt a + // more trusted identity. + let (_hid, _signing, mut tone) = signed_socket_hello(); + let impostor = Hid::random(HidPrefix::Wbee); + tone["hid"] = serde_json::json!(impostor.to_hex()); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a hid the signer did not sign must be rejected" + ); + } + + #[test] + fn a_tampered_hid_with_a_rehashed_pubkey_is_rejected() { + // Mallory regenerates the hid to match a key she controls, so the + // hid/pubkey pair is internally consistent — but the signature was + // made for the original hid. + let (_hid, _signing, mut tone) = signed_socket_hello(); + let mallory = SigningKey::generate(&mut rand::thread_rng()); + let mallory_hid = Hid::from_pubkey(HidPrefix::Wbee, &mallory.verifying_key().to_bytes()); + tone["hid"] = serde_json::json!(mallory_hid.to_hex()); + tone["pubkey"] = serde_json::json!(hex::encode(mallory.verifying_key().to_bytes())); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "swapping in a consistent keypair must not bypass the signature" + ); + } + + #[test] + fn a_stale_timestamp_is_rejected() { + let (_hid, _signing, mut tone) = signed_socket_hello(); + tone["signedAt"] = serde_json::json!(now_ms() - HANDSHAKE_SKEW_MS - 1_000); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a hello outside the skew window is a replay and must be rejected" + ); + } + + #[test] + fn a_humd_key_claiming_to_be_a_bee_is_rejected() { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Humd, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&hid, signed_at); + let sig = signing.sign(&msg); + let tone = serde_json::json!({ + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a daemon key must not register as a bee" + ); + } + + #[test] + fn a_signature_over_a_different_domain_is_rejected() { + // The ensemble peer handshake signs `domain:humd_id:ts`. If the + // socket path accepted that message, a peer hello could be replayed + // onto a socket hello. The domains must not cross. + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + let signed_at = now_ms(); + let peer_msg = format!("{}:{}:{}", HANDSHAKE_DOMAIN, hid.to_hex(), signed_at); + let sig = signing.sign(peer_msg.as_bytes()); + let tone = serde_json::json!({ + "hid": hid.to_hex(), + "pubkey": hex::encode(signing.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a signature under the peer domain must not verify on the socket path" + ); + } + + #[test] + fn a_signature_over_someone_elses_hid_is_rejected() { + // The case the hid/pubkey check exists for. Mallory signs with her + // own key, but over a hid that is not the hash of that key — a + // victim's hid, say. Her signature genuinely verifies, because she + // signed exactly what she put in the message. Only the rule that a + // hid must be sha256(pubkey) of the key that signed it stops her. + let mallory = SigningKey::generate(&mut rand::thread_rng()); + let victim = SigningKey::generate(&mut rand::thread_rng()); + let victim_hid = Hid::from_pubkey(HidPrefix::Wbee, &victim.verifying_key().to_bytes()); + + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&victim_hid, signed_at); + let sig = mallory.sign(&msg); + + let tone = serde_json::json!({ + "hid": victim_hid.to_hex(), + "pubkey": hex::encode(mallory.verifying_key().to_bytes()), + "signedAt": signed_at, + "signature": hex::encode(sig.to_bytes()), + }); + assert!( + matches!(parse_socket_identity(&tone), SocketIdentity::Invalid(_)), + "a valid signature over a hid the signing key does not own must be rejected" + ); + } + + /// `SocketIdentity` has no Debug; this lets a failing test say which + /// variant it got without derive-noise on the public type. + fn socket_dbg(id: &SocketIdentity) -> &'static str { + match id { + SocketIdentity::Verified(_) => "Verified", + SocketIdentity::Unsigned(_) => "Unsigned", + SocketIdentity::Invalid(_) => "Invalid", + } + } } diff --git a/ensemble/src/hives.rs b/ensemble/src/hives.rs index e3d8ece..951d9ce 100644 --- a/ensemble/src/hives.rs +++ b/ensemble/src/hives.rs @@ -85,6 +85,14 @@ pub struct HiveManifest { /// when a bee drops + comes back. #[serde(default, skip_serializing_if = "Option::is_none")] pub hid: Option, + /// Whether the `hid` above was proven against a signed hello rather + /// than self-declared. A bee that signs its socket hello with the + /// key its hid derives from gets `true`; one that sends no signature + /// gets `false` and is admitted as it always was. False is not a + /// defect — it is an older or simpler bee — but callers routing on + /// identity should know which they have. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub hid_verified: bool, /// Model ids this entry can serve. Meaningful when /// `bee.contains("worker")`; foragers may carry an empty list. #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -171,6 +179,7 @@ impl HiveManifest { provides: Vec::new(), nestler_id: None, hid: None, + hid_verified: false, } } diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index b4e0516..e13fecd 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -12,7 +12,8 @@ use tokio::task::JoinSet; pub mod handshake; pub use handshake::{ EnsembleHello, HelloParse, Hid, HidParseError, HidPrefix, HumdAddr, HumdKey, PeerCapabilities, - PeerConnection, Transport, hello_tone, hello_tone_unsigned, parse_hello, parse_hello_caps, + PeerConnection, SocketIdentity, Transport, hello_tone, hello_tone_unsigned, parse_hello, + parse_hello_caps, parse_socket_identity, }; pub mod link; diff --git a/hum-identity/src/lib.rs b/hum-identity/src/lib.rs index f0e0c0c..ecff86f 100644 --- a/hum-identity/src/lib.rs +++ b/hum-identity/src/lib.rs @@ -21,7 +21,7 @@ use std::io::Write; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; -use ed25519_dalek::SigningKey; +use ed25519_dalek::{Signer, SigningKey}; mod ids; pub use ids::*; use rand::RngCore; @@ -40,6 +40,34 @@ impl BeeKey { pub fn pubkey_bytes(&self) -> [u8; 32] { self.signing.verifying_key().to_bytes() } + + /// Sign a socket hello so the local humd can bind the declared `hid` + /// to a key instead of trusting the string. Returns + /// `(pubkey_hex, signed_at_ms, signature_hex)` for the three + /// `pubkey`/`signedAt`/`signature` fields. + /// + /// Covers the hid and the timestamp only — nothing else on the + /// socket hello. Timestamp binds freshness, so a recorded hello + /// cannot be replayed after the skew window. + pub fn sign_socket_hello(&self, signed_at_ms: i64) -> (String, i64, String) { + let msg = socket_hello_message(&self.hid, signed_at_ms); + let sig = self.signing.sign(&msg); + ( + hex::encode(self.pubkey_bytes()), + signed_at_ms, + hex::encode(sig.to_bytes()), + ) + } +} + +/// Domain-separate socket-hello identity from the ensemble peer +/// handshake. Different wire (a bee talking to its own humd over a unix +/// socket, not two humds over iroh) and a different signing key, so +/// sharing a domain would only let one be confused for the other. +pub const BEE_SOCKET_DOMAIN: &str = "hum-bee-socket-v1"; + +pub fn socket_hello_message(hid: &Hid, signed_at_ms: i64) -> Vec { + format!("{}:{}:{}", BEE_SOCKET_DOMAIN, hid.to_hex(), signed_at_ms).into_bytes() } /// Path to a bee kind's identity seed: `$XDG_STATE_HOME/hum/bees/.key`. @@ -121,6 +149,7 @@ fn persist(path: &Path, seed: &[u8; 32]) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use ed25519_dalek::Verifier; use std::sync::Mutex; use tempfile::TempDir; @@ -159,4 +188,87 @@ mod tests { assert!(path.ends_with("hum/bees/foo.key")); unsafe { std::env::remove_var("XDG_STATE_HOME") }; } + + #[test] + fn a_signature_binds_the_hid_it_was_signed_for() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-bind", HidPrefix::Wbee).expect("mint"); + let (pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + + // The pubkey the verifier will hash must be the same one the hid + // came from, or the whole point is moot. + assert_eq!(pubkey_hex, hex::encode(key.pubkey_bytes())); + + let msg = socket_hello_message(&key.hid, signed_at); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + assert_eq!(sig_bytes.len(), 64, "ed25519 signature is 64 bytes"); + + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let verifying = key.signing.verifying_key(); + verifying + .verify(&msg, &ed25519_dalek::Signature::from_bytes(&sig_arr)) + .expect("signature must verify against the signing key"); + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } + + #[test] + fn a_signature_does_not_verify_for_a_different_hid() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-rebind", HidPrefix::Wbee).expect("mint"); + let other = Hid::random(HidPrefix::Wbee); + + let (_pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + + // Someone claiming another bee's hid with this signature must fail — + // otherwise a bee could replay a signature to adopt someone's identity. + let msg = socket_hello_message(&other, signed_at); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let verifying = key.signing.verifying_key(); + assert!( + verifying + .verify(&msg, &ed25519_dalek::Signature::from_bytes(&sig_arr)) + .is_err(), + "signature bound to one hid must not verify for another" + ); + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } + + #[test] + fn the_timestamp_is_part_of_what_is_signed() { + let _guard = ENV_LOCK.lock().unwrap(); + let tmp = TempDir::new().unwrap(); + unsafe { std::env::set_var("XDG_STATE_HOME", tmp.path()) }; + + let key = load_or_mint_bee_key("sig-replay", HidPrefix::Wbee).expect("mint"); + let (_pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); + let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(&sig_bytes); + let signature = ed25519_dalek::Signature::from_bytes(&sig_arr); + let verifying = key.signing.verifying_key(); + + // Re-binding the same signature to any other timestamp fails. That is + // the property that makes the skew window enforceable: an attacker + // holding a valid hello cannot slide its clock forward or back. + for other in [1_700_000_000_001, 1_600_000_000_000, 0] { + let msg = socket_hello_message(&key.hid, other); + assert!( + verifying.verify(&msg, &signature).is_err(), + "a signature at {signed_at} must not verify at {other}" + ); + } + + unsafe { std::env::remove_var("XDG_STATE_HOME") }; + } } diff --git a/humd/src/lib.rs b/humd/src/lib.rs index e857282..3d1e878 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -924,20 +924,46 @@ impl ToneSink for HumdSink { manifest.bee = bee.clone(); manifest.models = models.clone(); let raw_hid = tone.get("hid").and_then(Value::as_str); - manifest.hid = raw_hid.and_then(|s| ensemble::Hid::from_hex(s).ok()); - match (raw_hid, manifest.hid) { - (Some(_), Some(hid)) => { - trace!(client_id, hid = %hid.short(), "bee.hid.registered"); + // A bee that signs its hello proves the declared hid is + // its own key. One that doesn't is admitted as before — + // this binds identity where identity is offered, without + // breaking every hive that hasn't caught up. + match ensemble::parse_socket_identity(&tone) { + ensemble::SocketIdentity::Verified(hid) => { + manifest.hid = Some(hid); + manifest.hid_verified = true; + trace!(client_id, hid = %hid.short(), + "bee.hid.verified — declared hid proven against a signed hello"); } - (Some(bad), None) => { - warn!(client_id, bad_hid = %bad, - "bee.hid.invalid — not a canonical Hid; reconnect dedup disabled for this bee. \ - Derive a stable hid from a persisted key (see hives/common identity)."); + ensemble::SocketIdentity::Unsigned(declared) => { + manifest.hid = declared; + match (raw_hid, declared) { + (Some(_), Some(hid)) => { + trace!(client_id, hid = %hid.short(), + "bee.hid.registered — self-declared, not signed"); + } + (Some(bad), None) => { + warn!(client_id, bad_hid = %bad, + "bee.hid.invalid — not a canonical Hid; reconnect dedup disabled for this bee. \ + Derive a stable hid from a persisted key (see hum-identity)."); + } + (None, _) => { + warn!(client_id, + "bee.hid.missing — hello has no hid; reconnect dedup disabled. \ + Ghost manifests will accumulate on reconnect."); + } + } } - (None, _) => { - warn!(client_id, - "bee.hid.missing — hello has no hid; reconnect dedup disabled. \ - Ghost manifests will accumulate on reconnect."); + ensemble::SocketIdentity::Invalid(why) => { + error!(client_id, reason = why, + "bee.hid.bad-signature — hello signed its hid and the signature failed. \ + Not registered, not announced."); + self.thrum.thrum_to( + client_id, + thrumd::echo_tone(&rid, false, Some("hid signature invalid")), + ); + self.thrum.thrum_close(client_id); + return; } } if let Some(arr) = tone.get("tools").and_then(Value::as_array) { From d0422d9392a8148feb7eb9105c7d73136aba5bcf Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 18:33:33 +0500 Subject: [PATCH 3/8] sim: prove socket hid binding end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tests driving humd's hello arm with a real signature: a valid one is admitted, a forged one is refused, one signed over a hid the key does not own is refused, a stale one is refused, and an unsigned one is still admitted. The unsigned case is the one that protects every existing hive, and the claiming-another's-hid case is the one the pubkey/hid binding exists for — neither was reachable from the unit tests, since they assert on the decision humd actually makes rather than on the parse. Rule 0: neutering humd's reject branch fails exactly the three refusal tests and leaves both admission tests passing. --- Cargo.lock | 3 + sim/Cargo.toml | 3 + sim/tests/socket_identity.rs | 182 +++++++++++++++++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 sim/tests/socket_identity.rs diff --git a/Cargo.lock b/Cargo.lock index 09c159b..93493f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4137,12 +4137,15 @@ name = "sim" version = "0.32.0" dependencies = [ "anyhow", + "ed25519-dalek 2.2.0", "ensemble", + "hex", "hum-identity", "hum-mcp", "hum-paths", "humd", "parking_lot", + "rand 0.8.6", "serde_json", "thrum-core", "tokio", diff --git a/sim/Cargo.toml b/sim/Cargo.toml index 28d8481..b302a55 100644 --- a/sim/Cargo.toml +++ b/sim/Cargo.toml @@ -21,3 +21,6 @@ parking_lot = "0.12" tokio = { workspace = true, features = ["test-util"] } tracing-subscriber = { workspace = true } hum-mcp = { path = "../hum-mcp" } +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = { workspace = true } +hex = { workspace = true } diff --git a/sim/tests/socket_identity.rs b/sim/tests/socket_identity.rs new file mode 100644 index 0000000..a4ecdf0 --- /dev/null +++ b/sim/tests/socket_identity.rs @@ -0,0 +1,182 @@ +//! Socket hello identity binding. +//! +//! A bee declares `hid` as a string. When it also signs that hid, humd +//! binds the string to a key before trusting it. These tests drive the +//! Hello arm the same way the admission tests do — through the installed +//! sink — so they cover the decision humd actually makes, not a +//! re-implementation of it. + +use std::time::Duration; + +use ed25519_dalek::{Signer, SigningKey}; +use hum_identity::{BeeKey, Hid, HidPrefix}; + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn fresh_bee() -> BeeKey { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + BeeKey { signing, hid } +} + +/// A structurally valid hello that would pass the protoVersion gate, so a +/// failure can only be the identity check. +fn hello_with_identity(tone: &mut serde_json::Value, bee: &BeeKey) { + let (pubkey, signed_at, signature) = bee.sign_socket_hello(now_ms()); + let obj = tone.as_object_mut().expect("object"); + obj.insert("hid".into(), serde_json::json!(bee.hid.to_hex())); + obj.insert("pubkey".into(), serde_json::json!(pubkey)); + obj.insert("signedAt".into(), serde_json::json!(signed_at)); + obj.insert("signature".into(), serde_json::json!(signature)); +} + +fn base_hello() -> serde_json::Value { + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "chis": ["hello", "prompt"], + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_that_signs_its_hid_stays_connected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello_with_identity(&mut hello, &bee); + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + humd.thrum.is_connected(&cid), + "a correctly signed hello must be admitted" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_with_no_signature_is_still_admitted() { + // Backward compatibility: every hive today sends no signature. If this + // breaks, every existing bee breaks with it. + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(bee.hid.to_hex()); + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + humd.thrum.is_connected(&cid), + "an unsigned hid must stay admitted" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_with_a_forged_signature_is_disconnected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + let mut hello = base_hello(); + hello_with_identity(&mut hello, &bee); + // Correct shape, wrong bytes. + hello["signature"] = serde_json::json!(hex::encode([0u8; 64])); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a hello whose signature does not verify must not stay connected" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_bee_claiming_anothers_hid_is_disconnected() { + // The attack the hid/pubkey binding exists for: Mallory signs with her + // own key, but over her victim's hid, so her hid/pubkey pair is not + // internally consistent. + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let mallory = fresh_bee(); + let victim = fresh_bee(); + let signed_at = now_ms(); + let msg = hum_identity::socket_hello_message(&victim.hid, signed_at); + let sig = mallory.signing.sign(&msg); + + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(victim.hid.to_hex()); + hello["pubkey"] = serde_json::json!(hex::encode(mallory.pubkey_bytes())); + hello["signedAt"] = serde_json::json!(signed_at); + hello["signature"] = serde_json::json!(hex::encode(sig.to_bytes())); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a valid signature over a hid the key does not own must be refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_replayed_hello_outside_the_skew_window_is_disconnected() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let bee = fresh_bee(); + // Sign an old hello. Everything is cryptographically valid; only the + // timestamp is stale. That is the whole point of the skew window. + let stale = now_ms() - 3_600_000; + let (pubkey, _signed_at, signature) = bee.sign_socket_hello(stale); + let mut hello = base_hello(); + hello["hid"] = serde_json::json!(bee.hid.to_hex()); + hello["pubkey"] = serde_json::json!(pubkey); + hello["signedAt"] = serde_json::json!(stale); + hello["signature"] = serde_json::json!(signature); + + humd.thrum.inject_tone(&cid, hello).await; + + tokio::time::sleep(Duration::from_millis(120)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a hello recorded a hour ago must not be replayable now" + ); +} \ No newline at end of file From cd15d0ba5c646224ddbfd625e6fb8bbf525e7a56 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 19:35:18 +0500 Subject: [PATCH 4/8] humd: scope broadcast delivery to the chis a client declared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit thrum_broadcast sent to anyone owning a sigil, and to everyone owning none when nobody matched. Neither looked at what the client said it speaks. A forager that only advertises humfs_* still received every prompt, chunk and tool-call the session produced — declared scope was recorded in the manifest and then ignored for delivery. Records the declared chis on the Reach at hello time and filters both the sigil pass and the no-owner fallback on them. A client that declares nothing is unfiltered, not starved. Every bee predating this field sends an empty set, and an empty list means "unspecified" rather than "wants nothing" — refusing it traffic would be a wire break dressed as a security fix. Re-declaring replaces the previous set rather than merging, so a re-hello that narrows scope actually narrows it. Scoped thrum_all too: it has no callers today, but leaving it unfiltered would make it a bypass for anyone who reaches for it next. Rule 0: neutering declares_chi fails the scoped-rejection test and leaves both delivery tests passing. Workspace 381 -> 393, no failures. --- humd/src/lib.rs | 4 ++ humd/src/thrumd.rs | 28 ++++++-- humd/src/thrumd/registry.rs | 71 +++++++++++++++++++ sim/tests/chi_scoping.rs | 132 ++++++++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 sim/tests/chi_scoping.rs diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 3d1e878..d9fe7b3 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -909,6 +909,10 @@ impl ToneSink for HumdSink { .and_then(|v| v.as_array()) .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) .unwrap_or_default(); + // A bee that declared its chis gets only those from + // broadcasts; one that declared none still gets + // everything, so bees predating this are unaffected. + self.thrum.claim_chis(client_id, chis.clone()); let source = tone.get("source").and_then(Value::as_str).map(str::to_string); let bind: Option = tone.get("bind") .and_then(|v| serde_json::from_value(v.clone()).ok()); diff --git a/humd/src/thrumd.rs b/humd/src/thrumd.rs index 328f796..2afa8ed 100644 --- a/humd/src/thrumd.rs +++ b/humd/src/thrumd.rs @@ -110,6 +110,10 @@ impl Thrum { /// every unregistered client (no sigils claimed) if nobody owns it. /// `nest` selects which nest-kind namespace to compute the sigil /// under (e.g. "claude-cli", "claude-repl"). + /// + /// A client that declared its `chis` in a hello only receives tones + /// whose chi it declared. One that declared nothing receives + /// everything — see [`registry::Reach::declares_chi`]. pub fn thrum_broadcast(&self, sid: &str, nest: &str, mut tone: Tone) { let sigil = thrum_core::sigil(sid, nest); if let Some(obj) = tone.as_object_mut() { @@ -117,17 +121,18 @@ impl Thrum { obj.entry("sigil").or_insert(json!(sigil)); obj.insert("from".into(), json!("daemon")); } + let chi = chi_of(&tone).unwrap_or("").to_string(); let reg = self.inner.clients.read(); let mut sent = false; for reach in reg.iter() { - if reach.has_sigil(&sigil) { + if reach.has_sigil(&sigil) && reach.declares_chi(&chi) { let _ = reach.send(tone.clone()); sent = true; } } if !sent { for reach in reg.iter() { - if reach.sigil_count() == 0 { + if reach.sigil_count() == 0 && reach.declares_chi(&chi) { let _ = reach.send(tone.clone()); } } @@ -140,11 +145,16 @@ impl Thrum { ); } - /// Send to every connected client, regardless of sigils. + /// Send to every connected client, regardless of sigils. Still respects + /// declared chi scope — a client that declared its chis does not + /// receive everything merely because sigils were bypassed. pub fn thrum_all(&self, tone: Tone) { + let chi = chi_of(&tone).unwrap_or("").to_string(); let reg = self.inner.clients.read(); for reach in reg.iter() { - let _ = reach.send(tone.clone()); + if reach.declares_chi(&chi) { + let _ = reach.send(tone.clone()); + } } } @@ -157,6 +167,16 @@ impl Thrum { } } + /// Record the chi values a client declared in its hello. Broadcasts + /// then reach only the clients whose declared set covers the tone's + /// chi. A client that declared none still receives everything. + pub fn claim_chis(&self, client_id: &str, chis: Vec) { + let reg = self.inner.clients.read(); + if let Some(reach) = reg.get(client_id) { + reach.set_chis(chis); + } + } + pub fn has_client(&self, client_id: &str) -> bool { self.inner.clients.read().get(client_id).is_some() } diff --git a/humd/src/thrumd/registry.rs b/humd/src/thrumd/registry.rs index 822f075..0e39d08 100644 --- a/humd/src/thrumd/registry.rs +++ b/humd/src/thrumd/registry.rs @@ -19,6 +19,11 @@ pub struct Reach { pub client_id: ClientId, tx: mpsc::Sender, sigils: Mutex>, + /// Chi values this client declared in its hello. Empty means it + /// declared none, which is the same as "unspecified" — such a client + /// receives everything, because every bee predating this field has + /// an empty set and refusing it traffic would be a wire break. + chis: Mutex>, } impl Reach { @@ -29,6 +34,7 @@ impl Reach { client_id, tx, sigils: Mutex::new(HashSet::new()), + chis: Mutex::new(HashSet::new()), }, rx, ) @@ -51,6 +57,23 @@ impl Reach { pub fn sigil_count(&self) -> usize { self.sigils.lock().len() } + + /// Record the chi values a client declared in its hello. Replaces any + /// previous set so a re-hello that narrows scope actually narrows it. + pub fn set_chis(&self, chis: Vec) { + *self.chis.lock() = chis.into_iter().collect(); + } + + /// Whether this client should be handed a tone for `chi`. + /// + /// A client that declared nothing receives everything — that is the + /// backward-compatible answer, and it is the only one available to + /// every bee written before this field existed. A client that declared + /// a list receives only those. + pub fn declares_chi(&self, chi: &str) -> bool { + let chis = self.chis.lock(); + chis.is_empty() || chis.contains(chi) + } } pub(crate) struct Registry { @@ -82,3 +105,51 @@ impl Registry { self.by_id.len() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_client_that_declared_nothing_receives_everything() { + let (reach, _rx) = Reach::new("unspecified".into()); + assert!(reach.declares_chi("prompt"), "no declared set = no filtering"); + assert!(reach.declares_chi("tool-call")); + } + + #[test] + fn a_client_that_declared_a_list_receives_only_those() { + let (reach, _rx) = Reach::new("scoped".into()); + reach.set_chis(vec!["prompt".into(), "chunk".into()]); + assert!(reach.declares_chi("prompt")); + assert!(reach.declares_chi("chunk")); + assert!( + !reach.declares_chi("tool-call"), + "an undeclared chi must not reach a scoped client" + ); + } + + #[test] + fn declaring_an_empty_list_is_not_scoped() { + // A hello with `chis: []` means "unspecified", not "wants nothing". + // Every bee that sends the field but knows none is in that boat. + let (reach, _rx) = Reach::new("empty".into()); + reach.set_chis(Vec::new()); + assert!(reach.declares_chi("prompt")); + } + + #[test] + fn re_declaring_replaces_the_previous_scope() { + // A re-hello that narrows scope must actually narrow, or a bee + // could widen its subscription by saying it once and never + // revoking. + let (reach, _rx) = Reach::new("rehello".into()); + reach.set_chis(vec!["prompt".into(), "chunk".into()]); + reach.set_chis(vec!["prompt".into()]); + assert!(reach.declares_chi("prompt")); + assert!( + !reach.declares_chi("chunk"), + "the earlier, wider scope must not survive a re-hello" + ); + } +} diff --git a/sim/tests/chi_scoping.rs b/sim/tests/chi_scoping.rs new file mode 100644 index 0000000..ba6f806 --- /dev/null +++ b/sim/tests/chi_scoping.rs @@ -0,0 +1,132 @@ +//! Chi-scoped delivery. +//! +//! A bee that declares `chis` in its hello gets only those from a +//! broadcast. One that declares nothing still gets everything, because +//! every bee written before this field existed sends an empty set. + +use std::sync::Arc; +use std::time::Duration; + +use serde_json::Value; + +use sim::{Sim, SimHumd}; + +fn base_hello(chis: Option<&[&str]>) -> Value { + let mut hello = serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + }); + if let Some(list) = chis { + hello["chis"] = serde_json::json!(list); + } + hello +} + +/// A reply arriving from a peer humd, which humd fans out to the nestlers +/// attached to that sid. +fn peer_reply(chi: &str, sid: &str) -> Value { + serde_json::json!({ + "chi": chi, + "from": "ensemble", + "sid": sid, + "text": "hello from the other side", + }) +} + +/// Drain everything currently queued on a synthetic client's channel. +fn drain(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(tone) = rx.try_recv() { + out.push(tone); + } + out +} + +fn chis_of(tones: &[Value]) -> Vec<&str> { + tones + .iter() + .filter_map(|t| t.get("chi").and_then(Value::as_str)) + .collect() +} + +async fn spawn() -> (Sim, Arc) { + let _ = tracing_subscriber::fmt::try_init(); + let sim = Sim::new(); + let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.await_ready(humd.id).await.expect("humd ready"); + (sim, humd) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_scoped_client_does_not_receive_an_undeclared_chi() { + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + // This client only wants prompts, not chunks. + humd.thrum + .inject_tone(&cid, base_hello(Some(&["prompt"]))) + .await; + + // A chunk arrives from a peer. + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + !chis_of(&got).contains(&"chunk"), + "a client that declared only 'prompt' must not receive a chunk, got {:?}", + chis_of(&got) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_scoped_client_receives_a_declared_chi() { + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum + .inject_tone(&cid, base_hello(Some(&["prompt", "chunk"]))) + .await; + + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + chis_of(&got).contains(&"chunk"), + "a client that declared 'chunk' must receive it, got {:?}", + chis_of(&got) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_client_that_declared_nothing_still_receives_everything() { + // The backward-compatibility case. Every existing hive sends no chis + // or an empty list; scoping must not starve them. + let (_sim, humd) = spawn().await; + + let cid = hum_identity::HumId::mint().to_string(); + let mut rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum.inject_tone(&cid, base_hello(None)).await; + + humd.thrum + .inject_tone("ensemble", peer_reply("chunk", "sess-1")) + .await; + tokio::time::sleep(Duration::from_millis(150)).await; + + let got = drain(&mut rx); + assert!( + chis_of(&got).contains(&"chunk"), + "a client that declared no chis must receive a chunk, got {:?}", + chis_of(&got) + ); +} \ No newline at end of file From 721e817ee020c4cb0b0c25428e08b86882b0da33 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 19:43:42 +0500 Subject: [PATCH 5/8] humd: refuse a revoked bee at the socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bee's hid is the hash of its public key. That is pseudonymity without accountability — no authority behind a name, and no way to withdraw one that behaves badly. Nothing was revocable. The operator lists hids in $XDG_STATE_HOME/hum/revoked.json and humd refuses them at hello: closed connection, error echo, not registered and not announced. Not merely hidden from discovery, which leaves the socket open to the bee it was meant to exclude. Checked whether or not the bee proved the hid. The evasion this closes is the obvious one: stop signing, claim the same string, and watch a proof-only check pass by. The revoked string is refused however it is presented. A corrupt file revokes nothing rather than everything. Failing closed would lock every bee out of the socket — a daemon that admits nobody is a worse failure than one that admits a revoked bee, and the operator can act on the parse warning. The list is read once at boot, not per hello. Re-reading puts disk on the admission path, which is the one path that must not stall; a revocation takes effect on next start. Path is on DaemonConfig so tests can point at a tempdir instead of the developer's real state. What this does not do: bind across the mesh. A bee refused here can still reach another daemon. That gap needs the on-chain registry to carry revocations, which is a contract change. Worth noting the issue describes HumdRegistry as "currently used for optional manifest anchoring" — it has no callers anywhere in the tree, behind an `onchain` feature flag. So there was nothing to hook into. Rule 0: neutering the check fails the two refusal tests and leaves the control passing. Workspace 393 -> 400, no failures. --- hum-paths/src/lib.rs | 6 ++ humd/src/lib.rs | 22 ++++++ humd/src/revoked.rs | 161 ++++++++++++++++++++++++++++++++++++++++ sim/src/lib.rs | 29 ++++++++ sim/tests/revocation.rs | 119 +++++++++++++++++++++++++++++ 5 files changed, 337 insertions(+) create mode 100644 humd/src/revoked.rs create mode 100644 sim/tests/revocation.rs diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index f508b16..2f1123a 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -69,6 +69,7 @@ pub const PENNY_BASENAME: &str = "penny.json"; pub const HUMD_KEY_BASENAME: &str = "humd.key"; pub const BEES_SNAPSHOT_BASENAME: &str = "bees.json"; pub const RUNTIME_INFO_BASENAME: &str = "runtime.json"; +pub const REVOKED_BEES_BASENAME: &str = "revoked.json"; pub const HUM_JSON_BASENAME: &str = "hum.json"; pub const PEERS_JSON_BASENAME: &str = "peers.json"; pub const ORCHFILE_BASENAME: &str = "Orchfile"; @@ -135,6 +136,11 @@ pub fn bees_snapshot() -> PathBuf { state_dir().join(BEES_SNAPSHOT_BASENAME) } /// Rendezvous file: running daemon publishes its socket path, pid, and version here. pub fn runtime_info() -> PathBuf { state_dir().join(RUNTIME_INFO_BASENAME) } +/// Bee identities the operator has withdrawn. Checked at hello time, so a +/// revoked bee is refused the socket rather than merely hidden from +/// discovery. +pub fn revoked_bees() -> PathBuf { state_dir().join(REVOKED_BEES_BASENAME) } + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeInfo { pub socket: PathBuf, diff --git a/humd/src/lib.rs b/humd/src/lib.rs index d9fe7b3..a5bde8c 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -20,6 +20,7 @@ pub mod peers; mod penny; pub mod redial; mod routing_store; +pub mod revoked; pub mod supervisor; pub mod thrumd; pub use identity::{key_path, load_or_mint_key, read_key}; @@ -47,6 +48,7 @@ pub struct DaemonConfig { pub routing_persist_interval: Duration, pub thrum_override: Option, pub ensemble: Option>, + pub revoked_path: PathBuf, pub bind_mcp: bool, pub capacity: LocalCapacity, pub waneman: Option>, @@ -84,6 +86,7 @@ impl DaemonConfig { routing_persist_interval: Duration::from_secs(30), thrum_override: None, ensemble: None, + revoked_path: hum_paths::revoked_bees(), bind_mcp: true, capacity: LocalCapacity::default(), waneman: None, @@ -299,6 +302,7 @@ where incoming_tool_calls: incoming_tool_calls.clone(), thehum: thehum_handle.clone(), trust_remote_workers: cfg.trust_remote_workers, + revoked: revoked::Revocations::load(&cfg.revoked_path), }); thrum.set_sink(sink); if let Some(ens) = &ensemble_for_sink { @@ -486,6 +490,7 @@ struct HumdSink { incoming_tool_calls: Arc>>, thehum: Option>, trust_remote_workers: bool, + revoked: revoked::Revocations, } pub struct PeersAliasResolver { @@ -970,6 +975,23 @@ impl ToneSink for HumdSink { return; } } + // A revoked hid is refused the socket, not merely hidden + // from discovery. Checked whether or not the bee proved + // the hid: one that dropped its signature to slip past + // this would be claiming the same revoked string. + if let Some(hid) = manifest.hid { + if self.revoked.is_revoked(&hid.to_hex()) { + error!(client_id, hid = %hid.short(), + "bee.revoked — hid is on the operator's revocation list. \ + Not registered, not announced."); + self.thrum.thrum_to( + client_id, + thrumd::echo_tone(&rid, false, Some("bee identity revoked")), + ); + self.thrum.thrum_close(client_id); + return; + } + } if let Some(arr) = tone.get("tools").and_then(Value::as_array) { manifest.tools = arr.iter().filter_map(|v| { let name = v.get("name").and_then(Value::as_str)?.to_string(); diff --git a/humd/src/revoked.rs b/humd/src/revoked.rs new file mode 100644 index 0000000..20d9da4 --- /dev/null +++ b/humd/src/revoked.rs @@ -0,0 +1,161 @@ +//! Revoked bee identities. +//! +//! A bee's hid is the hash of its public key. That gives pseudonymity and +//! no accountability — there is no authority behind a name, and nothing +//! to withdraw when one behaves badly. This is the local half of that: +//! an operator lists a hid here and the socket refuses it. +//! +//! Local, not global. It binds the *operator's* humd, not the mesh. A bee +//! refused here can still reach a different daemon. Closing that gap +//! needs the on-chain registry to carry revocations, which is a contract +//! change rather than a code one — see `ensemble/src/onchain.rs`. +//! +//! What revocation does bind to is the identity binding from the socket +//! handshake. A revoked hid is refused whether or not the bee proved the +//! hid, so dropping its signature to slip past the check does not help. + +use std::io; +use std::path::Path; + +use parking_lot::RwLock; +use tracing::{trace, warn}; + +#[derive(serde::Serialize, serde::Deserialize, Default)] +struct OnDisk { + #[serde(default)] + version: u32, + #[serde(default)] + revoked: Vec, +} + +/// The set of withdrawn hids, loaded once at boot and consulted per hello. +/// +/// Cloning is cheap — the set lives behind an `Arc` and is not re-read per +/// hello, so a revocation takes effect on the next daemon start rather +/// than immediately. That is a deliberate trade: re-reading a file on +/// every hello puts disk on the admission path, and admission is the one +/// place that must not stall. +#[derive(Clone, Default)] +pub struct Revocations { + inner: std::sync::Arc>>, +} + +impl Revocations { + /// Load from disk. An absent or unparseable file yields an empty set: + /// a corrupt revocation file must not lock every bee out of the + /// socket, which is a worse failure than admitting a revoked one. + pub fn load(path: &Path) -> Self { + let bytes = match std::fs::read(path) { + Ok(b) => b, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + trace!(target: "revoke", "revoke.load.absent"); + Vec::new() + } + Err(e) => { + warn!(target: "revoke", "revoke.load.read_failed err={e}"); + Vec::new() + } + }; + let list = if bytes.is_empty() { + Vec::new() + } else { + match serde_json::from_slice::(&bytes) { + Ok(disk) => disk.revoked, + Err(e) => { + warn!(target: "revoke", "revoke.load.parse_failed err={e} — treating as empty"); + Vec::new() + } + } + }; + Self { + inner: std::sync::Arc::new(RwLock::new(list)), + } + } + + /// Whether `hid_hex` has been withdrawn. + pub fn is_revoked(&self, hid_hex: &str) -> bool { + self.inner.read().iter().any(|h| h == hid_hex) + } + + pub fn len(&self) -> usize { + self.inner.read().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Replace the set and persist it. For tests and CLI paths that need + /// to change the list without restarting the daemon. + pub fn set_and_save(&self, list: Vec, path: &Path) -> io::Result<()> { + { + let mut guard = self.inner.write(); + *guard = list.clone(); + } + save(path, &list) + } +} + +pub fn save(path: &Path, revoked: &[String]) -> io::Result<()> { + if let Some(dir) = path.parent() { + if !dir.as_os_str().is_empty() { + std::fs::create_dir_all(dir)?; + } + } + let disk = OnDisk { + version: 1, + revoked: revoked.to_vec(), + }; + let bytes = serde_json::to_vec_pretty(&disk) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, path) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn an_absent_file_revokes_nothing() { + let tmp = TempDir::new().unwrap(); + let rev = Revocations::load(&tmp.path().join("nope.json")); + assert!(rev.is_empty()); + assert!(!rev.is_revoked("wbee_abc")); + } + + #[test] + fn a_listed_hid_is_revoked() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + let rev = Revocations::load(&path); + rev.set_and_save(vec!["wbee_dead".into()], &path).unwrap(); + assert!(rev.is_revoked("wbee_dead")); + assert!(!rev.is_revoked("wbee_other")); + } + + #[test] + fn a_revoked_list_survives_a_reload() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + Revocations::load(&path) + .set_and_save(vec!["wbee_dead".into()], &path) + .unwrap(); + let reopened = Revocations::load(&path); + assert!(reopened.is_revoked("wbee_dead")); + } + + #[test] + fn a_corrupt_file_revokes_nothing_rather_than_everything() { + // Failing closed here would lock every bee out of the socket. An + // operator can see the parse warning; they cannot debug a daemon + // that refuses to admit anything. + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + std::fs::write(&path, b"{ not json").unwrap(); + let rev = Revocations::load(&path); + assert!(rev.is_empty()); + } +} \ No newline at end of file diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 64bc9a7..4158e19 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -103,7 +103,24 @@ impl Sim { self.spawn_humd_inner(id, false).await } + /// A humd whose revocation list already contains these hids. The list + /// is written before the daemon reads it, matching how an operator + /// would have populated it on a previous run. + pub async fn spawn_humd_revoking(&self, id: Hid, revoked: &[&str]) -> Arc { + self.spawn_humd_inner_with_revocations(id, true, revoked).await + } + async fn spawn_humd_inner(&self, id: Hid, trust_remote_workers: bool) -> Arc { + self.spawn_humd_inner_with_revocations(id, trust_remote_workers, &[]) + .await + } + + async fn spawn_humd_inner_with_revocations( + &self, + id: Hid, + trust_remote_workers: bool, + revoked: &[&str], + ) -> Arc { let thrum = Thrum::new(); let ensemble = Arc::new(Ensemble::with_strict_auth(id, false)); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); @@ -112,6 +129,16 @@ impl Sim { let _ = std::fs::create_dir_all(&tmp); let penny_path = tmp.join(hum_paths::PENNY_BASENAME); + // Preload before the daemon constructs its sink, which reads once + // at boot. A stale file from a previous run would otherwise leak + // revocations into an unrelated test. + let revoked_path = tmp.join(hum_paths::REVOKED_BEES_BASENAME); + let _ = std::fs::remove_file(&revoked_path); + if !revoked.is_empty() { + let list: Vec = revoked.iter().map(|s| s.to_string()).collect(); + humd::revoked::save(&revoked_path, &list).expect("write revocation list"); + } + let initial_capacity = self .pending_capacities .write() @@ -132,6 +159,7 @@ impl Sim { penny_persist_interval: Duration::from_secs(3600), thrum_override: Some(thrum.clone()), ensemble: Some(ensemble.clone()), + revoked_path: revoked_path.clone(), bind_mcp: false, capacity, waneman: Some(waneman.clone()), @@ -254,6 +282,7 @@ impl Sim { penny_persist_interval: Duration::from_secs(3600), thrum_override: Some(thrum.clone()), ensemble: Some(ensemble.clone()), + revoked_path: tmp.join(hum_paths::REVOKED_BEES_BASENAME), bind_mcp: false, capacity, waneman: Some(waneman.clone()), diff --git a/sim/tests/revocation.rs b/sim/tests/revocation.rs new file mode 100644 index 0000000..ce9f838 --- /dev/null +++ b/sim/tests/revocation.rs @@ -0,0 +1,119 @@ +//! Revocation at the socket. +//! +//! A revoked hid is refused the socket, not merely hidden from discovery. +//! The interesting case is the one where revocation would be easy to +//! evade: a bee that drops its signature to slip past the check. + +use std::time::Duration; + +use ed25519_dalek::SigningKey; +use hum_identity::{BeeKey, Hid, HidPrefix}; + +fn fresh_bee() -> BeeKey { + let signing = SigningKey::generate(&mut rand::thread_rng()); + let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); + BeeKey { signing, hid } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn hello_with_identity(bee: &BeeKey) -> serde_json::Value { + let (pubkey, signed_at, signature) = bee.sign_socket_hello(now_ms()); + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "chis": ["hello", "prompt"], + "hid": bee.hid.to_hex(), + "pubkey": pubkey, + "signedAt": signed_at, + "signature": signature, + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_revoked_bee_is_refused_the_socket() { + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim.spawn_humd_revoking(id, &[&bee.hid.to_hex()]).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum.inject_tone(&cid, hello_with_identity(&bee)).await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + !humd.thrum.is_connected(&cid), + "a correctly signed hello from a revoked bee must still be refused" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_the_signature_does_not_evade_revocation() { + // The evasion this exists to close: the bee proved nothing, so it + // might reasonably think the check applies only to proofs. It does + // not — a revoked hid is refused however it is presented. + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim.spawn_humd_revoking(id, &[&bee.hid.to_hex()]).await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + + let unsigned = serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.1.0", + "protoVersion": thrum_core::THRUM_VERSION, + "hid": bee.hid.to_hex(), + }); + humd.thrum.inject_tone(&cid, unsigned).await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + !humd.thrum.is_connected(&cid), + "an unsigned hello claiming a revoked hid must be refused too" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unrevoked_bee_is_unaffected() { + // The control: a revocation list that names someone else must not + // lock out a well-behaved bee. + let _ = tracing_subscriber::fmt::try_init(); + let sim = sim::Sim::new(); + + let bee = fresh_bee(); + let other = fresh_bee(); + let id = ensemble::Hid::random_humd(); + let humd = sim + .spawn_humd_revoking(id, &[&other.hid.to_hex()]) + .await; + sim.await_ready(humd.id).await.expect("humd ready"); + + let cid = hum_identity::HumId::mint().to_string(); + let _rx = humd.thrum.register_synthetic(cid.clone()); + humd.thrum.inject_tone(&cid, hello_with_identity(&bee)).await; + + tokio::time::sleep(Duration::from_millis(150)).await; + assert!( + humd.thrum.is_connected(&cid), + "a bee absent from the revocation list must be admitted" + ); +} \ No newline at end of file From f0de28c9bdd79f3f6823ed11b1716d55722dd486 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 19:50:46 +0500 Subject: [PATCH 6/8] humctl: revoke and unrevoke a bee without hand-editing JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A revocation list nobody can reach is not a feature. The list was written by the test harness and by nothing an operator has. humctl revoke ..., humctl unrevoke ..., humctl revocations. Idempotent: revoking a listed hid says so rather than duplicating the entry, and unrevoking an absent one is not an error. The on-disk type moves to hum-paths, next to RuntimeInfo which is already the shared serde state file both sides read. humd keeps a thin wrapper for the admission check; humctl gets the edits without linking the daemon's dependency tree. Both verbs say restart humd afterwards, because the daemon reads the list once at boot. That is a deliberate trade — re-reading per hello puts disk on the admission path — but it is a footgun if left implicit. Workspace 400 -> 404, no failures. --- hum-paths/Cargo.toml | 3 + hum-paths/src/lib.rs | 146 ++++++++++++++++++++++++++++++++++++++++++- humctl/src/main.rs | 64 +++++++++++++++++++ humd/src/revoked.rs | 146 +++++++++++-------------------------------- sim/src/lib.rs | 4 +- 5 files changed, 253 insertions(+), 110 deletions(-) diff --git a/hum-paths/Cargo.toml b/hum-paths/Cargo.toml index 392c5df..4fa7159 100644 --- a/hum-paths/Cargo.toml +++ b/hum-paths/Cargo.toml @@ -10,3 +10,6 @@ serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } jsonschema = "0.46.5" + +[dev-dependencies] +tempfile = "3" diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index 2f1123a..af30be2 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -138,9 +138,96 @@ pub fn runtime_info() -> PathBuf { state_dir().join(RUNTIME_INFO_BASENAME) } /// Bee identities the operator has withdrawn. Checked at hello time, so a /// revoked bee is refused the socket rather than merely hidden from -/// discovery. +/// discovery. Lives here rather than in humd so `humctl` can edit the list +/// without the daemon's dependency tree. pub fn revoked_bees() -> PathBuf { state_dir().join(REVOKED_BEES_BASENAME) } +/// Withdrawn bee identities, as stored on disk. +/// +/// Shared between humd (which refuses a revoked hid at hello) and humctl +/// (which edits the list). humd reads it once at boot, so a change here +/// takes effect on the next start — see [`Revocations`]. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Revocations { + #[serde(default)] + pub version: u32, + #[serde(default)] + pub revoked: Vec, +} + +impl Revocations { + /// Read the list at `path`. + /// + /// An absent or unparseable file yields an empty list rather than an + /// error. Failing closed would lock every bee out of the socket, and a + /// daemon that admits nobody is a worse failure than one that admits a + /// revoked bee — the operator can act on the parse warning. + pub fn read_from(path: &std::path::Path) -> Self { + match std::fs::read(path) { + Ok(bytes) if bytes.is_empty() => Self::default(), + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(list) => list, + Err(e) => { + tracing::warn!( + target: "revoke", + path = %path.display(), + err = %e, + "revoke.load.parse_failed — treating as empty" + ); + Self::default() + } + }, + Err(_) => Self::default(), + } + } + + pub fn read() -> Self { + Self::read_from(&revoked_bees()) + } + + pub fn write_to(&self, path: &std::path::Path) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent)?; + } + } + let tmp = path.with_extension("json.tmp"); + // Stamp on write rather than trusting the caller to carry a + // version, so a hand-edited or CLI-built list is never written + // claiming to be a format it predates. + let mut body = self.clone(); + body.version = 1; + let body = serde_json::to_string_pretty(&body) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + std::fs::write(&tmp, body)?; + std::fs::rename(tmp, path) + } + + pub fn write(&self) -> std::io::Result<()> { + self.write_to(&revoked_bees()) + } + + pub fn contains(&self, hid_hex: &str) -> bool { + self.revoked.iter().any(|h| h == hid_hex) + } + + /// Add `hid`, returning false if it was already listed. + pub fn revoke(&mut self, hid: &str) -> bool { + if self.contains(hid) { + return false; + } + self.revoked.push(hid.to_string()); + true + } + + /// Remove `hid`, returning false if it was not listed. + pub fn unrevoke(&mut self, hid: &str) -> bool { + let before = self.revoked.len(); + self.revoked.retain(|h| h != hid); + self.revoked.len() != before + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RuntimeInfo { pub socket: PathBuf, @@ -285,3 +372,60 @@ pub fn expand_tilde(p: &std::path::Path) -> PathBuf { } pub mod config; + +#[cfg(test)] +mod revocation_tests { + use super::*; + + #[test] + fn an_absent_file_revokes_nothing() { + let tmp = tempfile::TempDir::new().unwrap(); + let list = Revocations::read_from(&tmp.path().join("nope.json")); + assert!(!list.contains("wbee_abc")); + } + + #[test] + fn a_listed_hid_is_contained() { + let mut list = Revocations::default(); + assert!(list.revoke("wbee_dead")); + assert!(list.contains("wbee_dead")); + assert!(!list.contains("wbee_other")); + } + + #[test] + fn revoking_twice_is_not_a_duplicate() { + let mut list = Revocations::default(); + assert!(list.revoke("wbee_dead")); + assert!(!list.revoke("wbee_dead"), "already listed"); + assert_eq!(list.revoked.len(), 1); + } + + #[test] + fn unrevoking_removes_only_that_hid() { + let mut list = Revocations::default(); + list.revoke("wbee_dead"); + list.revoke("wbee_gone"); + assert!(list.unrevoke("wbee_dead")); + assert!(!list.contains("wbee_dead")); + assert!(list.contains("wbee_gone")); + assert!(!list.unrevoke("wbee_dead"), "nothing left to remove"); + } + + #[test] + fn a_list_survives_a_round_trip() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + let mut list = Revocations::default(); + list.revoke("wbee_dead"); + list.write_to(&path).unwrap(); + assert!(Revocations::read_from(&path).contains("wbee_dead")); + } + + #[test] + fn a_corrupt_file_revokes_nothing_rather_than_everything() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("revoked.json"); + std::fs::write(&path, b"{ not json").unwrap(); + assert!(!Revocations::read_from(&path).contains("wbee_abc")); + } +} diff --git a/humctl/src/main.rs b/humctl/src/main.rs index b12a566..1924ede 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -20,6 +20,12 @@ Usage: humctl logs [-n LINES] humctl health humctl thehum + humctl revoke ... + humctl unrevoke ... + humctl revocations + +A revoked hid is refused the socket at hello. The list is read when humd +starts, so restart after revoking. "; fn main() -> ExitCode { @@ -50,6 +56,9 @@ fn run() -> Result<()> { "logs" => logs(parse_lines(args.collect::>())), "health" => health(), "thehum" => thehum(), + "revoke" => revoke(args.collect::>()), + "unrevoke" => unrevoke(args.collect::>()), + "revocations" => revocations(), other => bail!("unknown verb '{other}'\n{USAGE}"), } } @@ -254,3 +263,58 @@ fn thehum() -> Result<()> { unsafe extern "C" { fn geteuid() -> u32; } + +fn revoke(hids: Vec) -> Result<()> { + if hids.is_empty() { + bail!("revoke needs at least one hid\n{USAGE}"); + } + let mut list = hum_paths::Revocations::read(); + let mut added = 0usize; + for hid in &hids { + if list.revoke(hid) { + println!("revoked {hid}"); + added += 1; + } else { + println!("already revoked {hid}"); + } + } + list.write().context("write revocation list")?; + if added > 0 { + println!("restart humd for this to take effect"); + } + Ok(()) +} + +fn unrevoke(hids: Vec) -> Result<()> { + if hids.is_empty() { + bail!("unrevoke needs at least one hid\n{USAGE}"); + } + let mut list = hum_paths::Revocations::read(); + let mut removed = 0usize; + for hid in &hids { + if list.unrevoke(hid) { + println!("unrevoked {hid}"); + removed += 1; + } else { + println!("not revoked {hid}"); + } + } + list.write().context("write revocation list")?; + if removed > 0 { + println!("restart humd for this to take effect"); + } + Ok(()) +} + +fn revocations() -> Result<()> { + let list = hum_paths::Revocations::read(); + if list.revoked.is_empty() { + println!("no revoked bees"); + return Ok(()); + } + println!("{} revoked:", list.revoked.len()); + for hid in &list.revoked { + println!(" {hid}"); + } + Ok(()) +} diff --git a/humd/src/revoked.rs b/humd/src/revoked.rs index 20d9da4..de3a2db 100644 --- a/humd/src/revoked.rs +++ b/humd/src/revoked.rs @@ -1,161 +1,91 @@ -//! Revoked bee identities. +//! Revoked bee identities, as consulted at hello time. //! //! A bee's hid is the hash of its public key. That gives pseudonymity and //! no accountability — there is no authority behind a name, and nothing //! to withdraw when one behaves badly. This is the local half of that: -//! an operator lists a hid here and the socket refuses it. +//! an operator lists a hid and the socket refuses it. //! -//! Local, not global. It binds the *operator's* humd, not the mesh. A bee -//! refused here can still reach a different daemon. Closing that gap -//! needs the on-chain registry to carry revocations, which is a contract -//! change rather than a code one — see `ensemble/src/onchain.rs`. +//! Local, not global. It binds this daemon, not the mesh. A bee refused +//! here can still reach a different humd. Closing that gap needs the +//! on-chain registry to carry revocations, which is a contract change +//! rather than a code one — see `ensemble/src/onchain.rs`. //! //! What revocation does bind to is the identity binding from the socket //! handshake. A revoked hid is refused whether or not the bee proved the //! hid, so dropping its signature to slip past the check does not help. +//! +//! The on-disk format lives in [`hum_paths::Revocations`] so `humctl` can +//! edit the list without depending on the daemon. This wrapper is the +//! daemon-side handle. -use std::io; use std::path::Path; +use std::sync::Arc; use parking_lot::RwLock; -use tracing::{trace, warn}; - -#[derive(serde::Serialize, serde::Deserialize, Default)] -struct OnDisk { - #[serde(default)] - version: u32, - #[serde(default)] - revoked: Vec, -} -/// The set of withdrawn hids, loaded once at boot and consulted per hello. +/// The set of withdrawn hids, read once at boot. /// -/// Cloning is cheap — the set lives behind an `Arc` and is not re-read per -/// hello, so a revocation takes effect on the next daemon start rather -/// than immediately. That is a deliberate trade: re-reading a file on -/// every hello puts disk on the admission path, and admission is the one -/// place that must not stall. +/// Cloning is cheap. The set is deliberately *not* re-read per hello: +/// that would put disk on the admission path, and admission is the one +/// path that must not stall. A revocation therefore takes effect on the +/// next daemon start, which `humctl revoke` says out loud. #[derive(Clone, Default)] pub struct Revocations { - inner: std::sync::Arc>>, + inner: Arc>, } impl Revocations { - /// Load from disk. An absent or unparseable file yields an empty set: - /// a corrupt revocation file must not lock every bee out of the - /// socket, which is a worse failure than admitting a revoked one. pub fn load(path: &Path) -> Self { - let bytes = match std::fs::read(path) { - Ok(b) => b, - Err(e) if e.kind() == io::ErrorKind::NotFound => { - trace!(target: "revoke", "revoke.load.absent"); - Vec::new() - } - Err(e) => { - warn!(target: "revoke", "revoke.load.read_failed err={e}"); - Vec::new() - } - }; - let list = if bytes.is_empty() { - Vec::new() - } else { - match serde_json::from_slice::(&bytes) { - Ok(disk) => disk.revoked, - Err(e) => { - warn!(target: "revoke", "revoke.load.parse_failed err={e} — treating as empty"); - Vec::new() - } - } - }; Self { - inner: std::sync::Arc::new(RwLock::new(list)), + inner: Arc::new(RwLock::new(hum_paths::Revocations::read_from(path))), } } /// Whether `hid_hex` has been withdrawn. pub fn is_revoked(&self, hid_hex: &str) -> bool { - self.inner.read().iter().any(|h| h == hid_hex) + self.inner.read().contains(hid_hex) } pub fn len(&self) -> usize { - self.inner.read().len() + self.inner.read().revoked.len() } pub fn is_empty(&self) -> bool { self.len() == 0 } - /// Replace the set and persist it. For tests and CLI paths that need - /// to change the list without restarting the daemon. - pub fn set_and_save(&self, list: Vec, path: &Path) -> io::Result<()> { - { - let mut guard = self.inner.write(); - *guard = list.clone(); - } - save(path, &list) - } -} - -pub fn save(path: &Path, revoked: &[String]) -> io::Result<()> { - if let Some(dir) = path.parent() { - if !dir.as_os_str().is_empty() { - std::fs::create_dir_all(dir)?; - } + #[cfg(test)] + pub(crate) fn set_for_test(&self, list: Vec) { + let mut guard = self.inner.write(); + *guard = hum_paths::Revocations { + version: 1, + revoked: list, + }; } - let disk = OnDisk { - version: 1, - revoked: revoked.to_vec(), - }; - let bytes = serde_json::to_vec_pretty(&disk) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; - let tmp = path.with_extension("json.tmp"); - std::fs::write(&tmp, &bytes)?; - std::fs::rename(&tmp, path) } #[cfg(test)] mod tests { use super::*; - use tempfile::TempDir; #[test] - fn an_absent_file_revokes_nothing() { - let tmp = TempDir::new().unwrap(); - let rev = Revocations::load(&tmp.path().join("nope.json")); - assert!(rev.is_empty()); - assert!(!rev.is_revoked("wbee_abc")); - } - - #[test] - fn a_listed_hid_is_revoked() { - let tmp = TempDir::new().unwrap(); + fn a_loaded_hid_is_revoked() { + let tmp = tempfile::TempDir::new().unwrap(); let path = tmp.path().join("revoked.json"); + hum_paths::Revocations { + version: 1, + revoked: vec!["wbee_dead".into()], + } + .write_to(&path) + .unwrap(); let rev = Revocations::load(&path); - rev.set_and_save(vec!["wbee_dead".into()], &path).unwrap(); assert!(rev.is_revoked("wbee_dead")); assert!(!rev.is_revoked("wbee_other")); } #[test] - fn a_revoked_list_survives_a_reload() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("revoked.json"); - Revocations::load(&path) - .set_and_save(vec!["wbee_dead".into()], &path) - .unwrap(); - let reopened = Revocations::load(&path); - assert!(reopened.is_revoked("wbee_dead")); - } - - #[test] - fn a_corrupt_file_revokes_nothing_rather_than_everything() { - // Failing closed here would lock every bee out of the socket. An - // operator can see the parse warning; they cannot debug a daemon - // that refuses to admit anything. - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("revoked.json"); - std::fs::write(&path, b"{ not json").unwrap(); - let rev = Revocations::load(&path); - assert!(rev.is_empty()); + fn an_absent_file_revokes_nothing() { + let tmp = tempfile::TempDir::new().unwrap(); + assert!(Revocations::load(&tmp.path().join("nope.json")).is_empty()); } } \ No newline at end of file diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 4158e19..6131a8d 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -136,7 +136,9 @@ impl Sim { let _ = std::fs::remove_file(&revoked_path); if !revoked.is_empty() { let list: Vec = revoked.iter().map(|s| s.to_string()).collect(); - humd::revoked::save(&revoked_path, &list).expect("write revocation list"); + hum_paths::Revocations { version: 1, revoked: list } + .write_to(&revoked_path) + .expect("write revocation list"); } let initial_capacity = self From d3d4bdd031f85705a360419449aabdd1d4054976 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 18 Oct 2026 22:37:57 +0500 Subject: [PATCH 7/8] strip narration from the admission work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed 77 inline comment lines. The behaviour is unchanged; 404 tests still pass and no gate moved. Kept the doc comments. Those describe the contract rather than the code — what `SocketIdentity::Unsigned` permits, why the two signature domains must not cross, what a corrupt revocation file costs. `hives.rs` runs 105 doc-comment lines against 161 lines of code, so this is the house style and removing it would leave the new security surface undocumented at exactly the points a reader cannot infer. Removed the rest: "the peer signed can_relay: false, then ships can_relay: true" restates the next two lines. "a re-hello that narrows scope must actually narrow" restates the test name above it. Where a rationale was real but is now expressed by placement — revocation is checked outside the identity match, so "checked whether or not the bee proved the hid" is visible in the structure — the structure carries it. Also collapsed a nested if in `Revocations::write_to` to match the existing `RuntimeInfo::write` three lines above it. --- Cargo.lock | 1 + ensemble/src/handshake.rs | 57 +--- hum-identity/src/lib.rs | 16 +- hum-paths/src/lib.rs | 145 ++++++--- humctl/src/main.rs | 2 +- humd/src/lib.rs | 603 +++++++++++++++++++++++++---------- humd/src/revoked.rs | 2 +- humd/src/thrumd.rs | 19 +- humd/src/thrumd/registry.rs | 14 +- sim/src/lib.rs | 283 +++++++++------- sim/tests/chi_scoping.rs | 7 +- sim/tests/revocation.rs | 19 +- sim/tests/socket_identity.rs | 12 +- 13 files changed, 745 insertions(+), 435 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 93493f2..3ca2ee1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1733,6 +1733,7 @@ dependencies = [ "jsonschema", "serde", "serde_json", + "tempfile", "tracing", ] diff --git a/ensemble/src/handshake.rs b/ensemble/src/handshake.rs index 141a8e0..d57256c 100644 --- a/ensemble/src/handshake.rs +++ b/ensemble/src/handshake.rs @@ -7,9 +7,7 @@ use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -use super::{ - CAPS_HANDSHAKE_DOMAIN, HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms, -}; +use super::{CAPS_HANDSHAKE_DOMAIN, HANDSHAKE_DOMAIN, HANDSHAKE_SKEW_MS, Tone, headroom, now_ms}; pub use hum_identity::{Hid, HidParseError, HidPrefix}; @@ -131,8 +129,6 @@ pub fn hello_tone(me: &Hid, key: &HumdKey, caps: &PeerCapabilities) -> Tone { let signed_at = now_ms(); let msg = handshake_message(me, signed_at); let sig: Signature = key.0.sign(&msg); - // Second signature, over the claims. Identity stays covered by the - // first so an old peer verifying `signature` sees an unchanged message. let caps_msg = caps_message(me, caps, signed_at); let caps_sig: Signature = key.0.sign(&caps_msg); serde_json::json!({ @@ -267,11 +263,6 @@ pub fn parse_hello_caps(tone: &Tone) -> Option<(Hid, PeerCapabilities)> { .get("headroom") .and_then(|v| serde_json::from_value::(v.clone()).ok()) .unwrap_or_default(); - - // The claims signature is optional — a peer that predates it sends none - // and is admitted as unsigned. A peer that sends one and gets it wrong is - // either broken or lying, and either way the claims cannot be trusted, so - // the whole hello fails rather than degrading to unsigned. let mut caps_signed = false; match tone.get("capsSignature").and_then(|v| v.as_str()) { None => { @@ -350,7 +341,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { let Ok(claimed_id) = Hid::from_hex(humd_hex) else { return HelloParse::Invalid; }; - let nests: Vec = tone + let nests: Vec = tone .get("nests") .and_then(|v| v.as_array()) .map(|a| { @@ -359,7 +350,7 @@ pub fn parse_hello(tone: &Tone) -> HelloParse { .collect() }) .unwrap_or_default(); - let hosts: Vec = tone + let hosts: Vec = tone .get("hosts") .and_then(|v| v.as_array()) .map(|a| { @@ -420,8 +411,6 @@ pub enum SocketIdentity { /// peer hello and renaming would churn every hive for no gain. pub fn parse_socket_identity(tone: &Tone) -> SocketIdentity { let Some(pubkey_hex) = tone.get("pubkey").and_then(|v| v.as_str()) else { - // No signature fields at all. Same as before this check existed — - // a bee that predates signing keeps working. let hid = tone .get("hid") .and_then(|v| v.as_str()) @@ -435,9 +424,6 @@ pub fn parse_socket_identity(tone: &Tone) -> SocketIdentity { let Ok(claimed) = Hid::from_hex(hid_hex) else { return SocketIdentity::Invalid("hid unparseable"); }; - - // A socket bee's hid must carry a bee prefix. A humd_ hid here would - // mean a daemon key claiming to be a bee, which is not a thing. if !matches!(claimed.prefix, HidPrefix::Wbee | HidPrefix::Fbee) { return SocketIdentity::Invalid("hid prefix is not a bee role"); } @@ -450,9 +436,6 @@ pub fn parse_socket_identity(tone: &Tone) -> SocketIdentity { } let mut pubkey = [0u8; 32]; pubkey.copy_from_slice(&pubkey_bytes); - - // The hid must be the hash of the key that signed it. Without this a - // bee signs with any key it likes and claims someone else's hid. if Hid::from_pubkey(claimed.prefix, &pubkey) != claimed { tracing::warn!( target: "ensemble", @@ -541,7 +524,6 @@ mod tests { #[test] fn tampering_with_a_claim_after_signing_fails_verification() { let (_id, _key, mut tone) = signed_hello(&caps()); - // The peer signed can_relay: false, then ships can_relay: true. tone["can_relay"] = serde_json::json!(true); assert!( parse_hello_caps(&tone).is_none(), @@ -576,9 +558,10 @@ mod tests { #[test] fn a_hello_without_a_claims_signature_still_admits_as_unsigned() { - // Backward compatibility: a peer predating this field sends none. let (_id, _key, mut tone) = signed_hello(&caps()); - tone.as_object_mut().expect("tone object").remove("capsSignature"); + tone.as_object_mut() + .expect("tone object") + .remove("capsSignature"); let parsed = parse_hello_caps(&tone).expect("still parses"); assert!( !parsed.1.caps_signed, @@ -590,15 +573,10 @@ mod tests { fn a_replayed_claims_signature_from_another_peer_is_rejected() { let (_id_a, _key_a, tone_a) = signed_hello(&caps()); let (id_b, _key_b, mut tone_b) = signed_hello(&caps()); - // B claims A's signature while presenting its own pubkey and Hid. tone_b["capsSignature"] = tone_a["capsSignature"].clone(); assert!(parse_hello_caps(&tone_b).is_none(), "B={}", id_b.short()); } - // ── socket identity ──────────────────────────────────────────────── - - /// Build a socket hello the way a bee would: sign the hid with the - /// key that produced it, then attach the three identity fields. fn signed_socket_hello() -> (Hid, SigningKey, Tone) { let signing = SigningKey::generate(&mut rand::thread_rng()); let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); @@ -628,11 +606,14 @@ mod tests { #[test] fn a_socket_hello_without_a_signature_is_admitted_as_unsigned() { - // Backward compatible: every existing hive sends no pubkey today. let (hid, _signing, mut tone) = signed_socket_hello(); tone.as_object_mut().expect("tone object").remove("pubkey"); - tone.as_object_mut().expect("tone object").remove("signedAt"); - tone.as_object_mut().expect("tone object").remove("signature"); + tone.as_object_mut() + .expect("tone object") + .remove("signedAt"); + tone.as_object_mut() + .expect("tone object") + .remove("signature"); match parse_socket_identity(&tone) { SocketIdentity::Unsigned(Some(id)) => assert_eq!(id, hid), other => panic!("expected Unsigned(Some), got {:?}", socket_dbg(&other)), @@ -641,9 +622,6 @@ mod tests { #[test] fn claiming_another_bees_hid_fails_verification() { - // The signature is valid, but it was made for a different hid. If - // this passed, a bee could replay any recorded hello to adopt a - // more trusted identity. let (_hid, _signing, mut tone) = signed_socket_hello(); let impostor = Hid::random(HidPrefix::Wbee); tone["hid"] = serde_json::json!(impostor.to_hex()); @@ -655,9 +633,6 @@ mod tests { #[test] fn a_tampered_hid_with_a_rehashed_pubkey_is_rejected() { - // Mallory regenerates the hid to match a key she controls, so the - // hid/pubkey pair is internally consistent — but the signature was - // made for the original hid. let (_hid, _signing, mut tone) = signed_socket_hello(); let mallory = SigningKey::generate(&mut rand::thread_rng()); let mallory_hid = Hid::from_pubkey(HidPrefix::Wbee, &mallory.verifying_key().to_bytes()); @@ -700,9 +675,6 @@ mod tests { #[test] fn a_signature_over_a_different_domain_is_rejected() { - // The ensemble peer handshake signs `domain:humd_id:ts`. If the - // socket path accepted that message, a peer hello could be replayed - // onto a socket hello. The domains must not cross. let signing = SigningKey::generate(&mut rand::thread_rng()); let hid = Hid::from_pubkey(HidPrefix::Wbee, &signing.verifying_key().to_bytes()); let signed_at = now_ms(); @@ -722,11 +694,6 @@ mod tests { #[test] fn a_signature_over_someone_elses_hid_is_rejected() { - // The case the hid/pubkey check exists for. Mallory signs with her - // own key, but over a hid that is not the hash of that key — a - // victim's hid, say. Her signature genuinely verifies, because she - // signed exactly what she put in the message. Only the rule that a - // hid must be sha256(pubkey) of the key that signed it stops her. let mallory = SigningKey::generate(&mut rand::thread_rng()); let victim = SigningKey::generate(&mut rand::thread_rng()); let victim_hid = Hid::from_pubkey(HidPrefix::Wbee, &victim.verifying_key().to_bytes()); diff --git a/hum-identity/src/lib.rs b/hum-identity/src/lib.rs index ecff86f..da1cf3a 100644 --- a/hum-identity/src/lib.rs +++ b/hum-identity/src/lib.rs @@ -20,7 +20,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use ed25519_dalek::{Signer, SigningKey}; mod ids; pub use ids::*; @@ -152,10 +152,6 @@ mod tests { use ed25519_dalek::Verifier; use std::sync::Mutex; use tempfile::TempDir; - - // Serialize tests because they share the XDG_STATE_HOME env var. - // Without this lock, parallel cargo-test threads race the env and - // the second test sees the first's tempdir (or an empty value). static ENV_LOCK: Mutex<()> = Mutex::new(()); #[test] @@ -197,9 +193,6 @@ mod tests { let key = load_or_mint_bee_key("sig-bind", HidPrefix::Wbee).expect("mint"); let (pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); - - // The pubkey the verifier will hash must be the same one the hid - // came from, or the whole point is moot. assert_eq!(pubkey_hex, hex::encode(key.pubkey_bytes())); let msg = socket_hello_message(&key.hid, signed_at); @@ -226,9 +219,6 @@ mod tests { let other = Hid::random(HidPrefix::Wbee); let (_pubkey_hex, signed_at, sig_hex) = key.sign_socket_hello(1_700_000_000_000); - - // Someone claiming another bee's hid with this signature must fail — - // otherwise a bee could replay a signature to adopt someone's identity. let msg = socket_hello_message(&other, signed_at); let sig_bytes = hex::decode(&sig_hex).expect("sig hex"); let mut sig_arr = [0u8; 64]; @@ -257,10 +247,6 @@ mod tests { sig_arr.copy_from_slice(&sig_bytes); let signature = ed25519_dalek::Signature::from_bytes(&sig_arr); let verifying = key.signing.verifying_key(); - - // Re-binding the same signature to any other timestamp fails. That is - // the property that makes the skew window enforceable: an attacker - // holding a valid hello cannot slide its clock forward or back. for other in [1_700_000_000_001, 1_600_000_000_000, 0] { let msg = socket_hello_message(&key.hid, other); assert!( diff --git a/hum-paths/src/lib.rs b/hum-paths/src/lib.rs index af30be2..b259078 100644 --- a/hum-paths/src/lib.rs +++ b/hum-paths/src/lib.rs @@ -16,17 +16,19 @@ use serde::{Deserialize, Serialize}; /// Panics if `HOME` is unset, which is always a configuration error. pub fn init() { let home = home(); - xdg_default("XDG_STATE_HOME", home.join(".local/state")); + xdg_default("XDG_STATE_HOME", home.join(".local/state")); xdg_default("XDG_CONFIG_HOME", home.join(".config")); - xdg_default("XDG_DATA_HOME", home.join(".local/share")); - xdg_default("XDG_CACHE_HOME", home.join(".cache")); + xdg_default("XDG_DATA_HOME", home.join(".local/share")); + xdg_default("XDG_CACHE_HOME", home.join(".cache")); xdg_default("XDG_RUNTIME_DIR", home.join(".local/state/run")); } fn xdg_default(var: &str, default: PathBuf) { if std::env::var_os(var).is_none() { // Safety: single-threaded startup; no other threads reading env yet. - unsafe { std::env::set_var(var, default); } + unsafe { + std::env::set_var(var, default); + } } } @@ -94,22 +96,34 @@ pub fn claude_session_dir(cwd_hash: &str) -> PathBuf { /// [`thrum_sock_resolved`] instead so they honor whatever path humd /// actually published in `runtime.json`. pub fn thrum_sock() -> PathBuf { - if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } - if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { + return PathBuf::from(p); + } + if let Some(p) = std::env::var_os("HUM_SOCKET") { + return PathBuf::from(p); + } state_dir().join(THRUM_SOCK_BASENAME) } /// What clients (bees, CLI) should connect to. Honors humd's /// rendezvous file first, then env overrides, then the default. pub fn thrum_sock_resolved() -> PathBuf { - if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { return PathBuf::from(p); } - if let Some(p) = std::env::var_os("HUM_SOCKET") { return PathBuf::from(p); } - if let Some(rt) = RuntimeInfo::read() { return rt.socket; } + if let Some(p) = std::env::var_os("HUM_THRUM_SOCK") { + return PathBuf::from(p); + } + if let Some(p) = std::env::var_os("HUM_SOCKET") { + return PathBuf::from(p); + } + if let Some(rt) = RuntimeInfo::read() { + return rt.socket; + } state_dir().join(THRUM_SOCK_BASENAME) } /// humd HTTP control socket. -pub fn http_sock() -> PathBuf { runtime_dir().join(HTTP_SOCK_BASENAME) } +pub fn http_sock() -> PathBuf { + runtime_dir().join(HTTP_SOCK_BASENAME) +} /// Kademlia routing table, restored on boot. pub fn routing_json() -> PathBuf { @@ -117,13 +131,19 @@ pub fn routing_json() -> PathBuf { } /// Penny lifetime counters. -pub fn penny() -> PathBuf { runtime_dir().join(PENNY_BASENAME) } +pub fn penny() -> PathBuf { + runtime_dir().join(PENNY_BASENAME) +} /// humd ed25519 identity seed. -pub fn humd_key() -> PathBuf { state_dir().join(HUMD_KEY_BASENAME) } +pub fn humd_key() -> PathBuf { + state_dir().join(HUMD_KEY_BASENAME) +} /// Directory holding per-bee ed25519 identity seeds. -pub fn bees_dir() -> PathBuf { state_dir().join("bees") } +pub fn bees_dir() -> PathBuf { + state_dir().join("bees") +} /// Per-bee ed25519 identity seed; one file per hive kind. pub fn bee_key(kind: &str) -> PathBuf { @@ -131,16 +151,22 @@ pub fn bee_key(kind: &str) -> PathBuf { } /// Live bee manifest snapshot (written by daemon on every register/disconnect). -pub fn bees_snapshot() -> PathBuf { state_dir().join(BEES_SNAPSHOT_BASENAME) } +pub fn bees_snapshot() -> PathBuf { + state_dir().join(BEES_SNAPSHOT_BASENAME) +} /// Rendezvous file: running daemon publishes its socket path, pid, and version here. -pub fn runtime_info() -> PathBuf { state_dir().join(RUNTIME_INFO_BASENAME) } +pub fn runtime_info() -> PathBuf { + state_dir().join(RUNTIME_INFO_BASENAME) +} /// Bee identities the operator has withdrawn. Checked at hello time, so a /// revoked bee is refused the socket rather than merely hidden from /// discovery. Lives here rather than in humd so `humctl` can edit the list /// without the daemon's dependency tree. -pub fn revoked_bees() -> PathBuf { state_dir().join(REVOKED_BEES_BASENAME) } +pub fn revoked_bees() -> PathBuf { + state_dir().join(REVOKED_BEES_BASENAME) +} /// Withdrawn bee identities, as stored on disk. /// @@ -187,14 +213,9 @@ impl Revocations { pub fn write_to(&self, path: &std::path::Path) -> std::io::Result<()> { if let Some(parent) = path.parent() { - if !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; - } + std::fs::create_dir_all(parent)?; } let tmp = path.with_extension("json.tmp"); - // Stamp on write rather than trusting the caller to carry a - // version, so a hand-edited or CLI-built list is never written - // claiming to be a format it predates. let mut body = self.clone(); body.version = 1; let body = serde_json::to_string_pretty(&body) @@ -263,16 +284,24 @@ impl RuntimeInfo { } /// `hum.json` (daemon policy). -pub fn hum_json() -> PathBuf { config_dir().join(HUM_JSON_BASENAME) } +pub fn hum_json() -> PathBuf { + config_dir().join(HUM_JSON_BASENAME) +} /// `peers.json` (ensemble peer list). -pub fn peers_json() -> PathBuf { config_dir().join(PEERS_JSON_BASENAME) } +pub fn peers_json() -> PathBuf { + config_dir().join(PEERS_JSON_BASENAME) +} /// Drift rings directory (`drift/YYYY-MM-DD.ndjson`). -pub fn drift_dir() -> PathBuf { state_dir().join("drift") } +pub fn drift_dir() -> PathBuf { + state_dir().join("drift") +} /// thehum chi-log directory (`thehum/YYYY-MM-DD.ndjson` + seq.bin + snapshots/). -pub fn thehum_dir() -> PathBuf { state_dir().join("thehum") } +pub fn thehum_dir() -> PathBuf { + state_dir().join("thehum") +} pub const THEHUM_SEQ_BASENAME: &str = "seq.bin"; pub const THEHUM_SNAPSHOTS_SUBDIR: &str = "snapshots"; @@ -280,46 +309,72 @@ pub const THEHUM_ROOT_BASENAME: &str = "root.txt"; pub const THEHUM_NDJSON_EXT: &str = "ndjson"; /// `/seq.bin` — last-persisted seq counter for a thehum at `dir`. -pub fn thehum_seq_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SEQ_BASENAME) } +pub fn thehum_seq_file(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_SEQ_BASENAME) +} /// `/snapshots` — snapshot store inside a thehum at `dir`. -pub fn thehum_snapshots_dir(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_SNAPSHOTS_SUBDIR) } +pub fn thehum_snapshots_dir(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_SNAPSHOTS_SUBDIR) +} /// `/root.txt` — most recent state-root hex. -pub fn thehum_root_file(dir: &std::path::Path) -> PathBuf { dir.join(THEHUM_ROOT_BASENAME) } +pub fn thehum_root_file(dir: &std::path::Path) -> PathBuf { + dir.join(THEHUM_ROOT_BASENAME) +} /// Cloned hum source tree (recipes + hive installers). -pub fn src_dir() -> PathBuf { data_dir().join("src") } +pub fn src_dir() -> PathBuf { + data_dir().join("src") +} /// `$HOME/.local` — the base for HOME-anchored installs that aren't /// resolved through XDG (cargo install --root, .local/bin, etc). -pub fn local_dir() -> PathBuf { home().join(".local") } +pub fn local_dir() -> PathBuf { + home().join(".local") +} /// `$HOME/.local/bin` — where installed hum binaries live. -pub fn local_bin_dir() -> PathBuf { local_dir().join("bin") } +pub fn local_bin_dir() -> PathBuf { + local_dir().join("bin") +} /// Installed humd binary location. -pub fn humd_bin() -> PathBuf { local_bin_dir().join("humd") } +pub fn humd_bin() -> PathBuf { + local_bin_dir().join("humd") +} /// fnm-managed node binary (when fnm is the user's node manager). /// Probed as a fallback after PATH + /usr/local/bin/node. -pub fn fnm_node_bin() -> PathBuf { local_dir().join("share/fnm/aliases/default/bin/node") } +pub fn fnm_node_bin() -> PathBuf { + local_dir().join("share/fnm/aliases/default/bin/node") +} /// `~/.claude` — Claude CLI's data dir. Read by the claude-cli graft for /// transcript replay; we don't write here. -pub fn claude_data_dir() -> PathBuf { home().join(".claude") } +pub fn claude_data_dir() -> PathBuf { + home().join(".claude") +} /// Installed-binary location for a given hum binary name. -pub fn hum_bin(name: &str) -> PathBuf { local_bin_dir().join(name) } +pub fn hum_bin(name: &str) -> PathBuf { + local_bin_dir().join(name) +} /// Cache dir for a foreign hive clone (org/repo/branch). pub fn foreign_hive_cache(org: &str, repo: &str, branch: &str) -> PathBuf { - cache_dir().join("hives").join(format!("{org}-{repo}-{branch}")) + cache_dir() + .join("hives") + .join(format!("{org}-{repo}-{branch}")) } /// orch.d directory — one .orch file per registered hive. -pub fn orch_d_dir() -> PathBuf { config_dir().join("orch.d") } +pub fn orch_d_dir() -> PathBuf { + config_dir().join("orch.d") +} /// Aggregate Orchfile — orchd reads this; we rebuild it from orch.d. -pub fn orchfile() -> PathBuf { config_dir().join(ORCHFILE_BASENAME) } +pub fn orchfile() -> PathBuf { + config_dir().join(ORCHFILE_BASENAME) +} /// Per-bee config file for a given hive kind (e.g. `ollama-server.json`). pub fn bee_config(kind: &str) -> PathBuf { @@ -342,7 +397,9 @@ pub fn daemon_logs(name: &str) -> DaemonLogs { } } #[cfg(not(target_os = "macos"))] - DaemonLogs::Journald { unit: name.to_string() } + DaemonLogs::Journald { + unit: name.to_string(), + } } // ── helpers ─────────────────────────────────────────────────────────────────── @@ -366,8 +423,12 @@ pub fn home() -> PathBuf { /// expansion across the workspace. pub fn expand_tilde(p: &std::path::Path) -> PathBuf { let s = p.to_string_lossy(); - if let Some(rest) = s.strip_prefix("~/") { return home().join(rest); } - if s == "~" { return home(); } + if let Some(rest) = s.strip_prefix("~/") { + return home().join(rest); + } + if s == "~" { + return home(); + } p.to_path_buf() } diff --git a/humctl/src/main.rs b/humctl/src/main.rs index 1924ede..ee5afdb 100644 --- a/humctl/src/main.rs +++ b/humctl/src/main.rs @@ -4,7 +4,7 @@ use std::process::{Command, ExitCode}; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use service_manager::{ ServiceLabel, ServiceLevel, ServiceManager, ServiceStartCtx, ServiceStopCtx, }; diff --git a/humd/src/lib.rs b/humd/src/lib.rs index a5bde8c..5e40483 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -1,4 +1,3 @@ - use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -8,23 +7,23 @@ use anyhow::Result; use ensemble::{Ensemble, Hid, HumdKey, PeerCapabilities}; use parking_lot::RwLock; use serde_json::Value; -use thrumd::{serve_with_hook as thrum_serve_with_hook, Thrum, Tone, ToneSink}; use thrum_core::{Chi, WaneTracker}; +use thrumd::{Thrum, Tone, ToneSink, serve_with_hook as thrum_serve_with_hook}; use tracing::{error, info, trace, warn}; -mod drone; mod drift; +mod drone; mod identity; mod peer_transport; pub mod peers; mod penny; pub mod redial; -mod routing_store; pub mod revoked; +mod routing_store; pub mod supervisor; pub mod thrumd; pub use identity::{key_path, load_or_mint_key, read_key}; -pub use peers::{peers_path, PeerConfig}; +pub use peers::{PeerConfig, peers_path}; type Observers = Arc>>>; @@ -117,7 +116,12 @@ where "humd.sockets" ); - if let Ok(addr) = cfg.hum_cfg.humd.metrics_addr.parse::() { + if let Ok(addr) = cfg + .hum_cfg + .humd + .metrics_addr + .parse::() + { match metrics_exporter_prometheus::PrometheusBuilder::new() .with_http_listener(addr) .install() @@ -130,9 +134,14 @@ where } let penny = penny::Penny::load(&cfg.penny_path); - penny.clone().spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); + penny + .clone() + .spawn_persister(cfg.penny_path.clone(), cfg.penny_persist_interval); - let waneman = cfg.waneman.clone().unwrap_or_else(|| Arc::new(WaneTracker::new())); + let waneman = cfg + .waneman + .clone() + .unwrap_or_else(|| Arc::new(WaneTracker::new())); let _drift = drift::Drift::with_store_dir(hum_paths::drift_dir()); let _drone = drone::Drone::new(); @@ -164,7 +173,11 @@ where if let Some(ens) = &ensemble_opt { routing_store::restore_on_boot(ens, &cfg.routing_path); - routing_store::spawn_persister(ens.clone(), cfg.routing_path.clone(), cfg.routing_persist_interval); + routing_store::spawn_persister( + ens.clone(), + cfg.routing_path.clone(), + cfg.routing_persist_interval, + ); } let mut peer_reach: Vec = Vec::new(); @@ -176,7 +189,14 @@ where match peer_transport::iroh::bind(key).await { Ok((transport, hints)) => { let transport = Arc::new(transport); - peer_transport::iroh::dial_all(&transport, ens, key, &cfg.bootstrap_peers, &my_caps).await; + peer_transport::iroh::dial_all( + &transport, + ens, + key, + &cfg.bootstrap_peers, + &my_caps, + ) + .await; peer_transport::iroh::spawn_listener( transport.clone(), ens.clone(), @@ -189,8 +209,21 @@ where Err(e) => warn!(err = %e, "peer.iroh.bind_failed"), } - if let Some(addr) = cfg.hum_cfg.humd.tcp_listen.as_deref().filter(|s| !s.is_empty()) { - match peer_transport::tcp::spawn_listener(addr, ens.clone(), key.clone(), my_caps.clone()).await { + if let Some(addr) = cfg + .hum_cfg + .humd + .tcp_listen + .as_deref() + .filter(|s| !s.is_empty()) + { + match peer_transport::tcp::spawn_listener( + addr, + ens.clone(), + key.clone(), + my_caps.clone(), + ) + .await + { Ok((_bound, hints)) => peer_reach.extend(hints), Err(e) => warn!(addr, err = %e, "peer.tcp.bind_failed"), } @@ -229,37 +262,65 @@ where let observers: Observers = Arc::new(RwLock::new(HashMap::new())); let hive_tag = cfg.hum_cfg.nest.default.clone(); let manifests: Manifests = Arc::new(parking_lot::RwLock::new(HashMap::new())); - let remote_hives: RemoteHives = - Arc::new(parking_lot::RwLock::new(HashMap::new())); + let remote_hives: RemoteHives = Arc::new(parking_lot::RwLock::new(HashMap::new())); if let Some(thehum) = thehum_handle.as_ref() { let manifests_for_replay = manifests.clone(); if let Err(e) = thehum.replay(|event| { let body = &event.body; - let client_id = body.get("client_id").and_then(Value::as_str) + let client_id = body + .get("client_id") + .and_then(Value::as_str) .or_else(|| body.get("nestlerId").and_then(Value::as_str)) .or_else(|| body.get("from").and_then(Value::as_str)) .map(str::to_string); let Some(client_id) = client_id else { return }; match event.chi.as_str() { "hello" => { - let name = body.get("hive").and_then(Value::as_str) + let name = body + .get("hive") + .and_then(Value::as_str) .or_else(|| body.get("from").and_then(Value::as_str)) .unwrap_or(&client_id) .to_string(); - let version = body.get("version").and_then(Value::as_str) - .unwrap_or("0.0.0").to_string(); - let proto = body.get("protoVersion").and_then(Value::as_str) - .unwrap_or(thrum_core::THRUM_VERSION).to_string(); + let version = body + .get("version") + .and_then(Value::as_str) + .unwrap_or("0.0.0") + .to_string(); + let proto = body + .get("protoVersion") + .and_then(Value::as_str) + .unwrap_or(thrum_core::THRUM_VERSION) + .to_string(); let mut manifest = ensemble::HiveManifest::new(name, version, proto); - manifest.bee = body.get("bee").and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + manifest.bee = body + .get("bee") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - manifest.models = body.get("models").and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + manifest.models = body + .get("models") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - manifest.hid = body.get("hid").and_then(Value::as_str) + manifest.hid = body + .get("hid") + .and_then(Value::as_str) .and_then(|s| ensemble::Hid::from_hex(s).ok()); - manifest.nestler_id = body.get("nestlerId").and_then(Value::as_str).map(str::to_string); + manifest.nestler_id = body + .get("nestlerId") + .and_then(Value::as_str) + .map(str::to_string); manifests_for_replay.write().insert(client_id, manifest); } "disconnect" | "forget" => { @@ -312,7 +373,11 @@ where let mut seen = ens.hive_discover_all(); while let Some((humd_id, manifest)) = seen.recv().await { let key = bee_key(&manifest); - remote.write().entry(humd_id).or_default().insert(key, manifest); + remote + .write() + .entry(humd_id) + .or_default() + .insert(key, manifest); } }); } @@ -392,7 +457,11 @@ where } if now_ms.saturating_sub(last_retention_ms) >= 3_600_000 { match thehum.enforce_retention() { - Ok(r) => trace!(removed = r.removed_files, kept = r.kept_files, "thehum.retention.ok"), + Ok(r) => trace!( + removed = r.removed_files, + kept = r.kept_files, + "thehum.retention.ok" + ), Err(e) => warn!(err = %e, "thehum.retention.failed"), } last_retention_ms = now_ms; @@ -437,7 +506,8 @@ async fn autoupdate_check_once() -> Result { let body = tokio::process::Command::new("curl") .args([ "-fsSL", - "-H", "Accept: application/vnd.github+json", + "-H", + "Accept: application/vnd.github+json", "https://api.github.com/repos/adiled/hum/releases/latest", ]) .output() @@ -446,7 +516,8 @@ async fn autoupdate_check_once() -> Result { anyhow::bail!("github releases fetch failed: {}", body.status); } let body = String::from_utf8(body.stdout)?; - let upstream = parse_tag_name(&body).ok_or_else(|| anyhow::anyhow!("no tag_name in response"))?; + let upstream = + parse_tag_name(&body).ok_or_else(|| anyhow::anyhow!("no tag_name in response"))?; let upstream_trim = upstream.trim_start_matches('v'); if upstream_trim == local { return Ok(false); @@ -564,9 +635,14 @@ impl HumdSink { serde_json::to_vec_pretty(&*m).unwrap_or_default() }; let path = &self.bees_snapshot_path; - if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } let tmp = path.with_extension("json.tmp"); - if std::fs::write(&tmp, &json).and_then(|_| std::fs::rename(&tmp, path)).is_err() { + if std::fs::write(&tmp, &json) + .and_then(|_| std::fs::rename(&tmp, path)) + .is_err() + { trace!(path = %path.display(), "bees.snapshot.write.failed"); } } @@ -575,9 +651,13 @@ impl HumdSink { #[async_trait::async_trait] impl ToneSink for HumdSink { async fn forget(&self, client_id: &str) { - if client_id == "ensemble" { return; } + if client_id == "ensemble" { + return; + } let had_manifest = self.manifests.write().remove(client_id).is_some(); - self.tool_routes.write().retain(|_, originator| originator != client_id); + self.tool_routes + .write() + .retain(|_, originator| originator != client_id); if had_manifest { trace!(client_id, "manifest.evict.disconnect"); self.snapshot_bees(); @@ -594,10 +674,17 @@ impl ToneSink for HumdSink { if let Some(thehum) = self.thehum.as_ref() { if client_id != "ensemble" { let sid = tone.get("sid").and_then(Value::as_str).and_then(|s| { - hum_identity::HumId::parse(s).ok().or_else(|| Some(hum_identity::HumId::from_foreign(s))) + hum_identity::HumId::parse(s) + .ok() + .or_else(|| Some(hum_identity::HumId::from_foreign(s))) }); - let rid = tone.get("rid").and_then(Value::as_str) - .map(|s| hum_identity::HumId::parse(s).unwrap_or_else(|_| hum_identity::HumId::from_foreign(s))) + let rid = tone + .get("rid") + .and_then(Value::as_str) + .map(|s| { + hum_identity::HumId::parse(s) + .unwrap_or_else(|_| hum_identity::HumId::from_foreign(s)) + }) .unwrap_or_else(hum_identity::HumId::mint); let body = serde_json::to_value(&tone).unwrap_or_default(); if let Err(e) = thehum.append(chi_str, sid, rid, body).await { @@ -607,26 +694,34 @@ impl ToneSink for HumdSink { } if client_id == "ensemble" && matches!(chi, Some(Chi::ToolCall)) { - let tool_name = tone.get("toolName").and_then(Value::as_str) + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); if let Some(tool_name) = tool_name { let forager_cid = { let m = self.manifests.read(); - m.iter().find(|(_, man)| { - man.bee.iter().any(|b| b == "forager") - && man.tools.iter().any(|t| t.name == tool_name) - }).map(|(cid, _)| cid.clone()) + m.iter() + .find(|(_, man)| { + man.bee.iter().any(|b| b == "forager") + && man.tools.iter().any(|t| t.name == tool_name) + }) + .map(|(cid, _)| cid.clone()) }; if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); - let from_peer = tone.get("from").and_then(Value::as_str) + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let from_peer = tone + .get("from") + .and_then(Value::as_str) .and_then(parse_humd_id); - if let (Some(call_id), Some(from_peer)) = ( - (!call_id.is_empty()).then_some(call_id.clone()), - from_peer, - ) { + if let (Some(call_id), Some(from_peer)) = + ((!call_id.is_empty()).then_some(call_id.clone()), from_peer) + { self.incoming_tool_calls.write().insert(call_id, from_peer); } trace!( @@ -645,33 +740,48 @@ impl ToneSink for HumdSink { if !matches!(chi, Some(Chi::Hello)) { let bee_kind: Vec = { let m = self.manifests.read(); - m.get(client_id).map(|man| man.bee.clone()).unwrap_or_default() + m.get(client_id) + .map(|man| man.bee.clone()) + .unwrap_or_default() }; let is_worker = bee_kind.iter().any(|b| b == "worker"); let _ = &is_worker; - if !is_worker - && matches!(chi, Some(Chi::ToolCall)) - && client_id != "ensemble" - { - let sid = tone.get("sid").and_then(Value::as_str).map(str::to_string).unwrap_or_default(); - let tool_name = tone.get("toolName").and_then(Value::as_str) + if !is_worker && matches!(chi, Some(Chi::ToolCall)) && client_id != "ensemble" { + let sid = tone + .get("sid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); if !sid.is_empty() { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); } if let Some(tn) = tool_name.as_ref() { - let forager_cid: Option = self.manifests.read() + let forager_cid: Option = self + .manifests + .read() .iter() - .find(|(_, m)| m.bee.iter().any(|b| b == "forager") - && m.tools.iter().any(|t| &t.name == tn)) + .find(|(_, m)| { + m.bee.iter().any(|b| b == "forager") + && m.tools.iter().any(|t| &t.name == tn) + }) .map(|(cid, _)| cid.clone()); if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert(call_id.clone(), client_id.to_string()); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); } trace!(from = client_id, to = %fcid, %tn, %call_id, "tool-call.route.from-asker"); self.thrum.thrum_to(&fcid, tone); @@ -681,13 +791,21 @@ impl ToneSink for HumdSink { } if is_worker { if matches!(chi, Some(Chi::ToolCall)) { - let sid_for_lookup = tone.get("sid").and_then(Value::as_str) - .map(str::to_string).unwrap_or_default(); + let sid_for_lookup = tone + .get("sid") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_default(); if !sid_for_lookup.is_empty() { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid_for_lookup, &self.hive_tag)); + self.thrum.claim_sigil( + client_id, + &thrum_core::sigil(&sid_for_lookup, &self.hive_tag), + ); self.thrum.claim_sigil(client_id, &sid_for_lookup); } - let tool_name = tone.get("toolName").and_then(Value::as_str) + let tool_name = tone + .get("toolName") + .and_then(Value::as_str) .or_else(|| tone.get("name").and_then(Value::as_str)) .map(str::to_string); let pinned_fs = self.sid_fs.read().get(&sid_for_lookup).copied(); @@ -696,15 +814,18 @@ impl ToneSink for HumdSink { (pinned_fs, &self.ensemble, tool_name.as_ref()) { if fs_hid != ens.me() { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert( - call_id.clone(), client_id.to_string() - ); - self.tool_routes_peer.write().insert( - call_id.clone(), fs_hid - ); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); + self.tool_routes_peer + .write() + .insert(call_id.clone(), fs_hid); } let mut routed = tone.clone(); if let Some(obj) = routed.as_object_mut() { @@ -726,18 +847,23 @@ impl ToneSink for HumdSink { if let Some(tool_name) = tool_name { let forager_cid = { let m = self.manifests.read(); - m.iter().find(|(_, man)| { - man.bee.iter().any(|b| b == "forager") - && man.tools.iter().any(|t| t.name == tool_name) - }).map(|(cid, _)| cid.clone()) + m.iter() + .find(|(_, man)| { + man.bee.iter().any(|b| b == "forager") + && man.tools.iter().any(|t| t.name == tool_name) + }) + .map(|(cid, _)| cid.clone()) }; if let Some(fcid) = forager_cid { - let call_id = tone.get("callId").and_then(Value::as_str) - .unwrap_or("").to_string(); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if !call_id.is_empty() { - self.tool_routes.write().insert( - call_id.clone(), client_id.to_string() - ); + self.tool_routes + .write() + .insert(call_id.clone(), client_id.to_string()); } trace!( from = client_id, to = %fcid, %tool_name, %call_id, @@ -749,10 +875,16 @@ impl ToneSink for HumdSink { } } if let Some(sid) = tone.get("sid").and_then(Value::as_str).map(str::to_string) { - if matches!(chi, - Some(Chi::Chunk) | Some(Chi::Finish) | Some(Chi::Error) - | Some(Chi::ToolCall) | Some(Chi::ToolInfo) | Some(Chi::SessionReady) - | Some(Chi::Pulse) | Some(Chi::Breath) + if matches!( + chi, + Some(Chi::Chunk) + | Some(Chi::Finish) + | Some(Chi::Error) + | Some(Chi::ToolCall) + | Some(Chi::ToolInfo) + | Some(Chi::SessionReady) + | Some(Chi::Pulse) + | Some(Chi::Breath) ) { if let Some(ens) = &self.ensemble { let obs = self.observers.read().get(&sid).cloned().unwrap_or_default(); @@ -778,7 +910,8 @@ impl ToneSink for HumdSink { warn!(err = %e, "worker.reply.ensemble.failed"); } } - self.thrum.thrum_broadcast(&sid, &self.hive_tag, tone.clone()); + self.thrum + .thrum_broadcast(&sid, &self.hive_tag, tone.clone()); return; } } @@ -788,9 +921,13 @@ impl ToneSink for HumdSink { if matches!(chi, Some(Chi::Attach)) && client_id != "ensemble" { if let Some(sid) = tone.get("sid").and_then(Value::as_str) { if !sid.is_empty() { - self.thrum.claim_sigil(client_id, thrum_core::sigil(sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, thrum_core::sigil(sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, sid.to_string()); - let hear_only = tone.get("hearOnly").and_then(Value::as_bool).unwrap_or(false); + let hear_only = tone + .get("hearOnly") + .and_then(Value::as_bool) + .unwrap_or(false); trace!(client_id, sid, hear_only, "attach.local.claimed"); } } @@ -874,7 +1011,11 @@ impl ToneSink for HumdSink { ); self.thrum.thrum_to( client_id, - thrumd::echo_tone(&rid, false, Some("protoVersion incompatible")), + thrumd::echo_tone( + &rid, + false, + Some("protoVersion incompatible"), + ), ); self.thrum.thrum_close(client_id); return; @@ -887,41 +1028,65 @@ impl ToneSink for HumdSink { self.thrum.thrum_to(client_id, breath); let bee: Vec = match tone.get("bee") { - Some(Value::Array(arr)) => arr.iter() + Some(Value::Array(arr)) => arr + .iter() .filter_map(Value::as_str) .map(str::to_string) .collect(), Some(Value::String(s)) => vec![s.clone()], _ => Vec::new(), }; - let models: Vec = tone.get("models") + let models: Vec = tone + .get("models") .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); - let name = tone.get("hive").and_then(Value::as_str) + let name = tone + .get("hive") + .and_then(Value::as_str) .map(str::to_string) .or_else(|| tone.get("from").and_then(Value::as_str).map(str::to_string)); if let Some(name) = name { - let proto = tone.get("protoVersion").and_then(Value::as_str) - .unwrap_or(thrum_core::THRUM_VERSION).to_string(); - let version = tone.get("version").and_then(Value::as_str) - .unwrap_or("0.0.0").to_string(); - let propensity = tone.get("propensity") + let proto = tone + .get("protoVersion") + .and_then(Value::as_str) + .unwrap_or(thrum_core::THRUM_VERSION) + .to_string(); + let version = tone + .get("version") + .and_then(Value::as_str) + .unwrap_or("0.0.0") + .to_string(); + let propensity = tone + .get("propensity") .and_then(|v| serde_json::from_value(v.clone()).ok()) .unwrap_or_default(); - let chis: Vec = tone.get("chis") + let chis: Vec = tone + .get("chis") .or_else(|| tone.get("chi")) .and_then(|v| v.as_array()) - .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) .unwrap_or_default(); - // A bee that declared its chis gets only those from - // broadcasts; one that declared none still gets - // everything, so bees predating this are unaffected. self.thrum.claim_chis(client_id, chis.clone()); - let source = tone.get("source").and_then(Value::as_str).map(str::to_string); - let bind: Option = tone.get("bind") + let source = tone + .get("source") + .and_then(Value::as_str) + .map(str::to_string); + let bind: Option = tone + .get("bind") .and_then(|v| serde_json::from_value(v.clone()).ok()); - let nestler_id = tone.get("nestlerId").and_then(Value::as_str) + let nestler_id = tone + .get("nestlerId") + .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| client_id.to_string()); let mut manifest = ensemble::HiveManifest::new(name, version, proto); @@ -933,10 +1098,6 @@ impl ToneSink for HumdSink { manifest.bee = bee.clone(); manifest.models = models.clone(); let raw_hid = tone.get("hid").and_then(Value::as_str); - // A bee that signs its hello proves the declared hid is - // its own key. One that doesn't is admitted as before — - // this binds identity where identity is offered, without - // breaking every hive that hasn't caught up. match ensemble::parse_socket_identity(&tone) { ensemble::SocketIdentity::Verified(hid) => { manifest.hid = Some(hid); @@ -957,16 +1118,21 @@ impl ToneSink for HumdSink { Derive a stable hid from a persisted key (see hum-identity)."); } (None, _) => { - warn!(client_id, + warn!( + client_id, "bee.hid.missing — hello has no hid; reconnect dedup disabled. \ - Ghost manifests will accumulate on reconnect."); + Ghost manifests will accumulate on reconnect." + ); } } } ensemble::SocketIdentity::Invalid(why) => { - error!(client_id, reason = why, + error!( + client_id, + reason = why, "bee.hid.bad-signature — hello signed its hid and the signature failed. \ - Not registered, not announced."); + Not registered, not announced." + ); self.thrum.thrum_to( client_id, thrumd::echo_tone(&rid, false, Some("hid signature invalid")), @@ -975,10 +1141,6 @@ impl ToneSink for HumdSink { return; } } - // A revoked hid is refused the socket, not merely hidden - // from discovery. Checked whether or not the bee proved - // the hid: one that dropped its signature to slip past - // this would be claiming the same revoked string. if let Some(hid) = manifest.hid { if self.revoked.is_revoked(&hid.to_hex()) { error!(client_id, hid = %hid.short(), @@ -993,16 +1155,28 @@ impl ToneSink for HumdSink { } } if let Some(arr) = tone.get("tools").and_then(Value::as_array) { - manifest.tools = arr.iter().filter_map(|v| { - let name = v.get("name").and_then(Value::as_str)?.to_string(); - let description = v.get("description").and_then(Value::as_str) - .unwrap_or("").to_string(); - let input_schema = v.get("inputSchema").cloned().unwrap_or(Value::Null); - Some(ensemble::ToolEntry { name, description, input_schema }) - }).collect(); + manifest.tools = arr + .iter() + .filter_map(|v| { + let name = v.get("name").and_then(Value::as_str)?.to_string(); + let description = v + .get("description") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); + let input_schema = + v.get("inputSchema").cloned().unwrap_or(Value::Null); + Some(ensemble::ToolEntry { + name, + description, + input_schema, + }) + }) + .collect(); } if let Some(arr) = tone.get("provides").and_then(Value::as_array) { - manifest.provides = arr.iter() + manifest.provides = arr + .iter() .filter_map(Value::as_str) .map(str::to_string) .collect(); @@ -1025,9 +1199,12 @@ impl ToneSink for HumdSink { if client_id != "ensemble" { let mut m = self.manifests.write(); if let Some(new_hid) = manifest.hid { - let stale: Vec = m.iter() + let stale: Vec = m + .iter() .filter_map(|(cid, mf)| match mf.hid { - Some(h) if h == new_hid && cid != client_id => Some(cid.clone()), + Some(h) if h == new_hid && cid != client_id => { + Some(cid.clone()) + } _ => None, }) .collect(); @@ -1056,21 +1233,25 @@ impl ToneSink for HumdSink { } } Some(Chi::Prompt) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "prompt.no-sid"); return; } - if client_id != "ensemble" - && self.capacity == LocalCapacity::OverflowAlways - { + if client_id != "ensemble" && self.capacity == LocalCapacity::OverflowAlways { if let Some(ensemble) = &self.ensemble { let target = pick_overflow_peer(ensemble, &self.hive_tag); if let Some(peer) = target { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); if let Some(rid) = tone.get("rid").and_then(Value::as_str) { - self.thrum.thrum_to(client_id, thrumd::echo_tone(rid, true, None)); + self.thrum + .thrum_to(client_id, thrumd::echo_tone(rid, true, None)); } let mut forward = tone.clone(); if let Some(obj) = forward.as_object_mut() { @@ -1087,8 +1268,14 @@ impl ToneSink for HumdSink { } } } - let model = tone.get("modelId").and_then(Value::as_str).unwrap_or("sonnet").to_string(); - let cwd_raw = tone.get("cwd").and_then(Value::as_str) + let model = tone + .get("modelId") + .and_then(Value::as_str) + .unwrap_or("sonnet") + .to_string(); + let cwd_raw = tone + .get("cwd") + .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| "/".into()); let cwd = if ensemble::HumUri::starts_with_scheme(&cwd_raw) { @@ -1117,14 +1304,26 @@ impl ToneSink for HumdSink { } else { cwd_raw }; - let system_prompt = tone.get("systemPrompt").and_then(Value::as_str).map(str::to_string); - let text = tone.get("text").and_then(Value::as_str).map(str::to_string) - .or_else(|| tone.get("content").and_then(Value::as_str).map(str::to_string)) + let system_prompt = tone + .get("systemPrompt") + .and_then(Value::as_str) + .map(str::to_string); + let text = tone + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + tone.get("content") + .and_then(Value::as_str) + .map(str::to_string) + }) .unwrap_or_default(); if let Some(rid) = tone.get("rid").and_then(Value::as_str) { - self.thrum.thrum_to(client_id, thrumd::echo_tone(rid, true, None)); + self.thrum + .thrum_to(client_id, thrumd::echo_tone(rid, true, None)); } - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); let origin = if client_id == "ensemble" { tone.get("from") @@ -1164,7 +1363,9 @@ impl ToneSink for HumdSink { }; if !to_prune.is_empty() { let mut m = self.manifests.write(); - for cid in &to_prune { m.remove(cid); } + for cid in &to_prune { + m.remove(cid); + } } pick }; @@ -1184,7 +1385,10 @@ impl ToneSink for HumdSink { "prompt.forward.remote.failed"), } } - warn!(sid, model, "prompt.no-worker — no bee on this mesh advertises this model"); + warn!( + sid, + model, "prompt.no-worker — no bee on this mesh advertises this model" + ); let err = serde_json::json!({ "chi": "error", "sid": sid, @@ -1223,7 +1427,9 @@ impl ToneSink for HumdSink { let mut sorted: Vec<_> = m.iter().collect(); sorted.sort_by(|a, b| a.0.cmp(b.0)); for (_, man) in sorted { - if !man.bee.iter().any(|b| b == "forager") { continue; } + if !man.bee.iter().any(|b| b == "forager") { + continue; + } for t in &man.tools { tools.push(serde_json::json!({ "name": t.name, @@ -1231,7 +1437,9 @@ impl ToneSink for HumdSink { "inputSchema": t.input_schema, })); } - for c in &man.provides { caps.insert(c.clone()); } + for c in &man.provides { + caps.insert(c.clone()); + } } (tools, caps.into_iter().collect()) }; @@ -1239,18 +1447,29 @@ impl ToneSink for HumdSink { obj.insert("foragerTools".into(), Value::Array(forager_tools_json)); } if !provided_caps.is_empty() { - obj.insert("provided".into(), - Value::Array(provided_caps.iter().cloned().map(Value::String).collect())); + obj.insert( + "provided".into(), + Value::Array( + provided_caps.iter().cloned().map(Value::String).collect(), + ), + ); } - let mut disallowed: std::collections::BTreeSet = - obj.get("disallowedTools") - .and_then(Value::as_array) - .map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) - .unwrap_or_default(); + let mut disallowed: std::collections::BTreeSet = obj + .get("disallowedTools") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); for cap in &provided_caps { if let Some(names) = hum_mcp::capability::capability_tools(cap) { - for n in names { disallowed.insert((*n).into()); } + for n in names { + disallowed.insert((*n).into()); + } } } if !disallowed.is_empty() { @@ -1267,7 +1486,9 @@ impl ToneSink for HumdSink { } Some(Chi::Cancel) => { if let Some(sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1280,7 +1501,9 @@ impl ToneSink for HumdSink { } Some(Chi::Cleanup) => { if let Some(_sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1291,14 +1514,24 @@ impl ToneSink for HumdSink { } } Some(Chi::Attach) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "attach.no-sid"); return; } - let hear_only = tone.get("hearOnly").and_then(Value::as_bool).unwrap_or(false); + let hear_only = tone + .get("hearOnly") + .and_then(Value::as_bool) + .unwrap_or(false); if client_id == "ensemble" { - let peer = tone.get("from").and_then(Value::as_str).and_then(parse_humd_id); + let peer = tone + .get("from") + .and_then(Value::as_str) + .and_then(parse_humd_id); if let Some(peer) = peer { let mut obs = self.observers.write(); let list = obs.entry(sid.clone()).or_default(); @@ -1310,7 +1543,8 @@ impl ToneSink for HumdSink { warn!(client_id, sid, "attach.bad-from"); } } else { - self.thrum.claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); + self.thrum + .claim_sigil(client_id, &thrum_core::sigil(&sid, &self.hive_tag)); self.thrum.claim_sigil(client_id, &sid); if let Some(ensemble) = &self.ensemble { if let Some(to) = tone.get("to").and_then(Value::as_str) { @@ -1330,18 +1564,27 @@ impl ToneSink for HumdSink { } } Some(Chi::Detach) => { - let sid = tone.get("sid").and_then(Value::as_str).unwrap_or("").to_string(); + let sid = tone + .get("sid") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); if sid.is_empty() { warn!(client_id, "detach.no-sid"); return; } if client_id == "ensemble" { - let peer = tone.get("from").and_then(Value::as_str).and_then(parse_humd_id); + let peer = tone + .get("from") + .and_then(Value::as_str) + .and_then(parse_humd_id); if let Some(peer) = peer { let mut obs = self.observers.write(); if let Some(list) = obs.get_mut(&sid) { list.retain(|p| *p != peer); - if list.is_empty() { obs.remove(&sid); } + if list.is_empty() { + obs.remove(&sid); + } } trace!(client_id, sid, peer = %peer.short(), "observer.removed"); } @@ -1410,7 +1653,10 @@ impl ToneSink for HumdSink { ); } Some(Chi::Error) => { - let call_id = tone.get("callId").and_then(Value::as_str).map(str::to_string); + let call_id = tone + .get("callId") + .and_then(Value::as_str) + .map(str::to_string); if let Some(cid) = call_id.as_deref() { let origin_peer = self.incoming_tool_calls.read().get(cid).copied(); if let (Some(origin_peer), Some(ens)) = (origin_peer, &self.ensemble) { @@ -1472,7 +1718,11 @@ impl ToneSink for HumdSink { trace!(client_id, "backfill.no-thehum"); return; }; - let author = tone.get("author").and_then(Value::as_str).unwrap_or("").to_string(); + let author = tone + .get("author") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(); let from = tone.get("from").and_then(Value::as_u64).unwrap_or(0); if author.is_empty() { warn!(client_id, "backfill.no-author"); @@ -1496,7 +1746,9 @@ impl ToneSink for HumdSink { } Some(Chi::Curate) => { if let Some(_sid) = tone.get("sid").and_then(Value::as_str) { - let workers: Vec = self.manifests.read() + let workers: Vec = self + .manifests + .read() .iter() .filter(|(_, m)| m.bee.iter().any(|b| b == "worker")) .map(|(cid, _)| cid.clone()) @@ -1545,14 +1797,21 @@ fn pick_overflow_peer(ensemble: &Ensemble, nest_kind: &str) -> Option { let peers = ensemble.peers(); let mut fallback: Option = None; for id in peers { - let Some(caps) = ensemble.peer_caps(&id) else { continue }; + let Some(caps) = ensemble.peer_caps(&id) else { + continue; + }; let has_nest = caps.nests.iter().any(|n| n == nest_kind); - if !has_nest { continue; } + if !has_nest { + continue; + } match caps.free_slots { Some(n) if n > 0 => return Some(id), None => return Some(id), - Some(0) => { /* peer is full, skip but remember as fallback */ - if fallback.is_none() { fallback = Some(id); } + Some(0) => { + /* peer is full, skip but remember as fallback */ + if fallback.is_none() { + fallback = Some(id); + } } _ => {} } diff --git a/humd/src/revoked.rs b/humd/src/revoked.rs index de3a2db..a9db1ce 100644 --- a/humd/src/revoked.rs +++ b/humd/src/revoked.rs @@ -88,4 +88,4 @@ mod tests { let tmp = tempfile::TempDir::new().unwrap(); assert!(Revocations::load(&tmp.path().join("nope.json")).is_empty()); } -} \ No newline at end of file +} diff --git a/humd/src/thrumd.rs b/humd/src/thrumd.rs index 2afa8ed..a90c0c8 100644 --- a/humd/src/thrumd.rs +++ b/humd/src/thrumd.rs @@ -16,10 +16,10 @@ use std::sync::Arc; use anyhow::{Context, Result}; use async_trait::async_trait; +use governor::{Quota, RateLimiter}; use parking_lot::RwLock; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use thrum_core::THRUM_VERSION; -use governor::{Quota, RateLimiter}; use tokio::net::UnixListener; use tracing::{info, trace, warn}; @@ -189,7 +189,10 @@ impl Thrum { /// drains outbound tones bound for this client; `client_id` becomes /// addressable via [`thrum_to`] and [`thrum_broadcast`]. Used by /// sim to fake a connected nestler without a socket. - pub fn register_synthetic(&self, client_id: impl Into) -> tokio::sync::mpsc::Receiver { + pub fn register_synthetic( + &self, + client_id: impl Into, + ) -> tokio::sync::mpsc::Receiver { let cid: String = client_id.into(); let (reach, rx) = registry::Reach::new(cid); let mut reg = self.inner.clients.write(); @@ -202,7 +205,6 @@ impl Thrum { self.inner.clients.write().remove(client_id); } - /// Drop a client from the registry. Any tones already queued for it /// still drain; once the queue empties the socket handler sees the /// channel close and the connection ends. Used to refuse a bee that @@ -239,7 +241,9 @@ pub async fn serve(thrum: Thrum, path: impl AsRef) -> Result<()> { /// before the accept loop starts. humd uses this to publish its /// `runtime.json` rendezvous file. pub async fn serve_with_hook(thrum: Thrum, path: impl AsRef, on_bound: F) -> Result<()> -where F: FnOnce(&Path) + Send + 'static { +where + F: FnOnce(&Path) + Send + 'static, +{ let path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) @@ -248,8 +252,8 @@ where F: FnOnce(&Path) + Send + 'static { if path.exists() { std::fs::remove_file(path).with_context(|| format!("remove stale socket {:?}", path))?; } - let listener = UnixListener::bind(path) - .with_context(|| format!("bind unix socket {:?}", path))?; + let listener = + UnixListener::bind(path).with_context(|| format!("bind unix socket {:?}", path))?; info!(path = %path.display(), version = %THRUM_VERSION, "thrum.listening"); on_bound(path); @@ -340,7 +344,6 @@ pub fn echo_tone(rid: &str, ok: bool, error: Option<&str>) -> Tone { v } - /// How a bee's declared `protoVersion` compares to the one humd speaks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProtoVerdict { diff --git a/humd/src/thrumd/registry.rs b/humd/src/thrumd/registry.rs index 0e39d08..c31b968 100644 --- a/humd/src/thrumd/registry.rs +++ b/humd/src/thrumd/registry.rs @@ -82,7 +82,9 @@ pub(crate) struct Registry { impl Registry { pub(crate) fn new() -> Self { - Self { by_id: HashMap::new() } + Self { + by_id: HashMap::new(), + } } pub(crate) fn insert(&mut self, reach: std::sync::Arc) { @@ -113,7 +115,10 @@ mod tests { #[test] fn a_client_that_declared_nothing_receives_everything() { let (reach, _rx) = Reach::new("unspecified".into()); - assert!(reach.declares_chi("prompt"), "no declared set = no filtering"); + assert!( + reach.declares_chi("prompt"), + "no declared set = no filtering" + ); assert!(reach.declares_chi("tool-call")); } @@ -131,8 +136,6 @@ mod tests { #[test] fn declaring_an_empty_list_is_not_scoped() { - // A hello with `chis: []` means "unspecified", not "wants nothing". - // Every bee that sends the field but knows none is in that boat. let (reach, _rx) = Reach::new("empty".into()); reach.set_chis(Vec::new()); assert!(reach.declares_chi("prompt")); @@ -140,9 +143,6 @@ mod tests { #[test] fn re_declaring_replaces_the_previous_scope() { - // A re-hello that narrows scope must actually narrow, or a bee - // could widen its subscription by saying it once and never - // revoking. let (reach, _rx) = Reach::new("rehello".into()); reach.set_chis(vec!["prompt".into(), "chunk".into()]); reach.set_chis(vec!["prompt".into()]); diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 6131a8d..6b09e35 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -1,19 +1,18 @@ - use std::collections::HashMap; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; -use anyhow::{bail, Result}; +use anyhow::{Result, bail}; use ensemble::{ - hello_tone, Ensemble, Hid, HumdKey, InMemoryEndpoint, LinkCounters, LinkFaults, - PeerCapabilities, + Ensemble, Hid, HumdKey, InMemoryEndpoint, LinkCounters, LinkFaults, PeerCapabilities, + hello_tone, }; +use humd::thrumd::Thrum; use parking_lot::{Mutex, RwLock}; use serde_json::Value; use thrum_core::WaneTracker; -use humd::thrumd::Thrum; use tokio::sync::{mpsc, oneshot}; const CAPACITY_UNLIMITED: usize = usize::MAX; @@ -67,7 +66,11 @@ struct Link { } fn link_key(x: Hid, y: Hid) -> (Hid, Hid) { - if x.to_hex() <= y.to_hex() { (x, y) } else { (y, x) } + if x.to_hex() <= y.to_hex() { + (x, y) + } else { + (y, x) + } } impl Default for Sim { @@ -107,7 +110,8 @@ impl Sim { /// is written before the daemon reads it, matching how an operator /// would have populated it on a previous run. pub async fn spawn_humd_revoking(&self, id: Hid, revoked: &[&str]) -> Arc { - self.spawn_humd_inner_with_revocations(id, true, revoked).await + self.spawn_humd_inner_with_revocations(id, true, revoked) + .await } async fn spawn_humd_inner(&self, id: Hid, trust_remote_workers: bool) -> Arc { @@ -128,17 +132,16 @@ impl Sim { let tmp = std::env::temp_dir().join(format!("sim-humd-{}", id.short())); let _ = std::fs::create_dir_all(&tmp); let penny_path = tmp.join(hum_paths::PENNY_BASENAME); - - // Preload before the daemon constructs its sink, which reads once - // at boot. A stale file from a previous run would otherwise leak - // revocations into an unrelated test. let revoked_path = tmp.join(hum_paths::REVOKED_BEES_BASENAME); let _ = std::fs::remove_file(&revoked_path); if !revoked.is_empty() { let list: Vec = revoked.iter().map(|s| s.to_string()).collect(); - hum_paths::Revocations { version: 1, revoked: list } - .write_to(&revoked_path) - .expect("write revocation list"); + hum_paths::Revocations { + version: 1, + revoked: list, + } + .write_to(&revoked_path) + .expect("write revocation list"); } let initial_capacity = self @@ -192,7 +195,7 @@ impl Sim { self.humds.write().insert(id, sim_humd.clone()); sim_humd - } + } pub async fn await_ready(&self, humd: Hid) -> Result<()> { for _ in 0..200 { @@ -231,12 +234,8 @@ impl Sim { let a_caps = sim_caps(&ha); let b_caps = sim_caps(&hb); - let (a_view, b_view) = InMemoryEndpoint::pair_concrete( - ha.id, - b_caps.clone(), - hb.id, - a_caps.clone(), - ); + let (a_view, b_view) = + InMemoryEndpoint::pair_concrete(ha.id, b_caps.clone(), hb.id, a_caps.clone()); let key = link_key(ha.id, hb.id); self.links.write().insert( key, @@ -294,7 +293,9 @@ impl Sim { trust_remote_workers: true, }; - let shutdown_fut = async move { let _ = shutdown_rx.await; }; + let shutdown_fut = async move { + let _ = shutdown_rx.await; + }; let join = tokio::spawn(humd::run(cfg, shutdown_fut)); let sim_humd = Arc::new(SimHumd { @@ -317,26 +318,31 @@ impl Sim { pub fn wire_signed(&self, a: Hid, b: Hid) -> Result<()> { let humds = self.humds.read(); - let ha = humds.get(&a).cloned() + let ha = humds + .get(&a) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", a.short()))?; - let hb = humds.get(&b).cloned() + let hb = humds + .get(&b) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", b.short()))?; drop(humds); - let a_key = ha.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key — spawn via spawn_humd_with_identity", - a.short() - ))?; - let b_key = hb.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key — spawn via spawn_humd_with_identity", - b.short() - ))?; + let a_key = ha.key.clone().ok_or_else(|| { + anyhow::anyhow!( + "humd {} has no signing key — spawn via spawn_humd_with_identity", + a.short() + ) + })?; + let b_key = hb.key.clone().ok_or_else(|| { + anyhow::anyhow!( + "humd {} has no signing key — spawn via spawn_humd_with_identity", + b.short() + ) + })?; let a_caps = sim_caps(&ha); let b_caps = sim_caps(&hb); - let (a_view, b_view) = InMemoryEndpoint::pair( - ha.id, b_caps.clone(), - hb.id, a_caps.clone(), - ); + let (a_view, b_view) = InMemoryEndpoint::pair(ha.id, b_caps.clone(), hb.id, a_caps.clone()); ha.ensemble.install(a_view, a_caps, &a_key); hb.ensemble.install(b_view, b_caps, &b_key); Ok(()) @@ -344,21 +350,23 @@ impl Sim { pub fn wire_signed_tampered(&self, a: Hid, c: Hid) -> Result<()> { let humds = self.humds.read(); - let ha = humds.get(&a).cloned() + let ha = humds + .get(&a) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", a.short()))?; - let hc = humds.get(&c).cloned() + let hc = humds + .get(&c) + .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", c.short()))?; drop(humds); - let a_key = ha.key.clone().ok_or_else(|| anyhow::anyhow!( - "humd {} has no signing key", a.short() - ))?; + let a_key = ha + .key + .clone() + .ok_or_else(|| anyhow::anyhow!("humd {} has no signing key", a.short()))?; let a_caps = sim_caps(&ha); let c_caps = sim_caps(&hc); - let (a_view, c_view) = InMemoryEndpoint::pair( - ha.id, c_caps.clone(), - hc.id, a_caps.clone(), - ); + let (a_view, c_view) = InMemoryEndpoint::pair(ha.id, c_caps.clone(), hc.id, a_caps.clone()); ha.ensemble.install(a_view, a_caps, &a_key); @@ -418,12 +426,7 @@ impl Sim { }) } - pub fn link_counters_since( - &self, - a: Hid, - b: Hid, - base: &LinkCounters, - ) -> Result { + pub fn link_counters_since(&self, a: Hid, b: Hid, base: &LinkCounters) -> Result { let (ab, _) = self.link_counters(a, b)?; Ok(ab.since(base)) } @@ -482,7 +485,11 @@ impl Sim { .collect()) } - pub fn evict_expired(&self, observer: Hid, ttl: std::time::Duration) -> Result> { + pub fn evict_expired( + &self, + observer: Hid, + ttl: std::time::Duration, + ) -> Result> { let ens = self .humds .read() @@ -493,7 +500,11 @@ impl Sim { } pub fn peer_count(&self, observer: Hid) -> usize { - self.humds.read().get(&observer).map(|h| h.ensemble.peers().len()).unwrap_or(0) + self.humds + .read() + .get(&observer) + .map(|h| h.ensemble.peers().len()) + .unwrap_or(0) } pub fn partition(&self, a: Hid, b: Hid) -> Result<()> { @@ -560,7 +571,9 @@ impl Sim { .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", humd.short()))?; for _ in 0..200 { - if h.thrum.has_sink() { break; } + if h.thrum.has_sink() { + break; + } tokio::time::sleep(Duration::from_millis(5)).await; } let client_id = hum_identity::HumId::mint().to_string(); @@ -579,10 +592,22 @@ impl Sim { let cid_for_pump = client_id.clone(); tokio::spawn(async move { while let Some(tone) = rx.recv().await { - let chi = tone.get("chi").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if chi != "prompt" { continue; } - let sid = tone.get("sid").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if sid.is_empty() { continue; } + let chi = tone + .get("chi") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if chi != "prompt" { + continue; + } + let sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if sid.is_empty() { + continue; + } let frames = vec![ serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_start","id":0}), serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_delta","delta":"HELLO"}), @@ -615,7 +640,9 @@ impl Sim { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - if sid.is_empty() { continue; } + if sid.is_empty() { + continue; + } let tx = { let mut senders = h_for_pump.sid_senders.lock(); if let Some(tx) = senders.get(&sid) { @@ -639,12 +666,7 @@ impl Sim { Ok(client_id) } - pub async fn nestler_recv( - &self, - humd: Hid, - sid: &str, - timeout: Duration, - ) -> Option { + pub async fn nestler_recv(&self, humd: Hid, sid: &str, timeout: Duration) -> Option { let h = self.humds.read().get(&humd).cloned()?; { @@ -658,33 +680,36 @@ impl Sim { } { - let mut queues = h.out_queues.lock(); - for (_cid, rx) in queues.iter_mut() { - while let Ok(tone) = rx.try_recv() { - let tone_sid = tone - .get("sid") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - if tone_sid.is_empty() { - continue; - } - let senders = h.sid_senders.lock(); - if let Some(tx) = senders.get(&tone_sid) { - let _ = tx.send(tone); - } else { - drop(senders); - let (tx, rx2) = mpsc::unbounded_channel::(); - let _ = tx.send(tone); - h.sid_senders.lock().insert(tone_sid.clone(), tx); - h.sid_mailboxes.lock().insert(tone_sid, rx2); + let mut queues = h.out_queues.lock(); + for (_cid, rx) in queues.iter_mut() { + while let Ok(tone) = rx.try_recv() { + let tone_sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if tone_sid.is_empty() { + continue; + } + let senders = h.sid_senders.lock(); + if let Some(tx) = senders.get(&tone_sid) { + let _ = tx.send(tone); + } else { + drop(senders); + let (tx, rx2) = mpsc::unbounded_channel::(); + let _ = tx.send(tone); + h.sid_senders.lock().insert(tone_sid.clone(), tx); + h.sid_mailboxes.lock().insert(tone_sid, rx2); + } } } } - } let mut rx_opt = h.sid_mailboxes.lock().remove(sid)?; - let result = tokio::time::timeout(timeout, rx_opt.recv()).await.ok().flatten(); + let result = tokio::time::timeout(timeout, rx_opt.recv()) + .await + .ok() + .flatten(); h.sid_mailboxes.lock().insert(sid.to_string(), rx_opt); result } @@ -706,16 +731,23 @@ impl Sim { .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", humd.short()))?; for _ in 0..200 { - if h.thrum.has_sink() { break; } + if h.thrum.has_sink() { + break; + } tokio::time::sleep(Duration::from_millis(5)).await; } let client_id = hum_identity::HumId::mint().to_string(); let mut rx = h.thrum.register_synthetic(client_id.clone()); - let tools: Vec = tool_names.iter().map(|name| serde_json::json!({ - "name": name, - "description": format!("mock {name}"), - "inputSchema": { "type": "object", "properties": {}, "required": [] } - })).collect(); + let tools: Vec = tool_names + .iter() + .map(|name| { + serde_json::json!({ + "name": name, + "description": format!("mock {name}"), + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }) + }) + .collect(); let hello = serde_json::json!({ "chi": "hello", "bee": ["forager"], @@ -731,13 +763,30 @@ impl Sim { let responder = std::sync::Arc::new(responder); tokio::spawn(async move { while let Some(tone) = rx.recv().await { - let chi = tone.get("chi").and_then(|v| v.as_str()).unwrap_or("").to_string(); - if chi != "tool-call" { continue; } - let sid = tone.get("sid").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let call_id = tone.get("callId").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let tool_name = tone.get("toolName").and_then(|v| v.as_str()) + let chi = tone + .get("chi") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + if chi != "tool-call" { + continue; + } + let sid = tone + .get("sid") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let call_id = tone + .get("callId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_name = tone + .get("toolName") + .and_then(|v| v.as_str()) .or_else(|| tone.get("name").and_then(|v| v.as_str())) - .unwrap_or("").to_string(); + .unwrap_or("") + .to_string(); let args = tone.get("args").cloned().unwrap_or(Value::Null); let result = responder(&tool_name, args); let reply = serde_json::json!({ @@ -753,12 +802,7 @@ impl Sim { Ok(client_id) } - pub fn attach_observer( - &self, - observer_humd: Hid, - host_humd: Hid, - sid: &str, - ) -> Result { + pub fn attach_observer(&self, observer_humd: Hid, host_humd: Hid, sid: &str) -> Result { self.nestler_send( observer_humd, serde_json::json!({ @@ -846,12 +890,7 @@ impl Sim { Ok(cid) } - pub async fn publish( - &self, - from: Hid, - topic: &str, - payload: serde_json::Value, - ) -> Result<()> { + pub async fn publish(&self, from: Hid, topic: &str, payload: serde_json::Value) -> Result<()> { let ens = self .humds .read() @@ -875,7 +914,9 @@ impl Sim { .get(&from) .cloned() .ok_or_else(|| anyhow::anyhow!("no humd {}", from.short()))?; - ens.ensemble.publish_with_dusk(topic, payload, Some(dusk_ms)).await; + ens.ensemble + .publish_with_dusk(topic, payload, Some(dusk_ms)) + .await; Ok(()) } @@ -894,7 +935,10 @@ impl Sim { } pub fn expired_dusk(&self, humd: Hid) -> u64 { - self.humds.read().get(&humd).map_or(0, |h| h.ensemble.expired_dusk()) + self.humds + .read() + .get(&humd) + .map_or(0, |h| h.ensemble.expired_dusk()) } pub async fn send_marks(&self, from: Hid, to: Hid, tag: &str, n: usize) -> Result<()> { @@ -912,11 +956,18 @@ impl Sim { } pub fn rids(tones: &[Value]) -> Vec { - tones.iter().filter_map(|t| t["rid"].as_str().map(String::from)).collect() + tones + .iter() + .filter_map(|t| t["rid"].as_str().map(String::from)) + .collect() } pub fn ensemble_dropped(&self, humd: Hid) -> u64 { - self.humds.read().get(&humd).map(|h| h.ensemble.inbox_dropped()).unwrap_or(0) + self.humds + .read() + .get(&humd) + .map(|h| h.ensemble.inbox_dropped()) + .unwrap_or(0) } pub async fn shutdown(self) { diff --git a/sim/tests/chi_scoping.rs b/sim/tests/chi_scoping.rs index ba6f806..1db304d 100644 --- a/sim/tests/chi_scoping.rs +++ b/sim/tests/chi_scoping.rs @@ -66,12 +66,9 @@ async fn a_scoped_client_does_not_receive_an_undeclared_chi() { let cid = hum_identity::HumId::mint().to_string(); let mut rx = humd.thrum.register_synthetic(cid.clone()); - // This client only wants prompts, not chunks. humd.thrum .inject_tone(&cid, base_hello(Some(&["prompt"]))) .await; - - // A chunk arrives from a peer. humd.thrum .inject_tone("ensemble", peer_reply("chunk", "sess-1")) .await; @@ -110,8 +107,6 @@ async fn a_scoped_client_receives_a_declared_chi() { #[tokio::test(flavor = "multi_thread")] async fn a_client_that_declared_nothing_still_receives_everything() { - // The backward-compatibility case. Every existing hive sends no chis - // or an empty list; scoping must not starve them. let (_sim, humd) = spawn().await; let cid = hum_identity::HumId::mint().to_string(); @@ -129,4 +124,4 @@ async fn a_client_that_declared_nothing_still_receives_everything() { "a client that declared no chis must receive a chunk, got {:?}", chis_of(&got) ); -} \ No newline at end of file +} diff --git a/sim/tests/revocation.rs b/sim/tests/revocation.rs index ce9f838..093effa 100644 --- a/sim/tests/revocation.rs +++ b/sim/tests/revocation.rs @@ -50,7 +50,9 @@ async fn a_revoked_bee_is_refused_the_socket() { let cid = hum_identity::HumId::mint().to_string(); let _rx = humd.thrum.register_synthetic(cid.clone()); - humd.thrum.inject_tone(&cid, hello_with_identity(&bee)).await; + humd.thrum + .inject_tone(&cid, hello_with_identity(&bee)) + .await; tokio::time::sleep(Duration::from_millis(150)).await; assert!( @@ -61,9 +63,6 @@ async fn a_revoked_bee_is_refused_the_socket() { #[tokio::test(flavor = "multi_thread")] async fn dropping_the_signature_does_not_evade_revocation() { - // The evasion this exists to close: the bee proved nothing, so it - // might reasonably think the check applies only to proofs. It does - // not — a revoked hid is refused however it is presented. let _ = tracing_subscriber::fmt::try_init(); let sim = sim::Sim::new(); @@ -94,26 +93,24 @@ async fn dropping_the_signature_does_not_evade_revocation() { #[tokio::test(flavor = "multi_thread")] async fn an_unrevoked_bee_is_unaffected() { - // The control: a revocation list that names someone else must not - // lock out a well-behaved bee. let _ = tracing_subscriber::fmt::try_init(); let sim = sim::Sim::new(); let bee = fresh_bee(); let other = fresh_bee(); let id = ensemble::Hid::random_humd(); - let humd = sim - .spawn_humd_revoking(id, &[&other.hid.to_hex()]) - .await; + let humd = sim.spawn_humd_revoking(id, &[&other.hid.to_hex()]).await; sim.await_ready(humd.id).await.expect("humd ready"); let cid = hum_identity::HumId::mint().to_string(); let _rx = humd.thrum.register_synthetic(cid.clone()); - humd.thrum.inject_tone(&cid, hello_with_identity(&bee)).await; + humd.thrum + .inject_tone(&cid, hello_with_identity(&bee)) + .await; tokio::time::sleep(Duration::from_millis(150)).await; assert!( humd.thrum.is_connected(&cid), "a bee absent from the revocation list must be admitted" ); -} \ No newline at end of file +} diff --git a/sim/tests/socket_identity.rs b/sim/tests/socket_identity.rs index a4ecdf0..86ca9ec 100644 --- a/sim/tests/socket_identity.rs +++ b/sim/tests/socket_identity.rs @@ -24,8 +24,6 @@ fn fresh_bee() -> BeeKey { BeeKey { signing, hid } } -/// A structurally valid hello that would pass the protoVersion gate, so a -/// failure can only be the identity check. fn hello_with_identity(tone: &mut serde_json::Value, bee: &BeeKey) { let (pubkey, signed_at, signature) = bee.sign_socket_hello(now_ms()); let obj = tone.as_object_mut().expect("object"); @@ -70,8 +68,6 @@ async fn a_bee_that_signs_its_hid_stays_connected() { #[tokio::test(flavor = "multi_thread")] async fn a_bee_with_no_signature_is_still_admitted() { - // Backward compatibility: every hive today sends no signature. If this - // breaks, every existing bee breaks with it. let _ = tracing_subscriber::fmt::try_init(); let sim = sim::Sim::new(); let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; @@ -105,7 +101,6 @@ async fn a_bee_with_a_forged_signature_is_disconnected() { let bee = fresh_bee(); let mut hello = base_hello(); hello_with_identity(&mut hello, &bee); - // Correct shape, wrong bytes. hello["signature"] = serde_json::json!(hex::encode([0u8; 64])); humd.thrum.inject_tone(&cid, hello).await; @@ -119,9 +114,6 @@ async fn a_bee_with_a_forged_signature_is_disconnected() { #[tokio::test(flavor = "multi_thread")] async fn a_bee_claiming_anothers_hid_is_disconnected() { - // The attack the hid/pubkey binding exists for: Mallory signs with her - // own key, but over her victim's hid, so her hid/pubkey pair is not - // internally consistent. let _ = tracing_subscriber::fmt::try_init(); let sim = sim::Sim::new(); let humd = sim.spawn_humd(ensemble::Hid::random_humd()).await; @@ -162,8 +154,6 @@ async fn a_replayed_hello_outside_the_skew_window_is_disconnected() { let _rx = humd.thrum.register_synthetic(cid.clone()); let bee = fresh_bee(); - // Sign an old hello. Everything is cryptographically valid; only the - // timestamp is stale. That is the whole point of the skew window. let stale = now_ms() - 3_600_000; let (pubkey, _signed_at, signature) = bee.sign_socket_hello(stale); let mut hello = base_hello(); @@ -179,4 +169,4 @@ async fn a_replayed_hello_outside_the_skew_window_is_disconnected() { !humd.thrum.is_connected(&cid), "a hello recorded a hour ago must not be replayable now" ); -} \ No newline at end of file +} From 4732a2f5ef9a9589168f101338327cc792c6840e Mon Sep 17 00:00:00 2001 From: Adil Date: Mon, 19 Oct 2026 00:30:48 +0500 Subject: [PATCH 8/8] scenarios: drop two files from the unpaired list that have prose The table paired `overflow-inference.md` and `phone-laptop-roam.md` at lines 26 and 29; the list immediately below named both of their tests as having no scenario prose. Both files exist. The table was right and the list was stale. Also adds the six socket-admission tests this branch introduced, which the list predated. 7 paired + 13 unpaired now accounts for all 20 sim tests, and every name resolves to a file that exists. --- scenarios/README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scenarios/README.md b/scenarios/README.md index 0c28e28..46f64da 100644 --- a/scenarios/README.md +++ b/scenarios/README.md @@ -31,11 +31,11 @@ Pairing is the rule, not yet the state of the directory: | `eggs-on-the-hum.md` | `sim/tests/eggs_on_the_hum.rs` | | `find-the-worker.md` | `sim/tests/remote_discovery.rs` | -Tests with no scenario prose yet: `delivery.rs`, `liveness.rs`, -`lossy_link.rs`, `mock_prompt.rs`, `overflow_inference.rs`, -`phone_laptop_roam.rs`, `smoke.rs`, `stalled_peer.rs`, -`tool_catalogue.rs`, `two_humds_ping_pong.rs`. `wifi-p2p-meetup.md` -has prose with no test. +Tests with no scenario prose yet: `admission_gate.rs`, `chi_scoping.rs`, +`delivery.rs`, `liveness.rs`, `lossy_link.rs`, `mock_prompt.rs`, +`remote_routing_trust.rs`, `revocation.rs`, `smoke.rs`, +`socket_identity.rs`, `stalled_peer.rs`, `tool_catalogue.rs`, +`two_humds_ping_pong.rs`. `wifi-p2p-meetup.md` has prose with no test. Each MD covers five sections in the same order: **setup**, **happy path**, **failure modes**, **success criteria**, **what this validates**.