From 72c87547542ef4806edd4d345d4ef48737725a84 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 4 Oct 2026 23:07:46 +0500 Subject: [PATCH 1/3] humd: stop routing prompts on unverified capability claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find-the-worker routed a prompt to whichever peer advertised the model. The claim is self-declared, so any connected peer could announce a model it does not have and receive the prompt. Two defects, addressed separately: A peer could announce under a Hid it does not own. handle_gossip published the payload and relayed it without checking that the announced humd_id was the sender. Reject that at the gossip entry point, where every announce funnels through. A peer can still over-advertise for its own Hid, and no check on the announce can distinguish that from an honest claim. Refusing the prompt would break discovery entirely, so remote routing is now opt-in via HUM_TRUST_REMOTE_WORKERS. Default off: a claim alone no longer moves prompt content. sim/tests/remote_routing_trust.rs covers both directions — a claiming peer gets nothing by default, and opting in still reaches the worker. Both fail if the corresponding gate is removed. --- ensemble/src/lib.rs | 51 ++++++++++ humd/src/lib.rs | 17 ++++ sim/src/lib.rs | 12 +++ sim/tests/remote_routing_trust.rs | 151 ++++++++++++++++++++++++++++++ 4 files changed, 231 insertions(+) create mode 100644 sim/tests/remote_routing_trust.rs diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index e5c964b..7919ed5 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -845,6 +845,14 @@ async fn handle_liveness( true } +fn announce_claims_sender(payload: &serde_json::Value, arrived_from: &Hid) -> bool { + match serde_json::from_value::(payload.clone()) { + Ok(hives::HiveAnnounce::Advertise { humd_id, .. }) => humd_id == arrived_from.to_hex(), + Ok(hives::HiveAnnounce::Retract { humd_id, .. }) => humd_id == arrived_from.to_hex(), + Err(_) => true, + } +} + async fn handle_gossip( send_stats: &Arc, gossip: &Arc, @@ -859,6 +867,14 @@ async fn handle_gossip( if !gossip.note_seen(parsed.msg_id) { return true; } + if parsed.topic == hives::ANNOUNCE_TOPIC && !announce_claims_sender(&parsed.payload, arrived_from) { + tracing::warn!( + target: "ensemble.bees", + arrived_from = %arrived_from, + "gossip.announce.impersonation-rejected — payload claims a humd_id the sender does not own" + ); + return true; + } if let Some(tx) = gossip.sender(parsed.topic) { let _ = tx.send(parsed.payload.clone()); } @@ -896,6 +912,41 @@ mod tests { }) } + fn worker_announce(claimed: &str, model: &str) -> serde_json::Value { + let mut manifest = hives::HiveManifest::new("worker-bee", "0.1.0", "0.7.0"); + manifest.bee = vec!["worker".to_string()]; + manifest.models = vec![model.to_string()]; + serde_json::to_value(hives::HiveAnnounce::Advertise { + humd_id: claimed.to_string(), + manifest: Box::new(manifest), + }) + .expect("serialize announce") + } + + #[test] + fn an_announce_under_the_senders_own_hid_is_accepted() { + let me = Hid::random_humd(); + let payload = worker_announce(&me.to_hex(), "claude-opus-4-7"); + assert!(announce_claims_sender(&payload, &me)); + } + + #[test] + fn an_announce_claiming_another_hums_hid_is_rejected() { + let me = Hid::random_humd(); + let victim = Hid::random_humd(); + let payload = worker_announce(&victim.to_hex(), "claude-opus-4-7"); + assert!( + !announce_claims_sender(&payload, &me), + "a peer must not advertise capabilities under a Hid it does not own" + ); + } + + #[test] + fn an_unknown_payload_shape_passes_the_provenance_gate() { + let me = Hid::random_humd(); + assert!(announce_claims_sender(&json!({ "kind": "something-new" }), &me)); + } + async fn drain(rx: &mut mpsc::Receiver) -> Vec { let mut out = Vec::new(); while let Ok(t) = rx.try_recv() { diff --git a/humd/src/lib.rs b/humd/src/lib.rs index 1cd35ad..86b0efc 100644 --- a/humd/src/lib.rs +++ b/humd/src/lib.rs @@ -53,6 +53,7 @@ pub struct DaemonConfig { pub humd_key: Option>, pub bootstrap_peers: Vec, pub thehum_cfg: Option, + pub trust_remote_workers: bool, } impl DaemonConfig { @@ -89,6 +90,13 @@ impl DaemonConfig { humd_key, bootstrap_peers, thehum_cfg: None, + trust_remote_workers: matches!( + std::env::var("HUM_TRUST_REMOTE_WORKERS") + .ok() + .map(|v| v.trim().to_string()) + .as_deref(), + Some("1") | Some("true") | Some("yes") + ), } } } @@ -290,6 +298,7 @@ where tool_routes_peer: tool_routes_peer.clone(), incoming_tool_calls: incoming_tool_calls.clone(), thehum: thehum_handle.clone(), + trust_remote_workers: cfg.trust_remote_workers, }); thrum.set_sink(sink); if let Some(ens) = &ensemble_for_sink { @@ -476,6 +485,7 @@ struct HumdSink { tool_routes_peer: Arc>>, incoming_tool_calls: Arc>>, thehum: Option>, + trust_remote_workers: bool, } pub struct PeersAliasResolver { @@ -519,6 +529,13 @@ fn bees_snapshot_path() -> std::path::PathBuf { impl HumdSink { fn pick_remote_worker(&self, model: &str) -> Option { let ens = self.ensemble.as_ref()?; + if !self.trust_remote_workers { + warn!( + model, + "prompt.remote-routing.disabled — a peer can advertise any model and be handed the prompt; set HUM_TRUST_REMOTE_WORKERS=1 to accept that risk" + ); + return None; + } let live: std::collections::BTreeSet = ens.peers().iter().map(|h| h.to_hex()).collect(); let table = self.remote_hives.read(); diff --git a/sim/src/lib.rs b/sim/src/lib.rs index 98e960b..64bc9a7 100644 --- a/sim/src/lib.rs +++ b/sim/src/lib.rs @@ -94,6 +94,16 @@ impl Sim { } pub async fn spawn_humd(&self, id: Hid) -> Arc { + self.spawn_humd_inner(id, true).await + } + + /// A humd that refuses to route prompts on a peer's advertised model + /// claim — the production default. + pub async fn spawn_humd_not_trusting_remote_workers(&self, id: Hid) -> Arc { + self.spawn_humd_inner(id, false).await + } + + async fn spawn_humd_inner(&self, id: Hid, trust_remote_workers: bool) -> Arc { let thrum = Thrum::new(); let ensemble = Arc::new(Ensemble::with_strict_auth(id, false)); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); @@ -128,6 +138,7 @@ impl Sim { humd_key: None, bootstrap_peers: Vec::new(), thehum_cfg: None, + trust_remote_workers, }; let shutdown_fut = async move { @@ -249,6 +260,7 @@ impl Sim { humd_key: None, bootstrap_peers: Vec::new(), thehum_cfg: None, + trust_remote_workers: true, }; let shutdown_fut = async move { let _ = shutdown_rx.await; }; diff --git a/sim/tests/remote_routing_trust.rs b/sim/tests/remote_routing_trust.rs new file mode 100644 index 0000000..0131aac --- /dev/null +++ b/sim/tests/remote_routing_trust.rs @@ -0,0 +1,151 @@ +use std::time::Duration; + +/// A peer advertises a model it does not have. Forwarding a prompt on the +/// strength of that claim hands the prompt to whoever made the claim, so +/// humd refuses unless the operator opts in. +#[tokio::test(flavor = "multi_thread")] +async fn a_prompt_is_not_forwarded_on_an_unverified_capability_claim() { + let _ = tracing_subscriber::fmt::try_init(); + + let sim = sim::Sim::new(); + let laptop = sim + .spawn_humd_not_trusting_remote_workers(ensemble::Hid::random_humd()) + .await; + let server = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.wire(laptop.id, server.id).expect("L↔S"); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let worker_cid = hum_identity::HumId::mint().to_string(); + let mut claimed_rx = server.thrum.register_synthetic(worker_cid.clone()); + server + .thrum + .inject_tone( + &worker_cid, + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.0.0", + "protoVersion": thrum_core::THRUM_VERSION, + "models": ["claude-opus-4-7"], + "chis": ["hello", "prompt", "chunk", "finish"], + }), + ) + .await; + + tokio::time::sleep(Duration::from_millis(150)).await; + + sim.nestler_send( + laptop.id, + serde_json::json!({ + "chi": "prompt", + "rid": "trust-1", + "sid": "hum-trust", + "modelId": "claude-opus-4-7", + "content": "what is in my prompt?", + }), + ) + .expect("laptop nestler sends prompt"); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut forwarded = false; + let mut refused = false; + while std::time::Instant::now() < deadline { + if let Ok(Some(tone)) = + tokio::time::timeout(Duration::from_millis(50), claimed_rx.recv()).await + && tone.get("chi").and_then(|v| v.as_str()) == Some("prompt") + { + forwarded = true; + break; + } + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Some(tone) = sim.nestler_recv(laptop.id, "hum-trust", remaining).await else { break }; + if tone.get("chi").and_then(|v| v.as_str()) == Some("error") { + refused = true; + break; + } + } + + assert!( + !forwarded, + "the claiming peer received the prompt on an unverified capability claim" + ); + assert!(refused, "laptop never got a refusal, so the prompt went nowhere"); +} + +/// The same mesh, with the operator opting in, still routes. Proves the +/// interlock is the only thing standing in the way. +#[tokio::test(flavor = "multi_thread")] +async fn opting_in_restores_discovery_routing() { + let _ = tracing_subscriber::fmt::try_init(); + + let sim = sim::Sim::new(); + let laptop = sim.spawn_humd(ensemble::Hid::random_humd()).await; + let server = sim.spawn_humd(ensemble::Hid::random_humd()).await; + sim.wire(laptop.id, server.id).expect("L↔S"); + + tokio::time::sleep(Duration::from_millis(300)).await; + + let worker_cid = hum_identity::HumId::mint().to_string(); + let mut worker_rx = server.thrum.register_synthetic(worker_cid.clone()); + let server_thrum = server.thrum.clone(); + server + .thrum + .inject_tone( + &worker_cid, + serde_json::json!({ + "chi": "hello", + "bee": ["worker"], + "hive": "claude-cli", + "version": "0.0.0", + "protoVersion": thrum_core::THRUM_VERSION, + "models": ["claude-opus-4-7"], + "chis": ["hello", "prompt", "chunk", "finish"], + }), + ) + .await; + + let cid_for_pump = worker_cid.clone(); + tokio::spawn(async move { + while let Some(tone) = worker_rx.recv().await { + if tone.get("chi").and_then(|v| v.as_str()) == Some("prompt") { + let sid = tone.get("sid").and_then(|v| v.as_str()).unwrap_or("").to_string(); + for reply in [ + serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_start","id":0}), + serde_json::json!({"chi":"chunk","sid":&sid,"chunkType":"text_delta","delta":"remote hi"}), + serde_json::json!({"chi":"finish","sid":&sid,"finishReason":"end_turn","usage":{}}), + ] { + server_thrum.inject_tone(&cid_for_pump, reply).await; + } + } + } + }); + + tokio::time::sleep(Duration::from_millis(150)).await; + + sim.nestler_send( + laptop.id, + serde_json::json!({ + "chi": "prompt", + "rid": "trust-2", + "sid": "hum-trust", + "modelId": "claude-opus-4-7", + "content": "who is out there?", + }), + ) + .expect("laptop nestler sends prompt"); + + let deadline = std::time::Instant::now() + Duration::from_secs(5); + let mut saw_finish = false; + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Some(tone) = sim.nestler_recv(laptop.id, "hum-trust", remaining).await else { break }; + if tone.get("chi").and_then(|v| v.as_str()) == Some("finish") { + saw_finish = true; + break; + } + } + + assert!(saw_finish, "opt-in routing did not reach the discovered worker"); +} \ No newline at end of file From 127181af6bf4ccdd0351a16bdacac3bb113342db Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 4 Oct 2026 23:42:14 +0500 Subject: [PATCH 2/3] ensemble: drop needless borrow in announce provenance gate --- ensemble/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 7919ed5..6f9c3e6 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -867,7 +867,7 @@ async fn handle_gossip( if !gossip.note_seen(parsed.msg_id) { return true; } - if parsed.topic == hives::ANNOUNCE_TOPIC && !announce_claims_sender(&parsed.payload, arrived_from) { + if parsed.topic == hives::ANNOUNCE_TOPIC && !announce_claims_sender(parsed.payload, arrived_from) { tracing::warn!( target: "ensemble.bees", arrived_from = %arrived_from, From ca4869055020f0c1b441e8b1f0193354de279f19 Mon Sep 17 00:00:00 2001 From: Adil Date: Sun, 4 Oct 2026 23:49:48 +0500 Subject: [PATCH 3/3] ensemble: bind announce provenance to the tone origin, not the last hop The provenance gate compared the payload's humd_id against the peer the tone arrived on. Multi-hop gossip breaks that: A advertises, B relays, C sees arrived_from=B while the payload claims A, so a legitimate advertise was refused after one relay. Compare against the tone's own from field, which is preserved across hops. An added test covers A-B-C directly. --- ensemble/src/lib.rs | 67 ++++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/ensemble/src/lib.rs b/ensemble/src/lib.rs index 6f9c3e6..56854b6 100644 --- a/ensemble/src/lib.rs +++ b/ensemble/src/lib.rs @@ -845,12 +845,24 @@ async fn handle_liveness( true } -fn announce_claims_sender(payload: &serde_json::Value, arrived_from: &Hid) -> bool { - match serde_json::from_value::(payload.clone()) { - Ok(hives::HiveAnnounce::Advertise { humd_id, .. }) => humd_id == arrived_from.to_hex(), - Ok(hives::HiveAnnounce::Retract { humd_id, .. }) => humd_id == arrived_from.to_hex(), - Err(_) => true, - } +/// Whether an announce is internally consistent: the `humd_id` it claims must +/// match the `from` of the tone carrying it. Binds to the tone's own origin +/// field, not the connection it arrived on, so a legitimate multi-hop +/// advertise still percolates while a peer cannot stamp someone else's Hid. +fn announce_origin_matches_payload(tone: &Tone) -> bool { + let Some(from) = tone.get("from").and_then(|v| v.as_str()) else { + return true; + }; + let Ok(payload) = serde_json::from_value::( + tone.get("payload").cloned().unwrap_or(serde_json::Value::Null), + ) else { + return true; + }; + let claimed = match payload { + hives::HiveAnnounce::Advertise { humd_id, .. } => humd_id, + hives::HiveAnnounce::Retract { humd_id, .. } => humd_id, + }; + claimed == from } async fn handle_gossip( @@ -867,11 +879,11 @@ async fn handle_gossip( if !gossip.note_seen(parsed.msg_id) { return true; } - if parsed.topic == hives::ANNOUNCE_TOPIC && !announce_claims_sender(parsed.payload, arrived_from) { + if parsed.topic == hives::ANNOUNCE_TOPIC && !announce_origin_matches_payload(tone) { tracing::warn!( target: "ensemble.bees", - arrived_from = %arrived_from, - "gossip.announce.impersonation-rejected — payload claims a humd_id the sender does not own" + topic = parsed.topic, + "gossip.announce.impersonation-rejected — payload claims a humd_id the tone origin does not match" ); return true; } @@ -923,28 +935,51 @@ mod tests { .expect("serialize announce") } + fn announce_tone(origin: &str, payload: serde_json::Value) -> Tone { + json!({ + "chi": "gossip-publish", + "rid": "g1", + "topic": hives::ANNOUNCE_TOPIC, + "from": origin, + "msg_id": "m1", + "payload": payload, + }) + } + #[test] - fn an_announce_under_the_senders_own_hid_is_accepted() { + fn an_announce_matching_its_tone_origin_is_accepted() { let me = Hid::random_humd(); - let payload = worker_announce(&me.to_hex(), "claude-opus-4-7"); - assert!(announce_claims_sender(&payload, &me)); + let tone = announce_tone(&me.to_hex(), worker_announce(&me.to_hex(), "claude-opus-4-7")); + assert!(announce_origin_matches_payload(&tone)); } #[test] fn an_announce_claiming_another_hums_hid_is_rejected() { let me = Hid::random_humd(); let victim = Hid::random_humd(); - let payload = worker_announce(&victim.to_hex(), "claude-opus-4-7"); + let tone = announce_tone(&me.to_hex(), worker_announce(&victim.to_hex(), "claude-opus-4-7")); assert!( - !announce_claims_sender(&payload, &me), + !announce_origin_matches_payload(&tone), "a peer must not advertise capabilities under a Hid it does not own" ); } + #[test] + fn an_announce_relayed_by_a_third_peer_is_still_accepted() { + // A advertises, B relays, C receives. C sees B as the connection but + // the tone's origin is still A — the claim must survive the hop. + let a = Hid::random_humd(); + let b = Hid::random_humd(); + let tone = announce_tone(&a.to_hex(), worker_announce(&a.to_hex(), "claude-opus-4-7")); + assert!(announce_origin_matches_payload(&tone), "relay was blocked"); + assert_ne!(a, b); + } + #[test] fn an_unknown_payload_shape_passes_the_provenance_gate() { - let me = Hid::random_humd(); - assert!(announce_claims_sender(&json!({ "kind": "something-new" }), &me)); + let me = Hid::random_humd().to_hex(); + let tone = announce_tone(&me, json!({ "kind": "something-new" })); + assert!(announce_origin_matches_payload(&tone)); } async fn drain(rx: &mut mpsc::Receiver) -> Vec {