From 130d81efd7c4ec8ae3418df08fe7b08f6e446915 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:26:34 -0500 Subject: [PATCH 1/6] feat(agents): delegate gateway keys for remote agent access Let owners delegate an expiring, revocable key without sharing their main identity. Recheck authority for launch, prompts, cancellation and observer telemetry, and pin authority reads to the writer. Keep direct messages and owner administration outside the grant. Signed-off-by: Tee8z --- crates/buzz-acp/src/gateway.rs | 174 ++++++ crates/buzz-acp/src/gateway/tests.rs | 585 ++++++++++++++++++ crates/buzz-acp/src/lib.rs | 206 +++++- crates/buzz-acp/src/pool.rs | 59 +- crates/buzz-acp/src/runtime.rs | 1 + crates/buzz-cli/README.md | 14 + crates/buzz-cli/src/commands/gateway.rs | 141 +++++ crates/buzz-cli/src/commands/gateway/tests.rs | 143 +++++ crates/buzz-cli/src/commands/mod.rs | 1 + crates/buzz-cli/src/lib.rs | 26 + crates/buzz-core/src/kind.rs | 13 + crates/buzz-relay/src/api/bridge.rs | 41 ++ crates/buzz-relay/src/connection.rs | 11 +- crates/buzz-relay/src/handlers/event.rs | 280 ++++++++- crates/buzz-relay/src/handlers/ingest.rs | 39 ++ crates/buzz-relay/src/handlers/req.rs | 19 +- .../buzz-relay/src/handlers/side_effects.rs | 12 + crates/buzz-relay/src/protocol.rs | 34 + crates/buzz-relay/src/state.rs | 18 + crates/buzz-sandbox-reaper/src/manager.rs | 16 +- crates/buzz-sandbox-reaper/src/mentions.rs | 4 +- .../src/mentions/gateways.rs | 97 +++ .../buzz-sandbox-reaper/src/mentions/tests.rs | 4 +- .../src/mentions/tests/gateway.rs | 91 +++ .../src/mentions/tests/relay.rs | 5 +- .../src/mentions/worker.rs | 125 +++- .../buzz-sandbox-reaper/src/mobile_launch.rs | 45 +- .../src/mobile_launch/tests.rs | 55 ++ crates/buzz-sdk/src/gateway.rs | 384 ++++++++++++ crates/buzz-sdk/src/gateway/tests.rs | 348 +++++++++++ crates/buzz-sdk/src/lib.rs | 1 + docs/agent-gateway-key.md | 128 ++++ docs/remote-agents.md | 4 + mobile/lib/shared/relay/nostr_models.dart | 3 + voltage/remote-agents.md | 4 + 35 files changed, 3045 insertions(+), 86 deletions(-) create mode 100644 crates/buzz-acp/src/gateway.rs create mode 100644 crates/buzz-acp/src/gateway/tests.rs create mode 100644 crates/buzz-cli/src/commands/gateway.rs create mode 100644 crates/buzz-cli/src/commands/gateway/tests.rs create mode 100644 crates/buzz-sandbox-reaper/src/mentions/gateways.rs create mode 100644 crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs create mode 100644 crates/buzz-sdk/src/gateway.rs create mode 100644 crates/buzz-sdk/src/gateway/tests.rs create mode 100644 docs/agent-gateway-key.md diff --git a/crates/buzz-acp/src/gateway.rs b/crates/buzz-acp/src/gateway.rs new file mode 100644 index 00000000000..66f66850075 --- /dev/null +++ b/crates/buzz-acp/src/gateway.rs @@ -0,0 +1,174 @@ +//! Owner-delegated gateway keys, as the harness accepts them. +//! +//! A gateway is a separate key the agent's owner names in a signed, expiring +//! kind 30181 head (see `buzz_sdk::gateway` and `docs/agent-gateway-key.md`). +//! The harness accepts a live gateway in exactly three places, and only when +//! it runs in strict owner mode (`--respond-to owner`, which every remote +//! channel-thread sandbox session uses), with an immutable thread binding: +//! direct-message and unbound sessions never enable gateway access. +//! +//! 1. channel events at the inbound author gate (never DMs), rechecked again +//! when a queued turn reaches model dispatch; +//! 2. `cancel_turn` observer control frames (no other control command); +//! 3. observer telemetry, which is also encrypted to each live gateway. +//! +//! Every lookup fails closed. Verdicts are cached for [`VERDICT_TTL`] so a +//! revocation takes effect on the next lookup after that, and a failure is +//! remembered for [`FAILURE_RETRY`] so a relay outage cannot turn every event +//! into an inline relay round-trip. + +use std::time::Duration; + +use nostr::{Event, PublicKey}; +use serde_json::{json, Value}; +use tokio::time::Instant; + +use crate::relay::RestClient; + +/// Longest a successful lookup is reused, which bounds revocation latency. +pub(crate) const VERDICT_TTL: Duration = Duration::from_secs(30); +/// Delay before a failed lookup is retried. Until then, gateways are refused. +pub(crate) const FAILURE_RETRY: Duration = Duration::from_secs(5); +/// Bound on one complete lookup (delegation heads plus profiles). +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); + +/// A delegation lookup failed; callers refuse the gateway. +#[derive(Debug, Clone, thiserror::Error)] +#[error("gateway delegation lookup failed: {0}")] +pub(crate) struct LookupError(String); + +enum Cached { + Empty, + Fresh { + at: Instant, + gateways: Vec, + }, + Failed { + at: Instant, + error: LookupError, + }, +} + +/// The owner's live gateways for one agent, from the relay, with a bounded +/// cache. +/// +/// The cache mutex is held across the relay lookup. Lookups are therefore +/// single-flight: a slower, older lookup can never overwrite a newer result. +pub(crate) struct GatewayAccess { + owner: PublicKey, + agent: PublicKey, + rest: RestClient, + cached: tokio::sync::Mutex, +} + +impl GatewayAccess { + /// Gateway access for `agent`, owned by `owner`, read through `rest`. + pub(crate) fn new(owner: PublicKey, agent: PublicKey, rest: RestClient) -> Self { + Self { + owner, + agent, + rest, + cached: tokio::sync::Mutex::new(Cached::Empty), + } + } + + /// The live gateways now, newest delegation first, at most + /// [`buzz_sdk::gateway::MAX_LIVE_GATEWAYS`]. + pub(crate) async fn live(&self) -> Result, LookupError> { + let mut cached = self.cached.lock().await; + let reuse = match &*cached { + Cached::Fresh { at, .. } => at.elapsed() < VERDICT_TTL, + Cached::Failed { at, .. } => at.elapsed() < FAILURE_RETRY, + Cached::Empty => false, + }; + if !reuse { + // Age successful results from lookup start so network latency + // cannot extend the authority cache beyond the documented TTL. + let lookup_started = Instant::now(); + *cached = match tokio::time::timeout(LOOKUP_TIMEOUT, self.lookup()).await { + Ok(Ok(gateways)) => Cached::Fresh { + at: lookup_started, + gateways, + }, + Ok(Err(error)) => Cached::Failed { + at: Instant::now(), + error, + }, + Err(_) => Cached::Failed { + at: Instant::now(), + error: LookupError("timed out".into()), + }, + }; + } + let now = unix_now(); + match &*cached { + // A cached delegation never outlives its own expiration. + Cached::Fresh { gateways, .. } => Ok(gateways + .iter() + .filter(|live| live.is_live_at(now)) + .map(|live| live.gateway()) + .collect()), + Cached::Failed { error, .. } => Err(error.clone()), + Cached::Empty => Err(LookupError("no lookup".into())), + } + } + + /// Whether `key` is a live gateway of this agent's owner now. + pub(crate) async fn is_live(&self, key: &PublicKey) -> Result { + if *key == self.owner || *key == self.agent { + return Ok(false); + } + Ok(self.live().await?.contains(key)) + } + + async fn lookup(&self) -> Result, LookupError> { + let now = unix_now(); + let heads = self + .query(json!({ + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [self.owner.to_hex()], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + })) + .await?; + let candidates: Vec = buzz_sdk::gateway::live_heads(self.owner, &heads, now) + .iter() + .map(|live| live.gateway().to_hex()) + .collect(); + if candidates.is_empty() { + return Ok(Vec::new()); + } + let profiles = self + .query(json!({ + "kinds": [0], + "authors": candidates, + "limit": candidates.len(), + })) + .await?; + Ok(buzz_sdk::gateway::resolve( + self.owner, + &heads, + &profiles, + &[self.owner, self.agent], + now, + )) + } + + /// One `/query`, refusing any response that is not an array of events. + async fn query(&self, filter: Value) -> Result, LookupError> { + let mut filter = filter; + filter["consistency"] = json!("strong"); + let response = self + .rest + .query_raw(&[filter]) + .await + .map_err(|error| LookupError(error.to_string()))?; + serde_json::from_value(response).map_err(|_| LookupError("malformed relay response".into())) + } +} + +fn unix_now() -> u64 { + nostr::Timestamp::now().as_secs() +} + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/buzz-acp/src/gateway/tests.rs b/crates/buzz-acp/src/gateway/tests.rs new file mode 100644 index 00000000000..610e43e4867 --- /dev/null +++ b/crates/buzz-acp/src/gateway/tests.rs @@ -0,0 +1,585 @@ +//! A loopback `/query` relay that serves kind 30181 heads and kind:0 profiles, +//! so gateway checks run through the production [`RestClient`] seam. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use nostr::{Event, EventBuilder, Keys, Kind}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::*; + +/// Fake relay state shared with the serving task. +pub(crate) struct FakeRelay { + pub(crate) heads: Arc>>, + pub(crate) profiles: Arc>>, + pub(crate) fail: Arc, + pub(crate) queries: Arc, + pub(crate) rest: RestClient, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for FakeRelay { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Start a fake relay; `/query` answers 403 while `fail` is set. +pub(crate) async fn fake_relay() -> FakeRelay { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let heads = Arc::new(Mutex::new(Vec::::new())); + let profiles = Arc::new(Mutex::new(Vec::::new())); + let fail = Arc::new(AtomicBool::new(false)); + let queries = Arc::new(AtomicUsize::new(0)); + let task = { + let (heads, profiles, fail, queries) = ( + heads.clone(), + profiles.clone(), + fail.clone(), + queries.clone(), + ); + tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = Vec::new(); + let mut buffer = [0; 4096]; + // Read headers plus the Content-Length body. + while let Ok(read) = socket.read(&mut buffer).await { + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + let text = String::from_utf8_lossy(&request).to_string(); + if let Some(split) = text.find("\r\n\r\n") { + let length = text[..split] + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok())? + }) + .unwrap_or(0); + if request.len() >= split + 4 + length { + break; + } + } + } + queries.fetch_add(1, Ordering::SeqCst); + let text = String::from_utf8_lossy(&request).to_string(); + let body = text.split_once("\r\n\r\n").map(|(_, b)| b).unwrap_or(""); + let filter: Value = serde_json::from_str::>(body) + .ok() + .and_then(|filters| filters.into_iter().next()) + .unwrap_or_default(); + assert_eq!( + filter["consistency"], + json!("strong"), + "authority reads must bypass replicas" + ); + let (status, payload) = if fail.load(Ordering::SeqCst) { + ("403 Forbidden", "{}".to_string()) + } else if filter["kinds"] == json!([30181]) { + ( + "200 OK", + serde_json::to_string(&*heads.lock().unwrap()).unwrap(), + ) + } else if filter["kinds"] == json!([0]) { + ( + "200 OK", + serde_json::to_string(&*profiles.lock().unwrap()).unwrap(), + ) + } else { + ("200 OK", "[]".to_string()) + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{payload}", + payload.len() + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }) + }; + FakeRelay { + heads, + profiles, + fail, + queries, + rest: RestClient { + http: reqwest::Client::new(), + base_url, + keys: Keys::generate(), + auth_tag_json: None, + }, + task, + } +} + +/// An owner-signed grant for `gateway`, created `age` seconds ago. +pub(crate) fn grant(owner: &Keys, gateway: PublicKey, age: u64) -> Event { + let now = unix_now(); + buzz_sdk::gateway::Delegation::grant(gateway, now + 3_600, now - age) + .unwrap() + .sign_with_keys(owner) + .unwrap() +} + +/// An owner-signed revocation for `gateway`, created `age` seconds ago. +pub(crate) fn revoke(owner: &Keys, gateway: PublicKey, age: u64) -> Event { + buzz_sdk::gateway::Delegation::revoke(gateway, unix_now() - age) + .unwrap() + .sign_with_keys(owner) + .unwrap() +} + +/// The cached verdict is reused within the TTL, a revocation lands after it, +/// failures are refused and throttled, and agent keys never pass. +/// Mutations: drop the TTL reuse → the query count assertion fails; drop the +/// failure cache → the throttled-failure query count fails; drop the +/// `is_live_at` filter → the expired-cache assertion fails; drop the owner or +/// agent exclusion → their assertions fail. +#[tokio::test] +async fn gateway_access_is_cached_bounded_and_fails_closed() { + let relay = fake_relay().await; + let (owner, agent, gateway, sibling) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let access = GatewayAccess::new(owner.public_key(), agent.public_key(), relay.rest.clone()); + let gw = gateway.public_key(); + + assert!(!access.is_live(&gw).await.unwrap(), "no delegation yet"); + relay.heads.lock().unwrap().push(grant(&owner, gw, 10)); + assert!( + !access.is_live(&gw).await.unwrap(), + "the empty verdict is reused within the TTL" + ); + assert_eq!(relay.queries.load(Ordering::SeqCst), 1); + skip(VERDICT_TTL).await; + assert!( + access.is_live(&gw).await.unwrap(), + "grant seen after the TTL" + ); + assert!(!access.is_live(&owner.public_key()).await.unwrap()); + assert!(!access.is_live(&agent.public_key()).await.unwrap()); + + // A newer revocation wins after the TTL. + relay.heads.lock().unwrap().push(revoke(&owner, gw, 5)); + skip(VERDICT_TTL).await; + assert!(!access.is_live(&gw).await.unwrap(), "revoked"); + + // An agent key the owner names is refused by its kind:0 attestation. + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, sibling.public_key(), 1)); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &sibling.public_key(), "").unwrap(); + relay.profiles.lock().unwrap().push( + EventBuilder::new(Kind::Metadata, "{}") + .tag(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&sibling) + .unwrap(), + ); + skip(VERDICT_TTL).await; + assert!(!access.is_live(&sibling.public_key()).await.unwrap()); + + // Failure: refused, and not retried before FAILURE_RETRY. + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!(access.is_live(&gw).await.is_err()); + let after_failure = relay.queries.load(Ordering::SeqCst); + assert!(access.is_live(&gw).await.is_err()); + assert_eq!(relay.queries.load(Ordering::SeqCst), after_failure); + + // Recovery after the failure backoff. + relay.fail.store(false, Ordering::SeqCst); + relay.heads.lock().unwrap().clear(); + relay.heads.lock().unwrap().push(grant(&owner, gw, 1)); + skip(FAILURE_RETRY).await; + assert!(access.is_live(&gw).await.unwrap()); + + // Even a still-fresh cache cannot extend the signed expiration. + let now = unix_now(); + let expired = buzz_sdk::gateway::Delegation::grant(gw, now, now - 100) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + *access.cached.lock().await = Cached::Fresh { + at: tokio::time::Instant::now(), + gateways: buzz_sdk::gateway::live_heads(owner.public_key(), &[expired], now - 1), + }; + assert!( + !access.is_live(&gw).await.unwrap(), + "a cached grant expired" + ); +} + +/// Advance the tokio clock without letting paused time fire IO timeouts. +pub(crate) async fn skip(duration: Duration) { + tokio::time::pause(); + tokio::time::advance(duration).await; + tokio::time::resume(); +} + +fn channel_message(signer: &Keys, agent: PublicKey) -> Event { + EventBuilder::new(Kind::Custom(9), "please run the tests") + .tags([ + nostr::Tag::parse(["h", "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10"]).unwrap(), + nostr::Tag::public_key(agent), + ]) + .sign_with_keys(signer) + .unwrap() +} + +/// Thread ingress: strict owner mode admits the owner and a live gateway in +/// channels; never in DMs, never after revocation, never on lookup failure, +/// and never in modes that do not enable gateways. +/// Mutations: drop the gateway branch → "gateway in channel" fails; drop the +/// `!is_dm` guard → "gateway in DM" fails; drop the `RespondTo::Owner` guard +/// → the owner-only assertion fails. +#[tokio::test] +async fn author_gate_admits_a_live_gateway_only_in_owner_mode_channels() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + )); + let mut gate = + crate::InboundAuthorGate::connect(&relay.rest, &agent.public_key().to_hex(), "test").await; + gate.enable_gateways(Some(access)); + let cache = crate::OwnerCache::new(Some(owner.public_key().to_hex())); + let allowlist = std::collections::HashSet::new(); + let owner_mode = crate::RespondTo::Owner; + let allowed = |signer: &Keys, mode: crate::RespondTo, is_dm: bool| { + let event = channel_message(signer, agent.public_key()); + let (gate, cache, allowlist, rest) = (&gate, &cache, &allowlist, &relay.rest); + async move { + gate.evaluate_for_test(&event, &mode, allowlist, is_dm, cache, rest) + .await + .allowed + } + }; + + assert!(allowed(&owner, owner_mode.clone(), false).await, "owner"); + assert!( + allowed(&gateway, owner_mode.clone(), false).await, + "gateway in channel" + ); + assert!( + !allowed(&gateway, owner_mode.clone(), true).await, + "gateway in DM" + ); + assert!( + !allowed(&stranger, owner_mode.clone(), false).await, + "stranger" + ); + assert!( + !allowed(&gateway, crate::RespondTo::OwnerOnly, false).await, + "gateways are honoured only in strict owner mode" + ); + + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!( + !allowed(&gateway, owner_mode.clone(), false).await, + "revoked" + ); + + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!( + !allowed(&gateway, owner_mode.clone(), false).await, + "lookup failure" + ); + assert!(allowed(&owner, owner_mode, false).await, "owner unaffected"); +} + +fn control_frame(sender: &Keys, agent: &Keys, command: &str) -> Event { + let content = crate::encrypt_observer_payload( + sender, + &agent.public_key(), + &serde_json::json!({"type": command, "channelId": uuid::Uuid::new_v4()}), + ) + .unwrap(); + buzz_sdk::build_agent_observer_frame( + &agent.public_key().to_hex(), + &agent.public_key().to_hex(), + buzz_core::observer::OBSERVER_FRAME_CONTROL, + &content, + ) + .unwrap() + .sign_with_keys(sender) + .unwrap() +} + +/// Observer control: a live gateway may `cancel_turn` and nothing else; an +/// undelegated, revoked, or unverifiable sender is refused. +/// Mutations: drop the cancel-only restriction → "gateway switch_model" +/// fails; accept any non-owner sender → "stranger" fails; drop the gateway +/// lookup in `delegated_control_sender` → "gateway cancel" fails. +#[tokio::test] +async fn observer_control_from_a_gateway_is_cancel_only() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + let access = GatewayAccess::new(owner.public_key(), agent.public_key(), relay.rest.clone()); + let owner_hex = owner.public_key().to_hex(); + let accepted = |sender: &Keys, command: &str| { + let frame = control_frame(sender, &agent, command); + let (access, owner_hex, agent) = (&access, &owner_hex, &agent); + async move { + let gateway = crate::delegated_control_sender(Some(access), &frame, owner_hex).await; + crate::authorized_control_payload(agent, &frame, owner_hex, gateway).is_some() + } + }; + + assert!(accepted(&owner, "cancel_turn").await, "owner cancel"); + assert!(accepted(&owner, "switch_model").await, "owner switch_model"); + assert!(accepted(&gateway, "cancel_turn").await, "gateway cancel"); + assert!( + !accepted(&gateway, "switch_model").await, + "gateway switch_model" + ); + assert!( + !accepted(&gateway, "publish_project_owner_announcements").await, + "gateway announcements" + ); + assert!(!accepted(&stranger, "cancel_turn").await, "stranger"); + + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!(!accepted(&gateway, "cancel_turn").await, "revoked gateway"); +} + +/// Telemetry: while a delegation is live each frame is encrypted to the owner +/// and the gateway, and the extra frame is paid for by an idle tick so the +/// relay budget stays at one frame per tick. +/// Mutations: drop the gateway recipients → tick 1 has one frame; drop the +/// idle tick → tick 2 publishes. +#[tokio::test] +async fn telemetry_reaches_live_gateways_within_the_frame_budget() { + let relay = fake_relay().await; + let (owner, agent, gateway) = (Keys::generate(), Keys::generate(), Keys::generate()); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + )); + // Prime the cache in real time; the paced loop below runs on paused time. + assert_eq!(access.live().await.unwrap(), vec![gateway.public_key()]); + tokio::time::pause(); + + let observer = crate::observer::ObserverHandle::in_process(); + for (channel, marker) in [(uuid::Uuid::new_v4(), "a"), (uuid::Uuid::new_v4(), "b")] { + observer.emit( + "test_event", + None, + &crate::observer::context_for(Some(channel), None, None), + serde_json::json!({"marker": marker}), + ); + } + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let rx = observer.subscribe(); + let task = tokio::spawn(crate::run_relay_observer_publisher( + observer.snapshot(), + rx, + publisher, + agent.clone(), + agent.public_key().to_hex(), + owner.public_key(), + Some(access), + )); + // Let the publisher install its pacer before time moves. + for _ in 0..64 { + tokio::task::yield_now().await; + } + let first = tick(&mut published).await; + assert_eq!(first.len(), 2, "owner and gateway copies"); + let recipients: Vec = first + .iter() + .map(|frame| { + let p = frame + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .unwrap(); + PublicKey::from_hex(&p.as_slice()[1]).unwrap() + }) + .collect(); + assert_eq!(recipients, vec![owner.public_key(), gateway.public_key()]); + let decoded: Value = crate::decrypt_observer_payload(&gateway, &first[1]).unwrap(); + assert!(decoded.is_object(), "the gateway can read its copy"); + assert!( + tick(&mut published).await.is_empty(), + "idle tick pays for the gateway copy" + ); + assert_eq!( + tick(&mut published).await.len(), + 2, + "next frame, again to both" + ); + task.abort(); +} + +async fn tick(published: &mut tokio::sync::mpsc::Receiver) -> Vec { + // The extra millisecond covers the timer wheel rounding a deadline that + // was registered before the clock was paused. + tokio::time::advance(crate::OBSERVER_PUBLISH_TICK + Duration::from_millis(1)).await; + for _ in 0..64 { + tokio::task::yield_now().await; + } + let mut frames = Vec::new(); + while let Ok(frame) = published.try_recv() { + frames.push(frame); + } + frames +} + +/// Model dispatch rechecks a queued gateway turn: a revoked delegation or an +/// unverifiable lookup refuses it, the owner always passes, and a stranger is +/// refused without sharing. +/// Mutations: skip the gateway recheck → "revoked" passes; treat lookup +/// errors as live → "lookup failure" passes. +#[tokio::test] +async fn queued_gateway_turns_are_rechecked_at_dispatch() { + let relay = fake_relay().await; + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + let mut ctx = crate::pool::tests::make_prompt_context_no_owner(); + ctx.agent_owner_pubkey = Some(owner.public_key()); + ctx.gateways = Some(Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + ))); + let channel = uuid::Uuid::new_v4(); + let batch = |signer: &Keys| crate::queue::FlushBatch { + channel_id: channel, + scope: crate::scope::SessionScope::Thread { + channel_id: channel, + root_event_id: "a".repeat(64), + }, + events: vec![crate::queue::BatchEvent { + event: channel_message(signer, agent.public_key()), + prompt_tag: "@mention".into(), + received_at: std::time::Instant::now(), + edit: None, + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let refused = |signer: &Keys| { + let pending = batch(signer); + let ctx = &ctx; + async move { crate::pool::dispatch_refusal(ctx, &pending).await.is_some() } + }; + + assert!(!refused(&owner).await, "owner"); + assert!(!refused(&gateway).await, "live gateway"); + assert!(refused(&stranger).await, "stranger without sharing"); + relay + .heads + .lock() + .unwrap() + .push(revoke(&owner, gateway.public_key(), 5)); + skip(VERDICT_TTL).await; + assert!(refused(&gateway).await, "revoked"); + relay.heads.lock().unwrap().pop(); + relay.fail.store(true, Ordering::SeqCst); + skip(VERDICT_TTL).await; + assert!(refused(&gateway).await, "lookup failure"); +} + +/// The same factory wires ingress, dispatch, observer control and telemetry. +/// A DM or unbound harness must never create that shared gateway capability. +#[tokio::test] +async fn gateway_factory_admits_only_bound_owner_channel_threads() { + let relay = fake_relay().await; + let owner = Keys::generate().public_key().to_hex(); + let thread = crate::config::BoundSession::Thread { + channel_id: uuid::Uuid::new_v4(), + thread_root: "a".repeat(64), + }; + let dm = crate::config::BoundSession::DirectMessage { + dm_channel_id: uuid::Uuid::new_v4(), + }; + for (binding, mode, owner_hex, expected) in [ + ( + Some(thread.clone()), + crate::RespondTo::Owner, + Some(owner.as_str()), + true, + ), + ( + Some(dm), + crate::RespondTo::Owner, + Some(owner.as_str()), + false, + ), + (None, crate::RespondTo::Owner, Some(owner.as_str()), false), + ( + Some(thread.clone()), + crate::RespondTo::OwnerOnly, + Some(owner.as_str()), + false, + ), + (Some(thread), crate::RespondTo::Owner, None, false), + ] { + let mut config = crate::build_mcp_servers_tests::test_config(); + config.bound_session = binding; + config.respond_to = mode; + assert_eq!( + crate::gateway_access_for(&config, owner_hex, relay.rest.clone()).is_some(), + expected + ); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 368e72e7f00..d1325fb3dd5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -11,6 +11,7 @@ mod config; mod edit_routing; mod engram_fetch; mod filter; +mod gateway; mod isolated_execution; mod observer; mod pool; @@ -570,6 +571,7 @@ mod inbound_author_gate { // None means no authoritative NIP-11 result yet, including at startup. refreshed_generation: Option, sharing: Option>, + gateways: Option>, } pub(crate) fn refresh_needed(refreshed_generation: Option, event_generation: u64) -> bool { @@ -589,6 +591,7 @@ mod inbound_author_gate { relay_self, refreshed_generation: completed.then_some(0), sharing: None, + gateways: None, } } @@ -660,7 +663,7 @@ mod inbound_author_gate { } else { effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex) }; - let allowed = author_allowed( + let mut allowed = author_allowed( respond_to, allowlist, &effective_author, @@ -669,6 +672,23 @@ mod inbound_author_gate { rest_client, ) .await; + // Strict owner mode also admits a live owner-delegated gateway, in + // channels only. The raw signer is the effective author here, and + // the owner's DMs are never delegated. + if !allowed && !is_dm && matches!(respond_to, RespondTo::Owner) { + if let Some(gateways) = &self.gateways { + allowed = match gateways.is_live(&event.pubkey).await { + Ok(live) => live, + Err(error) => { + tracing::warn!( + author = %event.pubkey, + "gateway check failed; refusing this event: {error}" + ); + false + } + }; + } + } InboundAuthorGateDecision { effective_author, allowed, @@ -676,6 +696,14 @@ mod inbound_author_gate { } } + /// Admit live owner-delegated gateways in strict owner mode. + pub(crate) fn enable_gateways( + &mut self, + gateways: Option>, + ) { + self.gateways = gateways; + } + pub(crate) fn enable_sharing( &mut self, sharing: Option>, @@ -1398,8 +1426,8 @@ fn spawn_relay_observer_publisher( publisher: RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, - owner_pubkey_hex: String, owner_pubkey: PublicKey, + gateways: Option>, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { // Subscribe BEFORE snapshotting so an event emitted between the two @@ -1414,22 +1442,31 @@ fn spawn_relay_observer_publisher( publisher, keys, agent_pubkey_hex, - owner_pubkey_hex, owner_pubkey, + gateways, ) .await; }) } +/// Publish observer telemetry to the owner and every live owner-delegated +/// gateway. +/// +/// Each frame is encrypted once per recipient. To keep the agent's relay +/// message budget flat, a frame sent to `n` recipients is followed by `n - 1` +/// idle ticks, so telemetry still costs at most one relay frame per tick. A +/// gateway lookup failure sends that frame to the owner only. +#[allow(clippy::too_many_arguments)] async fn run_relay_observer_publisher( snapshot: Vec, mut rx: tokio::sync::broadcast::Receiver, publisher: RelayEventPublisher, keys: nostr::Keys, agent_pubkey_hex: String, - owner_pubkey_hex: String, owner_pubkey: PublicKey, + gateways: Option>, ) { + let mut idle_ticks = 0usize; let mut queue = ObserverPublishQueue::default(); let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); for event in snapshot { @@ -1472,11 +1509,25 @@ async fn run_relay_observer_publisher( } } _ = publish_tick.tick() => { - if let Some(frame) = queue.next_frame() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, frame, - ).await; + if idle_ticks > 0 { + idle_ticks -= 1; + } else if let Some(frame) = queue.next_frame() { + let mut recipients = vec![owner_pubkey]; + if let Some(gateways) = &gateways { + match gateways.live().await { + Ok(live) => recipients.extend(live), + Err(error) => tracing::warn!( + "observer telemetry to gateways skipped: {error}" + ), + } + } + idle_ticks = recipients.len() - 1; + for recipient in &recipients { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &recipient.to_hex(), recipient, frame.clone(), + ).await; + } } if closed && queue.is_empty() { break; @@ -1858,29 +1909,77 @@ async fn publish_relay_observer_event( /// Maximum age (seconds) for an observer control frame to be considered fresh. const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; -fn handle_relay_observer_control_event( +/// Build gateway access when this harness may honour owner-delegated gateways: +/// strict owner mode with a resolved owner and a bound channel thread. +/// Direct-message and unbound sessions never delegate telemetry or control. +fn gateway_access_for( + config: &Config, + owner_hex: Option<&str>, + rest: relay::RestClient, +) -> Option> { + if !matches!(config.respond_to, RespondTo::Owner) + || !matches!( + config.bound_session, + Some(config::BoundSession::Thread { .. }) + ) + { + return None; + } + let owner = PublicKey::from_hex(owner_hex?).ok()?; + Some(Arc::new(gateway::GatewayAccess::new( + owner, + config.keys.public_key(), + rest, + ))) +} + +/// The sender of an observer control frame when it is a live gateway of this +/// agent's owner, else `None`. The owner's own frames and every lookup +/// failure yield `None`; the handler then accepts only the owner. +async fn delegated_control_sender( + gateways: Option<&gateway::GatewayAccess>, + event: &nostr::Event, + owner_pubkey_hex: &str, +) -> Option { + let gateways = gateways?; + if event.pubkey.to_hex() == owner_pubkey_hex { + return None; + } + match gateways.is_live(&event.pubkey).await { + Ok(true) => Some(event.pubkey), + Ok(false) => None, + Err(error) => { + tracing::warn!(sender = %event.pubkey, "observer control gateway check failed: {error}"); + None + } + } +} + +/// Verify one observer control frame and return its decrypted payload when +/// the sender may issue it: the owner may send any command; the verified live +/// gateway named by `gateway` may send only `cancel_turn`. +fn authorized_control_payload( keys: &nostr::Keys, - event: nostr::Event, - pool: &mut AgentPool, - observer: Option<&observer::ObserverHandle>, + event: &nostr::Event, owner_pubkey_hex: &str, - event_publisher: RelayEventPublisher, - bound_channel: Option, -) { + gateway: Option, +) -> Option { // Defense-in-depth: verify signature even though the relay already checked. - if let Err(e) = buzz_core::verify_event(&event) { + if let Err(e) = buzz_core::verify_event(event) { tracing::warn!(error = %e, "observer control frame failed signature verification"); - return; + return None; } - // Defense-in-depth: verify the sender is the resolved owner. - if event.pubkey.to_hex() != owner_pubkey_hex { + let from_gateway = gateway == Some(event.pubkey); + // Defense-in-depth: verify the sender is the resolved owner or the + // verified live gateway. + if event.pubkey.to_hex() != owner_pubkey_hex && !from_gateway { tracing::warn!( sender = %event.pubkey, expected = %owner_pubkey_hex, "observer control frame from non-owner — dropping" ); - return; + return None; } // Freshness: reject stale/replayed frames outside ±5 minute window. @@ -1892,17 +1991,47 @@ fn handle_relay_observer_control_event( now, "observer control frame outside freshness window — dropping" ); - return; + return None; } - let payload = match decrypt_observer_payload::(keys, &event) { + let payload = match decrypt_observer_payload::(keys, event) { Ok(payload) => payload, Err(error) => { tracing::warn!("failed to decrypt observer control frame: {error}"); - return; + return None; } }; + let command_type = payload.get("type").and_then(|value| value.as_str()); + if from_gateway && command_type != Some("cancel_turn") { + tracing::warn!( + sender = %event.pubkey, + command = ?command_type, + "gateway observer control is limited to cancel_turn — dropping" + ); + return None; + } + Some(payload) +} + +/// Handle one observer control frame from the owner or, when `gateway` names +/// its verified sender, from a live owner-delegated gateway. A gateway may +/// only `cancel_turn`: model switches change the owner's cost and settings, +/// and project announcements publish owner-authored content. +#[allow(clippy::too_many_arguments)] +fn handle_relay_observer_control_event( + keys: &nostr::Keys, + event: nostr::Event, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, + owner_pubkey_hex: &str, + gateway: Option, + event_publisher: RelayEventPublisher, + bound_channel: Option, +) { + let Some(payload) = authorized_control_payload(keys, &event, owner_pubkey_hex, gateway) else { + return; + }; let command_type = payload.get("type").and_then(|value| value.as_str()); match command_type { Some("cancel_turn") => { @@ -3046,6 +3175,9 @@ async fn run_harness( } } let owner_cache = OwnerCache::new(startup_owner.clone()); + // Owner-delegated gateways are honoured only in strict owner mode, the mode + // every remote sandbox session runs in. Other modes never consult them. + let gateway_access = gateway_access_for(config, startup_owner.as_deref(), relay.rest_client()); let mut relay_observer_control_rx = None; let mut relay_observer_publisher_task = None; @@ -3111,7 +3243,7 @@ async fn run_harness( } } - if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) = + if let Some((observer, publisher, keys, agent_pubkey, _owner_pubkey, owner)) = relay_observer_publisher.take() { relay_observer_publisher_task = Some(spawn_relay_observer_publisher( @@ -3119,8 +3251,8 @@ async fn run_harness( publisher, keys, agent_pubkey, - owner_pubkey, owner, + gateway_access.clone(), )); } @@ -3150,12 +3282,15 @@ async fn run_harness( ); } - let ctx = Arc::new(runtime.prompt_context( + let mut prompt_context = runtime.prompt_context( relay.rest_client(), channel_info_map, SessionMode::Conversation, - )?); + )?; + prompt_context.gateways = gateway_access.clone(); + let ctx = Arc::new(prompt_context); author_gate_ctx.enable_sharing(ctx.sharing.clone()); + author_gate_ctx.enable_gateways(ctx.gateways.clone()); let _shared_notice_worker = ctx .sharing .as_ref() @@ -3550,12 +3685,19 @@ async fn run_harness( match control_event { Some(event) => { if let Some(ref owner_hex) = owner_cache.pubkey { + let gateway = delegated_control_sender( + gateway_access.as_deref(), + &event, + owner_hex, + ) + .await; handle_relay_observer_control_event( &config.keys, event, &mut pool, observer.as_ref(), owner_hex, + gateway, relay.event_publisher(), config.bound_session.as_ref().map(|binding| binding.channel_id()), ); @@ -9637,8 +9779,8 @@ mod observer_snapshot_race_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, ) .await; @@ -10361,8 +10503,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); // t=0: nothing may publish, no matter how full the snapshot was. @@ -10440,8 +10582,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); settle().await; @@ -10506,8 +10648,8 @@ mod observer_publish_cadence_tests { publisher, agent_keys.clone(), agent_keys.public_key().to_hex(), - owner_keys.public_key().to_hex(), owner_keys.public_key(), + None, )); settle().await; diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0a86526415b..d51399ae0fc 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -936,6 +936,9 @@ pub struct PromptContext { pub memory_enabled: bool, /// Fresh authorization for bound shared-channel tasks. pub sharing: Option>, + /// Live owner-delegated gateways, set only in strict owner mode. A queued + /// gateway turn is rechecked against it at dispatch. + pub(crate) gateways: Option>, /// Harness identity string for NIP-AM `harness` field. Derived from the /// configured `agent_command` at startup (e.g. `"goose"`, `"buzz-agent"`). pub harness_name: String, @@ -2514,24 +2517,10 @@ pub async fn run_prompt_task( // Queueing may outlive a grant. Recheck before session creation, memory // fetches, or any ACP prompt; the listener separately guards native steering. - if let (Some(sharing), Some(pending)) = (&ctx.sharing, &batch) { - let mut denied = None; - for item in &pending.events { - if Some(item.event.pubkey) == ctx.agent_owner_pubkey { - continue; - } - match sharing.authorize(&item.event).await { - Ok(crate::sharing::Admission::Allowed(_)) => {} - Ok(crate::sharing::Admission::Refused(reason)) => { - denied = Some(reason); - break; - } - Err(error) => { - denied = Some(error.to_string()); - break; - } - } - } + // A gateway turn is rechecked the same way: a revocation or expiry blocks + // it here even when it was admitted before. + if let (true, Some(pending)) = (ctx.sharing.is_some() || ctx.gateways.is_some(), &batch) { + let denied = dispatch_refusal(&ctx, pending).await; if let Some(reason) = denied { send_prompt_result( &result_tx, @@ -4941,6 +4930,39 @@ fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option Option { + for item in &pending.events { + if Some(item.event.pubkey) == ctx.agent_owner_pubkey { + continue; + } + let mut gateway_error = None; + if let Some(gateways) = &ctx.gateways { + match gateways.is_live(&item.event.pubkey).await { + Ok(true) => continue, + Ok(false) => {} + Err(error) => gateway_error = Some(error.to_string()), + } + } + let Some(sharing) = &ctx.sharing else { + return Some(gateway_error.unwrap_or_else(|| { + "the requester is not the owner or a live owner-delegated gateway".into() + })); + }; + match sharing.authorize(&item.event).await { + Ok(crate::sharing::Admission::Allowed(_)) => {} + Ok(crate::sharing::Admission::Refused(reason)) => return Some(reason), + Err(error) => return Some(error.to_string()), + } + } + None +} + /// Return the batch for requeue only in Queue mode; drop it in Drop mode. #[inline] fn requeue_batch_if_queue(ctx: &PromptContext, batch: Option) -> Option { @@ -10526,6 +10548,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" agent_owner_pubkey: owner_pubkey, memory_enabled: false, sharing: None, + gateways: None, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), } diff --git a/crates/buzz-acp/src/runtime.rs b/crates/buzz-acp/src/runtime.rs index 292d7f410ae..32252fbdecc 100644 --- a/crates/buzz-acp/src/runtime.rs +++ b/crates/buzz-acp/src/runtime.rs @@ -126,6 +126,7 @@ fn make_prompt_context( .and_then(|hex| nostr::PublicKey::from_hex(hex).ok()), memory_enabled: config.memory_enabled, sharing, + gateways: None, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), }) diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index 29ec19a04c2..f1fb50c414e 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -77,6 +77,12 @@ buzz users set-status --clear # remove your status buzz dms open --pubkey buzz dms list +# Gateway delegation (run as the human owner) +# Admit the gateway into the relay and channel separately before use. +buzz gateway grant --expires 90d +buzz gateway list +buzz gateway revoke + # Workflows buzz workflows list --channel buzz workflows trigger --workflow @@ -110,6 +116,11 @@ buzz channels list | jq '.[].name' constraint omitted from the command is removed. `protect list` reports malformed stored rules in `validation_error` so an owner can remove and repair them. +Gateway grants drive only your remote agents in bound channel threads. They +provide no access to your DMs, profile, or channel administration. See +[Gateway keys](../../docs/agent-gateway-key.md) for membership prerequisites, +revocation timing, and rollout. + ## Commands `buzz --help` prints this whole surface as an indented tree — every group with @@ -157,6 +168,9 @@ The table below mirrors that tree for readers who are not at a terminal. | | `presence` | Get presence status | | | `set-presence` | Set presence status | | | `set-status` | Set or clear your NIP-38 profile status | +| `gateway` | `grant` | Delegate a separate key for at most 90 days | +| `gateway` | `list` | Read the latest 32 delegation heads | +| `gateway` | `revoke` | Publish a durable revocation head | | `workflows` | `list` | List workflows | | | `get` | Get workflow definition | | | `create` | Create a workflow | diff --git a/crates/buzz-cli/src/commands/gateway.rs b/crates/buzz-cli/src/commands/gateway.rs new file mode 100644 index 00000000000..5ba7710d3fa --- /dev/null +++ b/crates/buzz-cli/src/commands/gateway.rs @@ -0,0 +1,141 @@ +//! Owner-managed gateway delegations. Membership remains independently managed. + +use buzz_sdk::gateway::{Delegation, MAX_DELEGATION_SECS}; +use nostr::{Event, PublicKey, Timestamp}; +use serde_json::json; + +use crate::{client::BuzzClient, error::CliError, GatewayCmd}; + +/// Run a gateway command using the human owner's signing identity. +pub async fn dispatch(command: GatewayCmd, client: &BuzzClient) -> Result<(), CliError> { + if client.auth_tag_owner_hex().is_some() { + return Err(CliError::Auth( + "gateway delegations require the owner's key".into(), + )); + } + match command { + GatewayCmd::List => { + let result = client + .query(&json!({ + "consistency": "strong", + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [client.keys().public_key()], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + })) + .await?; + println!("{result}"); + Ok(()) + } + GatewayCmd::Grant { pubkey, expires } => { + let gateway = parse_key(&pubkey)?; + if gateway == client.keys().public_key() { + return Err(CliError::Usage("the gateway must be a separate key".into())); + } + let duration = duration(&expires)?; + let now = Timestamp::now().as_secs(); + let created_at = next_timestamp(client, gateway, now).await?; + let expires_at = now + .checked_add(duration) + .ok_or_else(|| CliError::Usage("gateway expiry is out of range".into()))?; + let builder = Delegation::grant(gateway, expires_at, created_at) + .map_err(|error| CliError::Usage(error.to_string()))?; + publish(client, builder).await + } + GatewayCmd::Revoke { pubkey } => { + let gateway = parse_key(&pubkey)?; + let created_at = next_timestamp(client, gateway, Timestamp::now().as_secs()).await?; + let builder = Delegation::revoke(gateway, created_at) + .map_err(|error| CliError::Usage(error.to_string()))?; + publish(client, builder).await + } + } +} + +fn parse_key(value: &str) -> Result { + PublicKey::parse(value).map_err(|_| CliError::Usage("invalid gateway public key".into())) +} + +fn duration(value: &str) -> Result { + let (number, multiplier) = if let Some(number) = value.strip_suffix('w') { + (number, 7 * 86_400) + } else if let Some(number) = value.strip_suffix('d') { + (number, 86_400) + } else if let Some(number) = value.strip_suffix('h') { + (number, 3_600) + } else if let Some(number) = value.strip_suffix('m') { + (number, 60) + } else if let Some(number) = value.strip_suffix('s') { + (number, 1) + } else { + return Err(CliError::Usage("use an expiry such as 24h or 90d".into())); + }; + let seconds = number + .parse::() + .ok() + .and_then(|n| n.checked_mul(multiplier)); + match seconds { + Some(seconds) if seconds > 0 && seconds <= MAX_DELEGATION_SECS => Ok(seconds), + _ => Err(CliError::Usage( + "gateway expiry must be positive and at most 90d".into(), + )), + } +} + +/// A replacement must be newer than the head it replaces, including when +/// grant and revoke run during the same second. Refuse implausible future +/// heads rather than silently publishing a dominated revocation. +async fn next_timestamp( + client: &BuzzClient, + gateway: PublicKey, + now: u64, +) -> Result { + let result = client + .query(&json!({ + "consistency": "strong", + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [client.keys().public_key()], + "#d": [gateway.to_hex()], "limit": 1, + })) + .await?; + let heads: Vec = serde_json::from_str(&result) + .map_err(|_| CliError::Other("invalid delegation head response".into()))?; + if heads.len() > 1 { + return Err(CliError::Other("too many delegation heads returned".into())); + } + let Some(head) = heads.first() else { + return Ok(now); + }; + let coordinate = head.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" => Some(value.as_str()), + _ => None, + }); + if head.pubkey != client.keys().public_key() + || head.kind.as_u16() as u32 != buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION + || coordinate != Some(gateway.to_hex().as_str()) + || head.verify().is_err() + { + return Err(CliError::Other( + "relay returned an unrelated delegation head".into(), + )); + } + let next = now.max(head.created_at.as_secs().saturating_add(1)); + if next > now.saturating_add(buzz_sdk::gateway::MAX_FUTURE_SKEW_SECS) { + return Err(CliError::Conflict( + "existing delegation is too far in the future; check the owner's clock".into(), + )); + } + Ok(next) +} + +async fn publish(client: &BuzzClient, builder: nostr::EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let response = client.submit_event(event).await?; + println!( + "{}", + super::parse_write_response(&response, "delegation changed concurrently; retry")? + ); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-cli/src/commands/gateway/tests.rs b/crates/buzz-cli/src/commands/gateway/tests.rs new file mode 100644 index 00000000000..ab4ef10c556 --- /dev/null +++ b/crates/buzz-cli/src/commands/gateway/tests.rs @@ -0,0 +1,143 @@ +use super::*; +use axum::{extract::State, routing::post, Json, Router}; +use nostr::{EventBuilder, Keys, Kind}; +use std::sync::{Arc, Mutex}; + +#[tokio::test] +async fn grant_revoke_and_regrant_replace_heads_and_refuse_agent_credentials() { + let owner = Keys::generate(); + let gateway = Keys::generate(); + let heads = Arc::new(Mutex::new(Vec::::new())); + let app = Router::new() + .route( + "/query", + post( + |State(heads): State>>>, + Json(filters): Json| async move { + assert_eq!(filters[0]["consistency"], json!("strong")); + assert_eq!( + filters[0]["kinds"], + json!([buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION]) + ); + Json( + heads + .lock() + .unwrap() + .last() + // Match an honest relay's author filter for grant heads; + // metadata is an intentionally corrupt response below. + .filter(|head| { + head.kind == Kind::Metadata + || filters[0]["authors"] == json!([head.pubkey]) + }) + .cloned() + .into_iter() + .collect::>(), + ) + }, + ), + ) + .route( + "/events", + post( + |State(heads): State>>>, Json(event): Json| async move { + event.verify().unwrap(); + let id = event.id; + heads.lock().unwrap().push(event); + Json(json!({"event_id":id,"accepted":true,"message":""})) + }, + ), + ) + .with_state(heads.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = BuzzClient::new(url.clone(), owner.clone(), None, None).unwrap(); + let grant = || GatewayCmd::Grant { + pubkey: gateway.public_key().to_hex(), + expires: "2w".into(), + }; + dispatch(grant(), &client).await.unwrap(); + dispatch( + GatewayCmd::Revoke { + pubkey: gateway.public_key().to_hex(), + }, + &client, + ) + .await + .unwrap(); + dispatch(grant(), &client).await.unwrap(); + { + let heads = heads.lock().unwrap(); + assert_eq!(heads.len(), 3); + for (event, enabled) in heads.iter().zip([true, false, true]) { + assert_eq!(event.pubkey, owner.public_key()); + assert_eq!(Delegation::parse(event).unwrap().0.enabled, enabled); + } + assert!(heads[0].created_at < heads[1].created_at); + assert!(heads[1].created_at < heads[2].created_at); + } + for expires in ["0d", "91d", "18446744073709551615d", "forever"] { + assert!(dispatch( + GatewayCmd::Grant { + pubkey: gateway.public_key().to_hex(), + expires: expires.into() + }, + &client + ) + .await + .is_err()); + } + assert!(dispatch( + GatewayCmd::Grant { + pubkey: owner.public_key().to_hex(), + expires: "1d".into() + }, + &client + ) + .await + .is_err()); + let agent = Keys::generate(); + let auth = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "").unwrap(); + let agent_client = BuzzClient::new( + url, + agent, + Some(buzz_sdk::nip_oa::parse_auth_tag(&auth).unwrap()), + None, + ) + .unwrap(); + assert!(dispatch(grant(), &agent_client).await.is_err()); + // A verified but unrelated head must not influence the replacement time. + heads.lock().unwrap().clear(); + heads.lock().unwrap().push( + EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&owner) + .unwrap(), + ); + assert!(dispatch(grant(), &client).await.is_err()); + // A future head must fail visibly instead of accepting a dominated revoke. + let future = Timestamp::now().as_secs() + 120; + heads.lock().unwrap().clear(); + heads.lock().unwrap().push( + Delegation::grant(gateway.public_key(), future + 3_600, future) + .unwrap() + .sign_with_keys(&owner) + .unwrap(), + ); + assert!(matches!( + dispatch( + GatewayCmd::Revoke { + pubkey: gateway.public_key().to_hex() + }, + &client + ) + .await, + Err(CliError::Conflict(_)) + )); + assert_eq!( + heads.lock().unwrap().len(), + 1, + "no misleading revocation was published" + ); + server.abort(); +} diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index f7a3ebe9e0d..7c71016abd8 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod dms; pub mod emoji; pub(crate) mod event_output; pub mod feed; +pub mod gateway; pub mod gifs; pub mod issues; pub mod mem; diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index f4d000ca206..77ddbc168f3 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -263,6 +263,9 @@ enum Cmd { /// Draft owner-reviewed agent creation and updates #[command(subcommand)] Agents(AgentsCmd), + /// Delegate a separate key to drive your remote agents + #[command(subcommand)] + Gateway(GatewayCmd), /// Send, read, search, and manage messages #[command(subcommand)] Messages(MessagesCmd), @@ -349,6 +352,26 @@ impl RespondToArg { } } +/// Manage owner-signed, expiring gateway delegations. +#[derive(Subcommand)] +pub enum GatewayCmd { + /// Delegate a gateway key (membership must be granted separately) + Grant { + /// Gateway public key (hex or npub) + pubkey: String, + /// Lifetime, such as 24h or 90d (maximum 90d) + #[arg(long, default_value = "90d")] + expires: String, + }, + /// Read your delegation heads, including revocations and expired heads + List, + /// Revoke a gateway with a durable replacement head + Revoke { + /// Gateway public key (hex or npub) + pubkey: String, + }, +} + #[derive(Subcommand)] pub enum AgentsCmd { /// Delegate work to another shared agent, retaining the incoming task's provenance @@ -2316,6 +2339,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { match cli.command { Cmd::Agents(sub) => commands::agents::dispatch(sub, &client).await, + Cmd::Gateway(sub) => commands::gateway::dispatch(sub, &client).await, Cmd::Messages(sub) => commands::messages::dispatch(sub, &client, &cli.format).await, Cmd::Channels(sub) => commands::channels::dispatch(sub, &client, &cli.format).await, Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await, @@ -2512,6 +2536,7 @@ mod tests { "dms", "emoji", "feed", + "gateway", "gifs", "issues", "media", @@ -2731,6 +2756,7 @@ mod tests { ("dms", 4), ("emoji", 5), ("feed", 1), + ("gateway", 3), ("issues", 6), ("media", 1), ("messages", 8), diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 9f66666467f..23bbb207535 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -345,6 +345,17 @@ pub const KIND_MANAGED_AGENT: u32 = 30177; /// absent grants never authorize another member. Credentials are not included. pub const KIND_AGENT_CHANNEL_GRANT: u32 = 30180; +/// Owner-signed delegation of a gateway key (NIP-33, coordinate = gateway hex). +/// +/// A live head lets the named gateway key drive the signer's own remote +/// agents: mention-launch them, prompt them in their threads, cancel a turn, +/// and receive their observer telemetry. It never makes the gateway an owner +/// or an agent anywhere else. Every enabled head carries a NIP-40 +/// `expiration`. Revocation is a newer disabled head; the relay ignores NIP-09 +/// deletions of this kind so an old signed grant cannot be replayed into an +/// empty coordinate. See `docs/agent-gateway-key.md`. +pub const KIND_AGENT_GATEWAY_DELEGATION: u32 = 30181; + /// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). /// /// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` @@ -727,6 +738,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_AGENT_CHANNEL_GRANT, + KIND_AGENT_GATEWAY_DELEGATION, KIND_TEAM_CATALOG, KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, @@ -943,6 +955,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 300 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_AGENT_GATEWAY_DELEGATION)); // 30181 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index dce1a624c54..b96fbb8f467 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -10225,6 +10225,47 @@ pub(crate) mod postgres_tests { "Probe 3 FAIL — unknown consistency value must be rejected with 400" ); + // The WS extension must reach the same writer boundary. The parser, + // connection options and actual REQ history dispatcher stay real. + let ws = CancelFixture { + admin: admin.clone(), + writer: writer_pool.clone(), + replica: replica_pool.clone(), + search: writer_pool.clone(), + names: [writer_name.clone(), replica_name.clone()], + state: state.clone(), + host: host.clone(), + community: CommunityId::from_uuid(community), + channel: channel_id.to_string(), + root: canvas_ev.id.to_hex(), + reader: author.clone(), + }; + rt.block_on(async { + for (strong, expected) in [(false, 0), (true, 1)] { + let mut filter = serde_json::json!({"kinds":[buzz_core::kind::KIND_CANVAS], "#h":[channel_id], "limit":10}); + if strong { filter["consistency"] = serde_json::json!("strong"); } + let raw = serde_json::json!(["REQ", "strong-ws", filter]).to_string(); + let crate::protocol::ClientMessage::Req { sub_id, filters, before_ids, writer_reads } = + crate::protocol::ClientMessage::parse(&raw).unwrap() else { panic!("expected REQ"); }; + let (conn, mut rx) = ws.ws_conn(); + crate::handlers::req::handle_req_consistent(sub_id, filters, before_ids, writer_reads, conn.clone(), state.clone()).await; + let mut delivered = 0; + let mut eose = false; + while let Ok(axum::extract::ws::Message::Text(frame)) = rx.try_recv() { + let value: serde_json::Value = serde_json::from_str(frame.as_str()).unwrap(); + match value[0].as_str() { + Some("EVENT") => delivered += 1, + Some("EOSE") => eose = true, + _ => panic!("unexpected WS frame: {frame}"), + } + } + assert!(eose, "the WS history read completed"); + assert_eq!(delivered, expected, "WS writer pin strong={strong}"); + crate::handlers::close::handle_close("strong-ws".into(), conn, state.clone()).await; + } + }); + drop(ws); + // --- teardown -------------------------------------------------------- rt.block_on(async { let admin2 = PgPool::connect(&admin_url) diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index c2970fb4435..c29543c7af0 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -1418,6 +1418,7 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar sub_id, filters, before_ids, + writer_reads, } => { let conn = Arc::clone(&conn); let state = Arc::clone(&state); @@ -1445,7 +1446,15 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar let span = tracing::info_span!("ws.req", conn_id = %conn.conn_id, sub_id = %sub_id); tokio::spawn( async move { - handlers::req::handle_req(sub_id, filters, before_ids, conn, state).await; + handlers::req::handle_req_consistent( + sub_id, + filters, + before_ids, + writer_reads, + conn, + state, + ) + .await; drop(permit); } .instrument(span), diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index c21901e7ac3..b9ac014a441 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1417,7 +1417,30 @@ async fn handle_agent_observer_event( } } }; - if !is_owner { + // Not the owner: the counterpart may still be a live gateway the agent's + // owner delegated (control from it, telemetry to it). A lookup failure + // refuses the frame. + let is_authorized = is_owner + || match gateway_observer_authorized( + &state, + conn.tenant.community(), + route.agent, + route.owner, + ) + .await + { + Ok(authorized) => authorized, + Err(e) => { + warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer gateway check failed: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + }; + if !is_authorized { reject("auth"); conn.send(RelayMessage::ok( event_id_hex, @@ -1467,6 +1490,85 @@ async fn handle_agent_observer_event( conn.send(RelayMessage::ok(event_id_hex, true, "")); } +/// Longest a cached observer gateway verdict is trusted, which bounds how long +/// a revoked delegation keeps routing frames on another replica. +pub(crate) const GATEWAY_VERDICT_TTL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Whether `gateway` may exchange observer frames with `agent`: the agent's +/// recorded owner has a live kind 30181 delegation naming `gateway`, and +/// `gateway` is not itself an agent the relay has recorded. An owned agent +/// therefore never passes as a gateway, and an agent without a recorded owner +/// has none. Any lookup failure propagates and the caller refuses the frame. +async fn gateway_observer_authorized( + state: &AppState, + community: CommunityId, + agent: PublicKey, + gateway: PublicKey, +) -> Result { + let now = chrono::Utc::now().timestamp().max(0) as u64; + let agent_bytes = agent.to_bytes().to_vec(); + let gateway_bytes = gateway.to_bytes().to_vec(); + let key = (community, agent_bytes.clone(), gateway_bytes.clone()); + // Read the generation before any lookup: a head stored meanwhile bumps it, + // so the verdict computed below is ignored on the next frame. + let generation = state + .gateway_generation + .load(std::sync::atomic::Ordering::Acquire); + if let Some(cached) = state.observer_gateway_cache.get(&key) { + if cached.generation == generation && cached.valid_until > now { + return Ok(cached.authorized); + } + } + let users = state + .db + .get_agent_channel_policies(community, &[agent_bytes.clone(), gateway_bytes.clone()]) + .await?; + let owner = users + .get(&agent_bytes) + .and_then(|(_, owner)| owner.as_deref()) + .and_then(|owner| PublicKey::from_slice(owner).ok()); + let gateway_is_agent = users + .get(&gateway_bytes) + .is_some_and(|(_, owner)| owner.is_some()); + let live = match owner { + Some(owner) if !gateway_is_agent => { + let heads = state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION as i32]), + authors: Some(vec![owner.to_bytes().to_vec()]), + limit: Some(buzz_sdk::gateway::LOOKUP_LIMIT as i64), + global_only: true, + ..buzz_db::EventQuery::for_community(community) + }) + .await?; + let heads: Vec = heads.into_iter().map(|stored| stored.event).collect(); + buzz_sdk::gateway::resolve(owner, &heads, &[], &[owner, agent], now) + .into_iter() + .find(|live| live.gateway() == gateway) + } + _ => None, + }; + let cached = GatewayVerdict { + authorized: live.is_some(), + generation, + // A positive verdict never outlives the delegation's own expiry. + valid_until: live.map_or(u64::MAX, |live| live.expires_at()), + }; + state.observer_gateway_cache.insert(key, cached); + Ok(cached.authorized) +} + +/// A cached observer gateway verdict; see [`gateway_observer_authorized`]. +#[derive(Debug, Clone, Copy)] +pub(crate) struct GatewayVerdict { + authorized: bool, + /// [`AppState::gateway_generation`] read before the lookup. + generation: u64, + /// Unix seconds after which the verdict is recomputed. + valid_until: u64, +} + fn agent_observer_route(event: &Event) -> Result, String> { if !content_looks_like_nip44(&event.content) { return Err("invalid: observer content must be NIP-44 encrypted".into()); @@ -3969,6 +4071,182 @@ mod tests { ); } + /// Every observer path a delegated gateway gets, and the boundaries + /// around it: control from the gateway and telemetry to it route only + /// while the agent's recorded owner has a live kind 30181 head naming + /// it; an owned agent never passes as a gateway; another owner's agent + /// is unreachable; a newer disabled head revokes at once; NIP-09 + /// deletion cannot empty the coordinate for a replayed old grant. + /// Mutations: return `is_owner` alone (no gateway branch) → RED at + /// "live gateway"; drop `!gateway_is_agent` → RED at "sibling"; + /// drop the generation bump → RED at "revoked"; drop the deletion + /// guard → RED at "replay". + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn gateway_observer_frames_follow_the_live_delegation() { + use buzz_core::kind::{KIND_AGENT_GATEWAY_DELEGATION, KIND_AGENT_OBSERVER_FRAME}; + use buzz_sdk::gateway::Delegation; + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + let state = crate::state::tests::test_state().await; + let host = format!("ws-gateway-{}.test", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("ensure community") + .id; + let tenant = buzz_core::tenant::TenantContext::resolved(community, host); + let (owner, agent, sibling, gateway, stranger, foreign_agent) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + for keys in [ + &owner, + &agent, + &sibling, + &gateway, + &stranger, + &foreign_agent, + ] { + state + .db + .ensure_user(community, keys.public_key().as_bytes()) + .await + .expect("ensure user"); + } + for (owned, by) in [(&agent, &owner), (&foreign_agent, &stranger)] { + state + .db + .set_agent_owner( + community, + owned.public_key().as_bytes(), + by.public_key().as_bytes(), + ) + .await + .expect("set agent owner"); + } + let frame = |from: &Keys, to: &Keys, agent: &Keys, direction: &str| { + let content = super::encrypt_observer_payload( + from, + &to.public_key(), + &serde_json::json!({"type": "cancel_turn"}), + ) + .expect("encrypt"); + EventBuilder::new(Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), content) + .tags([ + Tag::parse(["p", &to.public_key().to_hex()]).unwrap(), + Tag::parse([super::OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]) + .unwrap(), + Tag::parse([super::OBSERVER_FRAME_TAG, direction]).unwrap(), + ]) + .sign_with_keys(from) + .expect("sign frame") + }; + let control = + |from: &Keys, to: &Keys| frame(from, to, to, super::OBSERVER_FRAME_CONTROL); + let telemetry = + |from: &Keys, to: &Keys| frame(from, to, from, super::OBSERVER_FRAME_TELEMETRY); + let accepted = |text: &str| text.contains(",true,"); + async fn ok_frame_scoped_owner( + state: &std::sync::Arc, + tenant: &buzz_core::tenant::TenantContext, + keys: &Keys, + event: nostr::Event, + ) -> String { + ok_frame_scoped( + state, + tenant, + keys, + event, + buzz_auth::Scope::all_non_admin(), + ) + .await + } + let now = Timestamp::now().as_secs(); + let grant = |to: &Keys, created_at: u64| { + Delegation::grant(to.public_key(), created_at + 3_600, created_at) + .unwrap() + .sign_with_keys(&owner) + .unwrap() + }; + + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(!accepted(&text), "undelegated gateway control: {text}"); + + let old_grant = grant(&gateway, now - 10); + let text = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant.clone()).await; + assert!(accepted(&text), "grant stored: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(accepted(&text), "live gateway control: {text}"); + let text = ok_frame(&state, &tenant, &agent, telemetry(&agent, &gateway)).await; + assert!(accepted(&text), "live gateway telemetry: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &foreign_agent)).await; + assert!(!accepted(&text), "another owner's agent: {text}"); + + let text = + ok_frame_scoped_owner(&state, &tenant, &owner, grant(&sibling, now - 9)).await; + assert!(accepted(&text), "sibling grant stored: {text}"); + // Becoming an agent after the grant still prevents gateway use. + state + .db + .set_agent_owner( + community, + sibling.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let text = + ok_frame_scoped_owner(&state, &tenant, &owner, grant(&sibling, now - 8)).await; + assert!( + !accepted(&text), + "known agent cannot receive a new delegation: {text}" + ); + let text = ok_frame(&state, &tenant, &sibling, control(&sibling, &agent)).await; + assert!( + !accepted(&text), + "sibling must not pass as a gateway: {text}" + ); + + let revocation = Delegation::revoke(gateway.public_key(), now - 5) + .unwrap() + .sign_with_keys(&owner) + .unwrap(); + let text = ok_frame_scoped_owner(&state, &tenant, &owner, revocation).await; + assert!(accepted(&text), "revocation stored: {text}"); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!(!accepted(&text), "revoked gateway control: {text}"); + + let coordinate = format!( + "{KIND_AGENT_GATEWAY_DELEGATION}:{}:{}", + owner.public_key().to_hex(), + gateway.public_key().to_hex() + ); + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tag(Tag::parse(["a", &coordinate]).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, deletion).await; + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant).await; + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!( + !accepted(&text), + "replayed old grant after deletion: {text}" + ); + + let malformed = + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), "{}") + .tag(Tag::parse(["d", &gateway.public_key().to_hex()]).unwrap()) + .sign_with_keys(&owner) + .unwrap(); + let text = ok_frame_scoped_owner(&state, &tenant, &owner, malformed).await; + assert!(!accepted(&text), "malformed head refused at ingest: {text}"); + } + /// Fresh community with one unrestricted user. async fn unrestricted_fixture() -> ( std::sync::Arc, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index acff4b7c3eb..1608fd3d0f8 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -552,6 +552,8 @@ fn map_push_accept_error(error: super::push_lease::AcceptError) -> IngestError { fn required_scope_for_kind(kind: u32, event: &Event) -> Result { match kind { buzz_core::kind::KIND_AGENT_CHANNEL_GRANT => Ok(Scope::MessagesWrite), + // Owner-managed global authority, like a profile or managed-agent record. + buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION => Ok(Scope::UsersWrite), KIND_PROFILE => Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM | buzz_core::kind::KIND_ARTIFACT => Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM @@ -786,6 +788,8 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_MANAGED_AGENT | KIND_PRIVATE_MANAGED_AGENT | KIND_TEAM_CATALOG + // Gateway delegations (30181) are owner-wide, never channel-scoped. + | buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -2823,6 +2827,34 @@ async fn ingest_event_inner( )); } + // Consumers verify every head themselves; ingest only refuses heads that + // could never be live or revoke, so a coordinate always holds a parseable + // head. + if kind_u32 == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + let (delegation, _) = buzz_sdk::gateway::Delegation::parse(&event) + .map_err(|_| IngestError::Rejected("invalid: malformed gateway delegation".into()))?; + if delegation.enabled { + let policies = state + .db + .get_agent_channel_policies( + tenant.community(), + &[ + event.pubkey.to_bytes().to_vec(), + delegation.gateway.to_bytes().to_vec(), + ], + ) + .await + .map_err(|error| IngestError::Internal(error.to_string()))?; + if policies.values().any(|(_, owner)| owner.is_some()) + || buzz_sdk::gateway::profile_claims_agent(&event) + { + return Err(IngestError::Rejected( + "restricted: agent keys cannot grant or receive gateway authority".into(), + )); + } + } + } + // A verified owner must be able to revoke after removal or archive. // Enabling still requires explicit membership in the grant validator. let disabled_agent_grant = if kind_u32 == buzz_core::kind::KIND_AGENT_CHANNEL_GRANT { @@ -3745,6 +3777,13 @@ async fn ingest_event_inner( }); } + // Fence observer gateway verdicts computed before this committed head. + if kind_u32 == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + state + .gateway_generation + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + // Workflow deletion, kind 5 / 9005 deletions of `e` targets, kind:9000 and // kind:9002 applied their changes in the insert's own transaction above. let persisted_atomically = workflow_deletion diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 6a4857715e9..dd06620a897 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -59,6 +59,18 @@ pub async fn handle_req( before_ids: Vec>>, conn: Arc, state: Arc, +) { + handle_req_consistent(sub_id, filters, before_ids, Vec::new(), conn, state).await; +} + +/// Handle a subscription with per-filter writer-pinned authority reads. +pub(crate) async fn handle_req_consistent( + sub_id: String, + filters: Vec, + before_ids: Vec>>, + writer_reads: Vec, + conn: Arc, + state: Arc, ) { let mut access = crate::access::ReadLog::req(&conn, &sub_id, &filters); let (conn_id, pubkey_bytes, token_channel_ids) = { @@ -505,8 +517,13 @@ pub async fn handle_req( let mut results = stream::iter(filter_queries.into_iter().map( |(idx, per_filter_channel, params)| { let db = db.clone(); + let writer_read = writer_reads.get(idx).copied().unwrap_or(false); async move { - let filter_events = db.query_events_routed("req_historical", ¶ms).await; + let filter_events = if writer_read { + db.query_events(¶ms).await + } else { + db.query_events_routed("req_historical", ¶ms).await + }; (idx, per_filter_channel, filter_events) } }, diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 38b6dbc38ab..98b63eab2ef 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3351,6 +3351,11 @@ async fn handle_a_tag_deletion( super::push_lease::KIND_PUSH_LEASE => { tracing::debug!(d_tag, "NIP-09 deletion ignored for push lease"); } + // Revocation is exclusively a newer disabled head. Deleting a head would + // empty the coordinate and let anyone replay an older signed grant. + buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION => { + tracing::debug!(d_tag, "NIP-09 deletion ignored for gateway delegation"); + } buzz_core::kind::KIND_WORKFLOW_DEF => { // Workflow deletion belongs to the atomic persistence path in ingest. return Err(anyhow::anyhow!( @@ -3548,6 +3553,13 @@ async fn plan_standard_deletion_targets( ); continue; } + if u32::from(target.event.kind.as_u16()) == buzz_core::kind::KIND_AGENT_GATEWAY_DELEGATION { + tracing::debug!( + target_id = %hex::encode(&target_id), + "NIP-09 deletion ignored for gateway delegation" + ); + continue; + } planned.push(PlannedDeletionTarget::load(tenant, state, target_id, target).await?); } Ok(planned) diff --git a/crates/buzz-relay/src/protocol.rs b/crates/buzz-relay/src/protocol.rs index 58c1a188a91..c9383327870 100644 --- a/crates/buzz-relay/src/protocol.rs +++ b/crates/buzz-relay/src/protocol.rs @@ -24,6 +24,8 @@ pub enum ClientMessage { filters: Vec, /// Optional per-filter composite cursor tiebreaks from raw extension fields. before_ids: Vec>>, + /// Per-filter authority reads pinned to the writer by `consistency:strong`. + writer_reads: Vec, }, /// A REQ whose single filter is a NIP-AR sync (`"artifact":"current"`). /// An invalid sync filter is answered with `CLOSED`, so the parse error @@ -173,10 +175,21 @@ impl ClientMessage { Ok(Some(bytes)) }) .collect::>>()?; + let writer_reads = filter_values + .iter() + .map(|filter| match filter.get("consistency") { + None => Ok(false), + Some(Value::String(value)) if value == "strong" => Ok(true), + Some(_) => Err(RelayError::InvalidMessage( + "consistency must be \"strong\" when present".into(), + )), + }) + .collect::>>()?; Ok(ClientMessage::Req { sub_id, filters, before_ids, + writer_reads, }) } "COUNT" => { @@ -437,6 +450,27 @@ mod tests { } } + #[test] + fn req_consistency_preserves_filter_alignment_and_rejects_unknown_modes() { + let value = serde_json::json!(["REQ", "authority", {"kinds":[9]}, + {"kinds":[30181], "consistency":"strong"}, {"kinds":[0]}]); + let ClientMessage::Req { writer_reads, .. } = + ClientMessage::parse(&value.to_string()).unwrap() + else { + panic!("expected REQ"); + }; + assert_eq!(writer_reads, vec![false, true, false]); + for bad in [ + serde_json::json!("eventual"), + serde_json::json!(true), + serde_json::json!(null), + ] { + let value = + serde_json::json!(["REQ", "authority", {"kinds":[30181], "consistency":bad}]); + assert!(ClientMessage::parse(&value.to_string()).is_err()); + } + } + #[test] fn parse_req_composite_cursor_preserves_filter_alignment() { let raw = serde_json::json!([ diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 11abba15963..691ba63becf 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1547,6 +1547,17 @@ pub struct AppState { /// for every concurrently active agent so per-request HTTP agents hit. #[allow(clippy::type_complexity)] pub observer_owner_cache: Arc, Vec), bool>>, + /// Observer-frame gateway verdicts (kind 24200 between an agent and a key + /// its owner delegated with kind 30181). Key: (community, agent, gateway). + /// A short TTL bounds revocation latency across replicas; the generation + /// fences a lookup that raced a delegation write on this replica. + #[allow(clippy::type_complexity)] + pub(crate) observer_gateway_cache: Arc< + moka::sync::Cache<(CommunityId, Vec, Vec), crate::handlers::event::GatewayVerdict>, + >, + /// Bumped after every stored gateway delegation head, so a cached verdict + /// computed before the write is ignored. + pub(crate) gateway_generation: Arc, /// Cache for the `author_type` metric label on the ingest path. /// Key: (community_id, author pubkey bytes). Value: is_agent /// (`users.agent_owner_pubkey IS NOT NULL`). The mapping is @@ -1854,6 +1865,13 @@ impl AppState { .time_to_live(std::time::Duration::from_secs(300)) .build(), ), + observer_gateway_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(crate::handlers::event::GATEWAY_VERDICT_TTL) + .build(), + ), + gateway_generation: Arc::new(AtomicU64::new(0)), author_type_cache: Arc::new( moka::sync::Cache::builder() .max_capacity(10_000) diff --git a/crates/buzz-sandbox-reaper/src/manager.rs b/crates/buzz-sandbox-reaper/src/manager.rs index 53efead6fd0..2f5b334bb90 100644 --- a/crates/buzz-sandbox-reaper/src/manager.rs +++ b/crates/buzz-sandbox-reaper/src/manager.rs @@ -463,22 +463,28 @@ impl Manager { } /// Reuse mobile start validation without manufacturing an owner control event. + /// Start the agent for one mention. The trigger's signer is the owner, a + /// live owner-delegated gateway (`delegation`, freshly verified by the + /// caller for this trigger), or a shared-task requester. pub(crate) async fn start_mention( &self, owner: PublicKey, agent: PublicKey, relay: &str, event: &Event, + delegation: Option<&buzz_sdk::gateway::Live>, ) -> serde_json::Value { let Some(developer) = self.config.developer(&owner) else { return refusal(ErrorCode::UnauthorizedOwner, "owner is not configured"); }; + let payload = + serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}); + let delegation = + delegation.filter(|live| live.owner() == owner && live.gateway() == event.pubkey); let admitted = if event.pubkey == owner { - Start::admit( - serde_json::json!({"agent_pubkey": agent, "relay_url": relay, "trigger": event}), - owner, - Timestamp::now().as_secs(), - ) + Start::admit(payload, owner, Timestamp::now().as_secs()) + } else if let Some(delegation) = delegation { + Start::delegated(payload, delegation, Timestamp::now().as_secs()) } else { let request = crate::shared_tasks::Request { relay_url: relay.into(), diff --git a/crates/buzz-sandbox-reaper/src/mentions.rs b/crates/buzz-sandbox-reaper/src/mentions.rs index 1bc76ee4d91..44019b450a4 100644 --- a/crates/buzz-sandbox-reaper/src/mentions.rs +++ b/crates/buzz-sandbox-reaper/src/mentions.rs @@ -1,8 +1,10 @@ -//! Launch registered remote workspaces from ordinary, owner-signed mentions. +//! Launch registered remote workspaces from ordinary mentions signed by the +//! owner or by a gateway key the owner has live-delegated (kind 30181). //! Clients need no control protocol. Agent-authenticated subscriptions retain //! the relay's channel access checks; credentials never leave the manager. mod channels; +mod gateways; mod journal; mod registry; pub(crate) use registry::Identity; diff --git a/crates/buzz-sandbox-reaper/src/mentions/gateways.rs b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs new file mode 100644 index 00000000000..196c17187d9 --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs @@ -0,0 +1,97 @@ +//! Read the owner's live delegated gateway keys (kind 30181) with the agent's +//! own relay identity. Every failure is an error, so callers fail closed: a +//! listener retries its session with backoff, and a launch is not started. + +use super::{registry::Identity, Error, IO_TIMEOUT}; +use buzz_sdk::gateway::Live; +use buzz_ws_client::{NostrWsConnection, RelayMessage}; +use nostr::{Event, Timestamp}; +use serde_json::{json, Value}; + +/// The owner's live gateways now, newest first, excluding the owner, this +/// agent, and any key whose profile claims agent identity. +/// +/// The subscriptions are finite reads on a short-lived connection; they are +/// left to end with it. +pub(super) async fn read( + connection: &mut NostrWsConnection, + identity: &Identity, +) -> Result, Error> { + tokio::time::timeout(IO_TIMEOUT, async { + let now = Timestamp::now().as_secs(); + let heads = fetch( + connection, + "mention-gateways", + json!({ + "kinds": [buzz_sdk::kind::KIND_AGENT_GATEWAY_DELEGATION], + "authors": [identity.owner], + "limit": buzz_sdk::gateway::LOOKUP_LIMIT, + }), + buzz_sdk::gateway::LOOKUP_LIMIT, + ) + .await?; + let candidates: Vec = buzz_sdk::gateway::live_heads(identity.owner, &heads, now) + .iter() + .map(|live| live.gateway().to_hex()) + .collect(); + if candidates.is_empty() { + return Ok(Vec::new()); + } + let count = candidates.len(); + let profiles = fetch( + connection, + "mention-gateway-profiles", + json!({"kinds": [0], "authors": candidates, "limit": count}), + count, + ) + .await?; + Ok(buzz_sdk::gateway::resolve( + identity.owner, + &heads, + &profiles, + &[identity.owner, identity.keys.public_key()], + now, + )) + }) + .await + .map_err(|_| Error::Deadline)? +} + +/// One finite REQ, read to EOSE. More than `limit` results is a relay fault. +async fn fetch( + connection: &mut NostrWsConnection, + id: &str, + filter: Value, + limit: usize, +) -> Result, Error> { + let mut filter = filter; + filter["consistency"] = json!("strong"); + connection + .send_raw(&json!(["REQ", id, filter])) + .await + .map_err(|_| Error::Relay)?; + let mut events = Vec::new(); + loop { + match connection + .next_event(IO_TIMEOUT) + .await + .map_err(|_| Error::Relay)? + { + RelayMessage::Event { + subscription_id, + event, + } if subscription_id == id => { + events.push(*event); + if events.len() > limit { + return Err(Error::Relay); + } + } + RelayMessage::Eose { subscription_id } if subscription_id == id => return Ok(events), + RelayMessage::Closed { + subscription_id, .. + } if subscription_id == id => return Err(Error::Relay), + RelayMessage::Auth { .. } => return Err(Error::Relay), + _ => {} + } + } +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests.rs b/crates/buzz-sandbox-reaper/src/mentions/tests.rs index e2a2e2f64e7..945edd1b3fa 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests.rs @@ -1,6 +1,7 @@ //! A black-box client sends only ordinary signed chat events. A real WebSocket //! drives the production supervisor/worker and provider against a fake cluster. +mod gateway; mod live_relay; mod relay; mod revoked; @@ -433,7 +434,8 @@ async fn ended_session_posts_an_ordinary_reply_without_recovering_the_sandbox() identity.owner, identity.keys.public_key(), &identity.relay, - &event + &event, + None, ) .await["ok"], true diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs new file mode 100644 index 00000000000..5bf29fc631b --- /dev/null +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs @@ -0,0 +1,91 @@ +use super::*; + +fn signed_mention(signer: &Keys, content: &str) -> Event { + EventBuilder::new(Kind::Custom(9), content) + .tags([ + Tag::parse(["h", CHANNEL]).unwrap(), + Tag::public_key(keys("22").public_key()), + ]) + .sign_with_keys(signer) + .unwrap() +} + +fn delegation(enabled: bool, age: u64) -> Event { + let now = Timestamp::now().as_secs(); + let gateway = keys("44").public_key(); + let builder = if enabled { + buzz_sdk::gateway::Delegation::grant(gateway, now + 3_600, now - age) + } else { + buzz_sdk::gateway::Delegation::revoke(gateway, now - age) + }; + builder.unwrap().sign_with_keys(&keys("33")).unwrap() +} + +/// Mention launch from a delegated gateway runs exactly like the owner's own +/// mention; an undelegated key never launches; a revocation stored after the +/// listener's snapshot is still enforced at launch time with a visible reply. +/// Mutations: drop the gateway authors from the listener filter or the +/// gateway branch in `eligible` → the first launch never happens; drop the +/// launch-time delegation check → the revoked mention launches a second +/// sandbox; let `Start::delegated` skip the signer check → stranger launches. +#[tokio::test] +async fn delegated_gateway_mentions_launch_until_revoked() { + let relay = relay::Relay::start().await; + let cluster = sandbox_fake::Shared::default(); + register(&cluster, &relay.url).await; + relay.events.lock().unwrap().push(delegation(true, 10)); + let stop = CancellationToken::new(); + let server = { + let manager = manager(&cluster); + let stop = stop.clone(); + let url = relay.url.clone(); + tokio::spawn(async move { + crate::serve::run_services(manager, &url, None, true, stop.cancelled()).await + }) + }; + relay.wait_subscription("mentions-history").await; + + let mut gateway = NostrWsConnection::connect_authenticated(&relay.url, &keys("44"), None) + .await + .unwrap(); + let mut stranger = NostrWsConnection::connect_authenticated(&relay.url, &keys("55"), None) + .await + .unwrap(); + let undelegated = signed_mention(&keys("55"), "@agent run as a stranger"); + assert!( + stranger + .send_event(undelegated.clone()) + .await + .unwrap() + .accepted + ); + let first = signed_mention(&keys("44"), "@agent run from the gateway"); + assert!(gateway.send_event(first.clone()).await.unwrap().accepted); + wait_for(|| completed(&cluster, &first)).await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); + assert!(!completed(&cluster, &undelegated), "stranger was journaled"); + + // Revoke after the listener captured its gateway snapshot. + relay.events.lock().unwrap().push(delegation(false, 5)); + let revoked = signed_mention(&keys("44"), "@agent run after revocation"); + assert!(gateway.send_event(revoked.clone()).await.unwrap().accepted); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.pubkey == keys("22").public_key() + && event.content.contains("not currently delegated") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&revoked.id.to_hex())) + }) + }) + .await; + assert_eq!( + cluster.lock().unwrap().sandboxes.len(), + 1, + "a revoked gateway must not launch" + ); + stop.cancel(); + tokio::time::timeout(BOUND, server).await.unwrap().unwrap(); + relay.stop().await; +} diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs index 5c6b24959e6..9bf87b30d06 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs @@ -175,7 +175,10 @@ async fn connection( assert!(author.is_some()); let id = input[1].as_str().unwrap().to_string(); let filter = input[2].clone(); - let allowed = |event: &Event| matches!(event.kind.as_u16(), 24210 | 30177) || event.tags.iter().any(|tag| tag.as_slice() == ["h", CHANNEL] || tag.as_slice() == ["d", CHANNEL]); + if matches!(id.as_str(), "mention-gateways" | "mention-gateway-profiles") { + assert_eq!(filter["consistency"], json!("strong")); + } + let allowed = |event: &Event| matches!(event.kind.as_u16(), 0 | 24210 | 30177 | 30181) || event.tags.iter().any(|tag| tag.as_slice() == ["h", CHANNEL] || tag.as_slice() == ["d", CHANNEL]); let mut history: Vec<_> = stored.lock().unwrap().iter().filter(|e| matches(&filter,e) && allowed(e)).cloned().collect(); history.sort_by_key(|e| std::cmp::Reverse((e.created_at, e.id))); history.truncate(filter["limit"].as_u64().unwrap_or(1000) as usize); diff --git a/crates/buzz-sandbox-reaper/src/mentions/worker.rs b/crates/buzz-sandbox-reaper/src/mentions/worker.rs index 31a10591a10..3da63337dfd 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/worker.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/worker.rs @@ -20,7 +20,9 @@ const MAX_ATTEMPTS: u8 = 3; const MAX_EVENT_BYTES: usize = 60_000; enum LaunchPermission { - Allowed, + /// Deliverable; carries the trigger signer's fresh delegation when the + /// signer is a live owner-delegated gateway. + Allowed(Option), UnsupportedConversation, NoLongerRemote, } @@ -106,12 +108,22 @@ pub(super) async fn session( tokio::time::sleep(RECYCLE_AFTER).await; return Ok(()); } + // A delegation granted later is seen on the next bounded recycle; a + // revocation is enforced again at launch, so this snapshot only widens + // what is journaled, never what starts. + let gateways: Vec = super::gateways::read(&mut connection, identity) + .await? + .iter() + .map(buzz_sdk::gateway::Live::gateway) + .collect(); let mut connected_at = Timestamp::now().as_secs(); let started = tokio::time::Instant::now(); let mut filter = json!({"kinds": [9, 40002], "#h":channels, "#p": [identity.keys.public_key()]}); if !manager.sharing_enabled() { - filter["authors"] = json!([identity.owner]); + let mut authors = vec![identity.owner]; + authors.extend(gateways.iter().copied()); + filter["authors"] = json!(authors); } let mut live = filter.clone(); live["limit"] = json!(0); @@ -156,7 +168,13 @@ pub(super) async fn session( } oldest = oldest.min(event.created_at.as_secs()); } - let actionable = eligible(identity, journal, &event, manager.sharing_enabled()); + let actionable = eligible( + identity, + journal, + &event, + manager.sharing_enabled(), + Some(&gateways), + ); tracing::debug!(event_id=%event.id, agent=%identity.keys.public_key(), actionable,"received mention"); if actionable { let persisted = tokio::time::timeout(IO_TIMEOUT, journal.enqueue(*event)) @@ -234,38 +252,72 @@ async fn history( send(connection, json!(["REQ", "mentions-history", filter])).await } -fn eligible(identity: &Identity, journal: &Journal, event: &Event, shared: bool) -> bool { +/// Whether `event` may be journaled for launch. +/// +/// `gateways` is the listener's current snapshot of live delegated gateways. +/// `None` defers that judgement for an already journaled event: delivery +/// re-verifies the delegation before any launch. +fn eligible( + identity: &Identity, + journal: &Journal, + event: &Event, + shared: bool, + gateways: Option<&[nostr::PublicKey]>, +) -> bool { if event.created_at.as_secs() < journal.state.activated || serde_json::to_vec(event).map_or(true, |v| v.len() > MAX_EVENT_BYTES) { return false; } - if event.pubkey != identity.owner { - return shared - && buzz_sdk::sharing::task::scope( - event, - identity.keys.public_key(), - Timestamp::now().as_secs(), - ) - .is_ok(); + if event.pubkey == identity.owner { + return conversation(identity, event).is_ok(); + } + let delegated = gateways.is_none_or(|gateways| gateways.contains(&event.pubkey)); + if delegated && gateway_thread(identity, event).is_ok() { + return true; } - conversation(identity, event).is_ok() + shared + && buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) + .is_ok() +} + +fn start_payload(identity: &Identity, event: &Event) -> serde_json::Value { + json!({"agent_pubkey":identity.keys.public_key(),"relay_url":identity.relay,"trigger":event}) +} + +/// The thread a non-owner signer's mention binds, as an owner mention would. +fn gateway_thread(identity: &Identity, event: &Event) -> Result<(String, String), Error> { + Start::thread_of( + start_payload(identity, event), + identity.owner, + event.pubkey, + Timestamp::now().as_secs(), + ) + .map_err(|_| Error::Registration) } fn conversation(identity: &Identity, event: &Event) -> Result<(String, String), Error> { if event.pubkey == identity.owner { - let start = Start::admit(json!({"agent_pubkey":identity.keys.public_key(),"relay_url":identity.relay,"trigger":event}), - identity.owner,Timestamp::now().as_secs()).map_err(|_|Error::Registration)?; - let (channel, root) = start.conversation(); - Ok((channel.into(), root.into())) - } else { - let scope = buzz_sdk::sharing::task::scope( - event, - identity.keys.public_key(), + let start = Start::admit( + start_payload(identity, event), + identity.owner, Timestamp::now().as_secs(), ) .map_err(|_| Error::Registration)?; + let (channel, root) = start.conversation(); + Ok((channel.into(), root.into())) + } else if let Ok(scope) = buzz_sdk::sharing::task::scope( + event, + identity.keys.public_key(), + Timestamp::now().as_secs(), + ) { Ok((scope.channel.to_string(), scope.root.to_hex())) + } else { + gateway_thread(identity, event) } } @@ -279,7 +331,13 @@ async fn drain( ) -> Result<(), Error> { while journal.state.pending.is_some() { let pending = journal.state.pending.as_ref().ok_or(Error::Storage)?; - if !eligible(identity, journal, &pending.event, manager.sharing_enabled()) { + if !eligible( + identity, + journal, + &pending.event, + manager.sharing_enabled(), + None, + ) { return Err(Error::Registration); } if pending.notice.is_some() { @@ -314,13 +372,21 @@ async fn drain( .await .map_err(|_| Error::Deadline)??; let result = match channel_launch_allowed(identity, &event).await { - Ok(LaunchPermission::Allowed) => tokio::time::timeout( + // A non-owner trigger needs a fresh live delegation unless the + // manager may still admit it as a shared task. + Ok(LaunchPermission::Allowed(None)) + if event.pubkey != identity.owner && !manager.sharing_enabled() => + { + json!({"ok": false, "code": "gateway_not_delegated"}) + } + Ok(LaunchPermission::Allowed(delegation)) => tokio::time::timeout( TURN_TIMEOUT, manager.start_mention( identity.owner, identity.keys.public_key(), &identity.relay, &event, + delegation.as_ref(), ), ) .await @@ -352,6 +418,7 @@ async fn drain( | "invalid_start" | "unsupported_conversation" | "shared_task_denied" + | "gateway_not_delegated" ) || pending.attempts >= MAX_ATTEMPTS { pending.notice = Some(failure_notice(identity, &event, code)?); @@ -430,6 +497,7 @@ fn failure_notice(identity: &Identity, event: &Event, code: &str) -> Result "Start this agent once from desktop to register its launch settings, then mention me again.", "unsupported_conversation" => "Automatic remote startup requires a channel thread. Mention me in a channel, or manage this conversation from desktop.", "shared_task_denied" => "Shared task refused: current sharing permission, channel membership, or handoff budget does not permit this request. Ask the agent owner to check sharing.", + "gateway_not_delegated" => "This gateway key is not currently delegated by my owner, so I did not start. Ask my owner to check `buzz gateway list`.", "shared_task_retry" => "Current sharing authorization is unavailable. Mention me again to retry; this request has not been allowed to run.", _ => "Agent startup could not be confirmed. Mention me again to retry.", }; @@ -458,6 +526,15 @@ async fn channel_launch_allowed( tokio::time::timeout(IO_TIMEOUT, async { let (channel, _) = conversation(identity,trigger)?; let mut connection = connect(identity).await?; + // Fresh, per launch: a revoked or expired delegation never starts. + let delegation = if trigger.pubkey == identity.owner { + None + } else { + super::gateways::read(&mut connection, identity) + .await? + .into_iter() + .find(|live| live.gateway() == trigger.pubkey) + }; send(&mut connection, json!(["REQ", "mention-trigger", {"kinds":[9,40002],"ids":[trigger.id],"#h":[channel],"limit":1}])).await?; send(&mut connection, json!(["REQ", "mention-channel", {"kinds":[39000],"#d":[channel],"limit":1}])).await?; // Query the current coordinate without a runtime filter: an old remote @@ -498,7 +575,7 @@ async fn channel_launch_allowed( if trigger_done && channel_done && listing_done { if !remote { return Ok(LaunchPermission::NoLongerRemote); } return match (message_seen, metadata) { - (true, Some(true)) => Ok(LaunchPermission::Allowed), + (true, Some(true)) => Ok(LaunchPermission::Allowed(delegation)), (true, Some(false)) => Ok(LaunchPermission::UnsupportedConversation), _ => Err(Error::Relay), }; diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch.rs b/crates/buzz-sandbox-reaper/src/mobile_launch.rs index e67ad87ffb4..90b033d0a74 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch.rs @@ -170,6 +170,48 @@ impl Start { /// Verify the owner's signed mention and derive its immutable thread scope. pub fn admit(payload: Value, owner: PublicKey, request_time: u64) -> Result { + Self::admit_signed(payload, owner, owner, request_time) + } + + /// Verify a live owner-delegated gateway's signed mention and derive its + /// immutable thread scope. The launch runs exactly as the owner's own + /// mention would: same namespace, registration, and identity policy. The + /// gateway can never address itself or the owner as the agent. + pub(crate) fn delegated( + payload: Value, + delegation: &buzz_sdk::gateway::Live, + request_time: u64, + ) -> Result { + if !delegation.is_live_at(request_time) { + return Err(invalid()); + } + Self::admit_signed( + payload, + delegation.owner(), + delegation.gateway(), + request_time, + ) + } + + /// The channel and thread a `signer`'s mention would bind, for replies and + /// durable journaling only. This confers no launch authority. + pub(crate) fn thread_of( + payload: Value, + owner: PublicKey, + signer: PublicKey, + request_time: u64, + ) -> Result<(String, String), Value> { + let start = Self::admit_signed(payload, owner, signer, request_time)?; + let (channel, root) = start.conversation(); + Ok((channel.into(), root.into())) + } + + fn admit_signed( + payload: Value, + owner: PublicKey, + signer: PublicKey, + request_time: u64, + ) -> Result { let payload: StartPayload = serde_json::from_value(payload).map_err(|_| invalid())?; let event = payload.trigger; let tags: Vec<&[String]> = event.tags.iter().map(|tag| tag.as_slice()).collect(); @@ -182,8 +224,9 @@ impl Start { && t.get(1).map(String::as_str) == Some(payload.agent_pubkey.to_hex().as_str()) }); if event.verify().is_err() - || event.pubkey != owner + || event.pubkey != signer || payload.agent_pubkey == owner + || payload.agent_pubkey == signer || !matches!(event.kind.as_u16(), 9 | 40002) || event.created_at.as_secs() > request_time.saturating_add(60) || !addressed diff --git a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs index a44b7066416..2affee457de 100644 --- a/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs +++ b/crates/buzz-sandbox-reaper/src/mobile_launch/tests.rs @@ -83,6 +83,61 @@ fn rejects_forged_wrong_owner_unaddressed_ambiguous_and_future_triggers() { assert!(Start::admit(value, owner().public_key(), event.created_at.as_secs() - 61).is_err()); } +/// A delegated start binds exactly like the owner's mention, but only for the +/// live gateway's own signature, never for an expired delegation, and never +/// with the gateway or the owner addressed as the agent. +/// Mutations: drop the `is_live_at` check → "expired" admits; check the +/// owner instead of the signer → "gateway" refuses and "owner signed" admits. +#[test] +fn delegated_start_requires_the_live_gateway_signature() { + let gateway = Keys::parse(&"44".repeat(32)).unwrap(); + let at = 1_800_000_000; + let head = |created_at: u64, expires_at: u64| { + buzz_sdk::gateway::Delegation::grant(gateway.public_key(), expires_at, created_at) + .unwrap() + .sign_with_keys(&owner()) + .unwrap() + }; + let live = buzz_sdk::gateway::live( + &head(at - 10, at + 3_600), + owner().public_key(), + gateway.public_key(), + at, + ) + .unwrap(); + let signed = |signer: &Keys, agent_key: nostr::PublicKey| { + let event = EventBuilder::new(Kind::Custom(9), "@agent investigate this") + .tags([ + Tag::parse(["h", CHANNEL]).unwrap(), + Tag::public_key(agent_key), + ]) + .custom_created_at(Timestamp::from(at)) + .sign_with_keys(signer) + .unwrap(); + json!({"agent_pubkey": agent_key, "relay_url": RELAY, "trigger": event}) + }; + let started = Start::delegated(signed(&gateway, agent().public_key()), &live, at).unwrap(); + assert_eq!(started.owner, owner().public_key()); + assert_eq!(started.agent(), agent().public_key()); + assert_eq!(started.scope["channel_id"], CHANNEL); + assert!( + Start::delegated(signed(&owner(), agent().public_key()), &live, at).is_err(), + "owner signed" + ); + assert!( + Start::delegated(signed(&Keys::generate(), agent().public_key()), &live, at).is_err(), + "stranger signed" + ); + assert!( + Start::delegated(signed(&gateway, gateway.public_key()), &live, at).is_err(), + "gateway addressed as the agent" + ); + assert!( + Start::delegated(signed(&gateway, agent().public_key()), &live, at + 3_600).is_err(), + "expired" + ); +} + #[tokio::test] async fn durable_registration_survives_manager_restart_and_rebinds_each_thread() { let state: sandbox_fake::Shared = Arc::new(Mutex::new(Default::default())); diff --git a/crates/buzz-sdk/src/gateway.rs b/crates/buzz-sdk/src/gateway.rs new file mode 100644 index 00000000000..583b20377e3 --- /dev/null +++ b/crates/buzz-sdk/src/gateway.rs @@ -0,0 +1,384 @@ +//! Owner-delegated gateway keys for driving the owner's own remote agents. +//! +//! A gateway is a separate key, for example an always-on orchestrator, that +//! the owner explicitly delegates with a signed, expiring, revocable kind +//! [`KIND_AGENT_GATEWAY_DELEGATION`] head. A live delegation lets the gateway +//! do exactly what [`Scope::RemoteAgents`] names: mention-launch the owner's +//! remote agents, prompt them in their channel threads, cancel their turns, +//! and read their observer telemetry. It never confers ownership, agent +//! identity, channel or relay membership, or access to anyone else's agents. +//! +//! Gateway authority and agent authority are disjoint by construction: +//! +//! - A gateway is named only by an owner-signed delegation head. A NIP-OA +//! `auth` attestation, which every owned agent carries, is never gateway +//! authority, and a delegation head is never agent authority. +//! - A key whose kind:0 profile carries any `auth` tag claims to be an agent, +//! so [`resolve`] refuses it as a gateway even when a delegation names it. +//! Consumers additionally refuse the owner and the consuming agent itself, +//! and the relay refuses any key it has recorded as an agent. +//! +//! Every verdict fails closed: a malformed, expired, revoked, future-dated, or +//! over-long head is "not delegated", and callers treat a lookup failure the +//! same way. Only [`live`] and [`resolve`] construct a [`Live`] value, so code +//! holding one holds proof of a verified owner signature. + +use std::collections::BTreeMap; + +use nostr::{Event, EventBuilder, Kind, PublicKey, Tag, Timestamp}; +use serde::{Deserialize, Serialize}; + +use crate::{kind::KIND_AGENT_GATEWAY_DELEGATION, SdkError}; + +/// Longest delegation lifetime, measured from the head's `created_at`. +pub const MAX_DELEGATION_SECS: u64 = 90 * 24 * 60 * 60; + +/// Most live gateways one owner may have. Consumers act on at most this many, +/// newest first, and the harness encrypts each telemetry frame once per live +/// gateway, so the cap also bounds telemetry fan-out. +pub const MAX_LIVE_GATEWAYS: usize = 2; + +/// Largest accepted clock skew for a head's `created_at` in the future. +pub const MAX_FUTURE_SKEW_SECS: u64 = 60; + +/// Most heads a consumer reads per owner in one lookup. Live heads beyond this +/// window are not seen, which fails closed. +pub const LOOKUP_LIMIT: usize = 32; + +/// What a delegation permits. Unknown scopes do not deserialize, so a future +/// scope can never widen an older consumer's acceptance. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +pub enum Scope { + /// Drive the signer's own remote agents: thread prompts, mention launch, + /// `cancel_turn` control frames, and observer telemetry. + #[serde(rename = "remote-agents")] + RemoteAgents, +} + +/// The signed content of one delegation head. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Delegation { + /// Wire version; currently one. + pub version: u32, + /// The delegated gateway key. + pub gateway: PublicKey, + /// The only permitted scope. + pub scope: Scope, + /// `false` is a durable revocation head. + pub enabled: bool, +} + +/// A verified, unexpired delegation from `owner` to `gateway`. +/// +/// Fields are private: the only constructors are [`live`] and [`resolve`], +/// which verify the owner's signature and the head's lifetime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Live { + owner: PublicKey, + gateway: PublicKey, + expires_at: u64, + created_at: u64, +} + +impl Live { + /// The delegating owner (the head's signer). + pub fn owner(&self) -> PublicKey { + self.owner + } + + /// The delegated gateway key. + pub fn gateway(&self) -> PublicKey { + self.gateway + } + + /// NIP-40 expiration, unix seconds. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// The head's `created_at`, unix seconds. + pub fn created_at(&self) -> u64 { + self.created_at + } + + /// Whether this delegation is still unexpired at `now`. + pub fn is_live_at(&self, now: u64) -> bool { + self.expires_at > now + } +} + +impl Delegation { + /// Build a grant head for `gateway` that expires at `expires_at`. + /// The caller signs with the owner's key and must set `created_at` newer + /// than any existing head for the same gateway. + pub fn grant( + gateway: PublicKey, + expires_at: u64, + created_at: u64, + ) -> Result { + if !lifetime_ok(created_at, expires_at) { + return Err(SdkError::InvalidInput(format!( + "gateway delegation must expire within {} days", + MAX_DELEGATION_SECS / 86_400 + ))); + } + Self::build(gateway, true, Some(expires_at), created_at) + } + + /// Build a revocation head for `gateway`. The caller signs with the owner's + /// key and must set `created_at` newer than the current head. + pub fn revoke(gateway: PublicKey, created_at: u64) -> Result { + Self::build(gateway, false, None, created_at) + } + + fn build( + gateway: PublicKey, + enabled: bool, + expires_at: Option, + created_at: u64, + ) -> Result { + let content = serde_json::to_string(&Self { + version: 1, + gateway, + scope: Scope::RemoteAgents, + enabled, + }) + .map_err(|e| SdkError::InvalidInput(e.to_string()))?; + let mut tags = vec![ + vec!["d".to_string(), gateway.to_hex()], + vec!["p".to_string(), gateway.to_hex()], + ]; + if let Some(expires_at) = expires_at { + tags.push(vec!["expiration".to_string(), expires_at.to_string()]); + } + let tags = tags + .into_iter() + .map(Tag::parse) + .collect::, _>>() + .map_err(|e| SdkError::InvalidTag(e.to_string()))?; + Ok( + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), content) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)), + ) + } + + /// Verify the signature, kind, version, unambiguous signed scope, and + /// lifetime shape of a head. Returns the content and its expiration. + /// + /// An enabled head must carry exactly one canonical `expiration` after its + /// `created_at` and within [`MAX_DELEGATION_SECS`]; a revocation head must + /// carry none, so no NIP-40 purge can ever remove a revocation. Liveness at + /// a given time is checked separately by [`live`]. + pub fn parse(event: &Event) -> Result<(Self, Option), SdkError> { + if u32::from(event.kind.as_u16()) != KIND_AGENT_GATEWAY_DELEGATION + || event.verify().is_err() + { + return Err(invalid("invalid delegation signature or kind")); + } + let delegation: Self = serde_json::from_str(&event.content) + .map_err(|_| invalid("invalid delegation content"))?; + if delegation.version != 1 || delegation.gateway == event.pubkey { + return Err(invalid("invalid delegation version or gateway")); + } + let gateway = delegation.gateway.to_hex(); + for name in ["d", "p"] { + match single(event, name)? { + Some(value) if value == gateway => {} + _ => return Err(invalid("ambiguous delegation scope")), + } + } + let expiration = match single(event, "expiration")? { + Some(text) => Some(canonical_u64(text)?), + None => None, + }; + match (delegation.enabled, expiration) { + (true, Some(expires_at)) if lifetime_ok(event.created_at.as_secs(), expires_at) => {} + (true, _) => { + return Err(invalid( + "an enabled delegation requires a bounded expiration", + )) + } + (false, None) => {} + (false, Some(_)) => { + return Err(invalid("a revocation must not carry an expiration")); + } + } + Ok((delegation, expiration)) + } +} + +/// The live delegation `event` carries from `owner` to `gateway` at `now`, or +/// `None`. Expired, revoked, future-dated, and over-long heads are not live. +/// +/// This judges one head in isolation. Use [`resolve`] over relay results so a +/// newer revocation for the same coordinate always wins. +pub fn live(event: &Event, owner: PublicKey, gateway: PublicKey, now: u64) -> Option { + if event.pubkey != owner { + return None; + } + let (delegation, expiration) = Delegation::parse(event).ok()?; + let expires_at = expiration?; + let created_at = event.created_at.as_secs(); + let live = delegation.enabled + && delegation.gateway == gateway + && created_at <= now.saturating_add(MAX_FUTURE_SKEW_SECS) + && expires_at > now; + live.then_some(Live { + owner, + gateway, + expires_at, + created_at, + }) +} + +/// Whether a kind:0 profile claims agent identity: any `auth` tag at all. +/// Such a key is never accepted as a gateway, whoever the claimed owner is. +pub fn profile_claims_agent(profile: &Event) -> bool { + profile + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")) +} + +/// `owner`'s live delegations in `delegations`, before the agent-identity and +/// exclusion checks of [`resolve`]: newest first, at most +/// [`MAX_LIVE_GATEWAYS`]. Callers use the gateway keys to fetch the kind:0 +/// profiles [`resolve`] needs. +/// +/// For each coordinate (the first `d` tag, as NIP-33 addresses it) only the +/// newest correctly signed head counts, ties to the lowest id. That head must +/// itself parse and be live: a stale grant returned beside a newer revocation +/// or a newer malformed head never wins. +pub fn live_heads(owner: PublicKey, delegations: &[Event], now: u64) -> Vec { + let mut heads: BTreeMap<&str, &Event> = BTreeMap::new(); + for event in delegations { + if event.pubkey != owner + || u32::from(event.kind.as_u16()) != KIND_AGENT_GATEWAY_DELEGATION + || event.verify().is_err() + { + continue; + } + let Some(coordinate) = first_d_tag(event) else { + continue; + }; + let newer = heads.get(coordinate).is_none_or(|head| { + (event.created_at, std::cmp::Reverse(event.id)) + > (head.created_at, std::cmp::Reverse(head.id)) + }); + if newer { + heads.insert(coordinate, event); + } + } + let mut live: Vec = heads + .values() + .filter_map(|head| { + let (delegation, _) = Delegation::parse(head).ok()?; + live(head, owner, delegation.gateway, now) + }) + .collect(); + live.sort_by_key(|live| (std::cmp::Reverse(live.created_at), live.gateway.to_hex())); + live.truncate(MAX_LIVE_GATEWAYS); + live +} + +/// Resolve `owner`'s live gateways from relay results. +/// +/// Starts from [`live_heads`], then refuses gateways in `excluded` (the owner, +/// the consuming agent) and any key whose correctly signed kind:0 in +/// `profiles` claims agent identity. Refused keys still occupy their slot in +/// the [`MAX_LIVE_GATEWAYS`] window, so every consumer acts on the same set. +pub fn resolve( + owner: PublicKey, + delegations: &[Event], + profiles: &[Event], + excluded: &[PublicKey], + now: u64, +) -> Vec { + let claims_agent = |gateway: &PublicKey| { + profiles.iter().any(|profile| { + profile.pubkey == *gateway + && profile.kind == Kind::Metadata + && profile.verify().is_ok() + && profile_claims_agent(profile) + }) + }; + live_heads(owner, delegations, now) + .into_iter() + .filter(|live| !excluded.contains(&live.gateway) && !claims_agent(&live.gateway)) + .collect() +} + +/// Build and sign an owner delegation with the current timestamp. +/// +/// `expires_at` is a Unix timestamp, at most 90 days from now. The caller +/// submits this event to its community relay with the owner's identity. +pub fn build_gateway_grant( + owner_keys: &nostr::Keys, + gateway: PublicKey, + expires_at: u64, +) -> Result { + if owner_keys.public_key() == gateway { + return Err(invalid("the gateway must be a separate key")); + } + Delegation::grant(gateway, expires_at, Timestamp::now().as_secs())? + .sign_with_keys(owner_keys) + .map_err(|_| invalid("could not sign gateway delegation")) +} + +/// Whether a signer has a live verified owner delegation in these relay +/// results. Supply current gateway profiles so agent identities are excluded. +/// Consumers must also enforce community and channel membership separately. +pub fn is_live_delegate( + owner: PublicKey, + signer: PublicKey, + grants: &[Event], + profiles: &[Event], + now: u64, +) -> bool { + resolve(owner, grants, profiles, &[owner], now) + .iter() + .any(|grant| grant.gateway() == signer) +} + +fn lifetime_ok(created_at: u64, expires_at: u64) -> bool { + expires_at > created_at && expires_at - created_at <= MAX_DELEGATION_SECS +} + +fn first_d_tag(event: &Event) -> Option<&str> { + event.tags.iter().find_map(|tag| match tag.as_slice() { + [name, value, ..] if name == "d" => Some(value.as_str()), + _ => None, + }) +} + +fn single<'a>(event: &'a Event, name: &str) -> Result, SdkError> { + let mut tags = event + .tags + .iter() + .map(Tag::as_slice) + .filter(|tag| tag.first().map(String::as_str) == Some(name)); + let first = tags.next(); + if tags.next().is_some() || first.is_some_and(|tag| tag.len() != 2) { + return Err(invalid("ambiguous delegation tag")); + } + Ok(first.map(|tag| tag[1].as_str())) +} + +fn canonical_u64(text: &str) -> Result { + let value: u64 = text + .parse() + .map_err(|_| invalid("invalid delegation expiration"))?; + if value.to_string() != text { + return Err(invalid("invalid delegation expiration")); + } + Ok(value) +} + +fn invalid(message: &str) -> SdkError { + SdkError::InvalidInput(message.into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/buzz-sdk/src/gateway/tests.rs b/crates/buzz-sdk/src/gateway/tests.rs new file mode 100644 index 00000000000..f5fcd98e991 --- /dev/null +++ b/crates/buzz-sdk/src/gateway/tests.rs @@ -0,0 +1,348 @@ +use super::*; +use nostr::Keys; + +const NOW: u64 = 1_800_000_000; +const DAY: u64 = 86_400; + +fn keys(byte: &str) -> Keys { + Keys::parse(&byte.repeat(32)).unwrap() +} + +fn owner() -> Keys { + keys("33") +} + +fn gateway() -> Keys { + keys("44") +} + +fn grant(signer: &Keys, gateway: PublicKey, created_at: u64, expires_at: u64) -> Event { + Delegation::build(gateway, true, Some(expires_at), created_at) + .unwrap() + .sign_with_keys(signer) + .unwrap() +} + +fn revoke(signer: &Keys, gateway: PublicKey, created_at: u64) -> Event { + Delegation::revoke(gateway, created_at) + .unwrap() + .sign_with_keys(signer) + .unwrap() +} + +fn raw(signer: &Keys, content: &str, tags: &[&[&str]], created_at: u64) -> Event { + EventBuilder::new(Kind::Custom(KIND_AGENT_GATEWAY_DELEGATION as u16), content) + .tags( + tags.iter() + .map(|tag| Tag::parse(tag.iter().copied()).unwrap()), + ) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(signer) + .unwrap() +} + +#[test] +fn builders_round_trip_and_refuse_unbounded_lifetimes() { + let gw = gateway().public_key(); + let event = Delegation::grant(gw, NOW + DAY, NOW) + .unwrap() + .sign_with_keys(&owner()) + .unwrap(); + let (delegation, expiration) = Delegation::parse(&event).unwrap(); + assert_eq!(delegation.gateway, gw); + assert_eq!(delegation.scope, Scope::RemoteAgents); + assert!(delegation.enabled); + assert_eq!(expiration, Some(NOW + DAY)); + assert_eq!(event.created_at.as_secs(), NOW); + + for expires_at in [NOW, NOW - 1, NOW + MAX_DELEGATION_SECS + 1] { + assert!( + Delegation::grant(gw, expires_at, NOW).is_err(), + "{expires_at}" + ); + } + assert!(Delegation::grant(gw, NOW + MAX_DELEGATION_SECS, NOW).is_ok()); + + let revoked = revoke(&owner(), gw, NOW); + let (delegation, expiration) = Delegation::parse(&revoked).unwrap(); + assert!(!delegation.enabled); + assert_eq!(expiration, None); +} + +#[test] +fn only_an_exact_unexpired_owner_head_is_live() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let gw_hex = gw.to_hex(); + let good = + format!(r#"{{"version":1,"gateway":"{gw_hex}","scope":"remote-agents","enabled":true}}"#); + let exp = (NOW + DAY).to_string(); + let cases: Vec<(&str, Event, bool)> = vec![ + ("valid", grant(&owner(), gw, NOW, NOW + DAY), true), + ( + "expired", + grant(&owner(), gw, NOW - 2 * DAY, NOW - 1), + false, + ), + ("expires now", grant(&owner(), gw, NOW - DAY, NOW), false), + ( + "lifetime over the cap", + grant(&owner(), gw, NOW - 1, NOW + MAX_DELEGATION_SECS), + false, + ), + ( + "created beyond clock skew", + grant(&owner(), gw, NOW + MAX_FUTURE_SKEW_SECS + 1, NOW + DAY), + false, + ), + ( + "created within clock skew", + grant(&owner(), gw, NOW + MAX_FUTURE_SKEW_SECS, NOW + DAY), + true, + ), + ("revoked head", revoke(&owner(), gw, NOW), false), + ( + "signed by someone else", + grant(&keys("55"), gw, NOW, NOW + DAY), + false, + ), + ( + "names another gateway", + grant(&owner(), keys("66").public_key(), NOW, NOW + DAY), + false, + ), + ( + "missing expiration", + raw(&owner(), &good, &[&["d", &gw_hex], &["p", &gw_hex]], NOW), + false, + ), + ( + "non-canonical expiration", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &format!("0{exp}")], + ], + NOW, + ), + false, + ), + ( + "duplicate expiration", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &exp], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "second p tag", + raw( + &owner(), + &good, + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["p", &keys("66").public_key().to_hex()], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "d tag names another key", + raw( + &owner(), + &good, + &[ + &["d", &keys("66").public_key().to_hex()], + &["p", &gw_hex], + &["expiration", &exp], + ], + NOW, + ), + false, + ), + ( + "unknown scope", + raw( + &owner(), + &good.replace("remote-agents", "everything"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ( + "unknown field", + raw( + &owner(), + &good.replace("\"enabled\":true", "\"enabled\":true,\"dm\":true"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ( + "version two", + raw( + &owner(), + &good.replace("\"version\":1", "\"version\":2"), + &[&["d", &gw_hex], &["p", &gw_hex], &["expiration", &exp]], + NOW, + ), + false, + ), + ]; + for (name, event, expected) in cases { + assert_eq!(live(&event, o, gw, NOW).is_some(), expected, "{name}"); + } + + let self_delegation = grant(&owner(), o, NOW, NOW + DAY); + assert!(Delegation::parse(&self_delegation).is_err()); + let mut tampered = grant(&owner(), gw, NOW, NOW + DAY); + tampered.content = tampered.content.replace("true", "false"); + assert!(Delegation::parse(&tampered).is_err()); +} + +#[test] +fn resolve_uses_the_newest_head_and_refuses_agents_and_excluded_keys() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let agent = keys("77"); + let auth = crate::nip_oa::compute_auth_tag(&owner(), &agent.public_key(), "").unwrap(); + let agent_profile = EventBuilder::new(Kind::Metadata, "{}") + .tag(crate::nip_oa::parse_auth_tag(&auth).unwrap()) + .sign_with_keys(&agent) + .unwrap(); + let plain_profile = EventBuilder::new(Kind::Metadata, "{}") + .sign_with_keys(&gateway()) + .unwrap(); + // A profile with any auth tag, even a foreign or invalid one, claims agency. + let foreign_claim = EventBuilder::new(Kind::Metadata, "{}") + .tag(Tag::parse(["auth", &o.to_hex(), "", &"0".repeat(128)]).unwrap()) + .sign_with_keys(&gateway()) + .unwrap(); + + let grant_old = grant(&owner(), gw, NOW - 10, NOW + DAY); + let revoke_new = revoke(&owner(), gw, NOW - 5); + let grant_newest = grant(&owner(), gw, NOW - 1, NOW + DAY); + let agent_grant = grant(&owner(), agent.public_key(), NOW, NOW + DAY); + let gateways = |events: &[Event], profiles: &[Event], excluded: &[PublicKey]| { + resolve(o, events, profiles, excluded, NOW) + .into_iter() + .map(|live| live.gateway()) + .collect::>() + }; + + assert_eq!(gateways(&[grant_old.clone()], &[], &[]), vec![gw]); + // A stale grant returned beside a newer revocation never wins, in any order. + assert!(gateways(&[grant_old.clone(), revoke_new.clone()], &[], &[]).is_empty()); + assert!(gateways(&[revoke_new.clone(), grant_old.clone()], &[], &[]).is_empty()); + assert_eq!( + gateways(&[grant_old.clone(), revoke_new, grant_newest], &[], &[]), + vec![gw] + ); + assert_eq!( + gateways(&[grant_old.clone()], &[plain_profile], &[]), + vec![gw] + ); + assert!(gateways(&[grant_old.clone()], &[foreign_claim], &[]).is_empty()); + // An owner's own agent can never pass as the owner's gateway. + assert!(gateways(&[agent_grant], &[agent_profile], &[]).is_empty()); + assert!(gateways(&[grant_old.clone()], &[], &[gw]).is_empty()); + // Another signer's head is ignored, not merged. + assert!(gateways(&[grant(&keys("55"), gw, NOW, NOW + DAY)], &[], &[]).is_empty()); +} + +#[test] +fn resolve_caps_live_gateways_newest_first() { + let o = owner().public_key(); + let heads: Vec = ["a1", "a2", "a3"] + .iter() + .enumerate() + .map(|(i, byte)| { + grant( + &owner(), + keys(byte).public_key(), + NOW - 10 + i as u64, + NOW + DAY, + ) + }) + .collect(); + let live = resolve(o, &heads, &[], &[], NOW); + assert_eq!(live.len(), MAX_LIVE_GATEWAYS); + assert_eq!(live[0].gateway(), keys("a3").public_key()); + assert_eq!(live[1].gateway(), keys("a2").public_key()); + // An excluded key keeps its slot, so every consumer acts on the same set. + let excluded = resolve(o, &heads, &[], &[keys("a3").public_key()], NOW); + assert_eq!(excluded.len(), 1); + assert_eq!(excluded[0].gateway(), keys("a2").public_key()); +} + +#[test] +fn a_newer_unparseable_head_hides_an_older_grant() { + let o = owner().public_key(); + let gw = gateway().public_key(); + let gw_hex = gw.to_hex(); + let older = grant(&owner(), gw, NOW - 10, NOW + DAY); + // A newer head for the same coordinate that is not a valid delegation + // (here: a revocation that illegally carries an expiration) is still the + // coordinate's head, so the older grant must not resurface. + let revoked_with_expiry = raw( + &owner(), + &format!(r#"{{"version":1,"gateway":"{gw_hex}","scope":"remote-agents","enabled":false}}"#), + &[ + &["d", &gw_hex], + &["p", &gw_hex], + &["expiration", &(NOW + DAY).to_string()], + ], + NOW - 5, + ); + assert!(Delegation::parse(&revoked_with_expiry).is_err()); + assert!(live_heads(o, &[older.clone(), revoked_with_expiry.clone()], NOW).is_empty()); + assert!(live_heads(o, &[revoked_with_expiry, older.clone()], NOW).is_empty()); + // A forged newer head (bad signature) is not the owner's and is ignored. + let mut forged = revoke(&owner(), gw, NOW - 1); + forged.content = forged.content.replace("false", "true"); + assert_eq!(live_heads(o, &[older, forged], NOW).len(), 1); +} + +#[test] +fn signed_grant_and_delegate_verifier_bind_the_owner_signature() { + let owner = owner(); + let gateway = gateway(); + let now = Timestamp::now().as_secs(); + let event = + build_gateway_grant(&owner, gateway.public_key(), now + MAX_DELEGATION_SECS).unwrap(); + assert!(is_live_delegate( + owner.public_key(), + gateway.public_key(), + &[event.clone()], + &[], + now + )); + assert!(!is_live_delegate( + keys("55").public_key(), + gateway.public_key(), + &[event], + &[], + now + )); + assert!(build_gateway_grant(&owner, owner.public_key(), now + DAY).is_err()); + assert!(build_gateway_grant(&owner, gateway.public_key(), now).is_err()); + assert!( + build_gateway_grant(&owner, gateway.public_key(), now + MAX_DELEGATION_SECS + 60).is_err() + ); +} diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 68fb0d394bb..4b47b066bc2 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -14,6 +14,7 @@ pub mod broker; pub mod builders; +pub mod gateway; pub mod mentions; pub mod nip_oa; pub mod sharing; diff --git a/docs/agent-gateway-key.md b/docs/agent-gateway-key.md new file mode 100644 index 00000000000..0580bfd94e6 --- /dev/null +++ b/docs/agent-gateway-key.md @@ -0,0 +1,128 @@ +# Gateway keys for remote agents + +A gateway drives your remote agents with its own key. Your main Nostr key stays +with you. Existing Buzz apps continue using your main key without a grant. Direct-message +sandboxes and unbound harness sessions never enable gateway access. + +## Authority + +An owner signs a kind `30181` addressable event naming one gateway. The +coordinate is `(30181, owner, gateway hex)`. Enabled heads require a NIP-40 +`expiration` within 90 days. A newer disabled head revokes the grant permanently +until a newer enabled head replaces it. + +```mermaid +sequenceDiagram + participant Owner + participant Relay + participant Gateway + participant Agent + Owner->>Relay: Signed expiring grant naming gateway + Gateway->>Relay: Signed thread prompt mentioning agent + Agent->>Relay: Read current owner grant and gateway profile + Agent->>Agent: Verify grant, membership and thread scope + Agent->>Relay: Telemetry encrypted separately to owner and gateway + Owner->>Relay: Newer disabled head + Agent->>Relay: Refresh grant within 30 seconds + Agent->>Agent: Refuse further gateway prompts and control +``` + +| Path | Authorization | +| --- | --- | +| Mention launch | Current signed owner grant, current remote-agent registration, visible trigger and channel | +| Thread prompt | Bound channel thread in strict owner mode; live grant checked at ingress and again before queued model dispatch | +| Turn cancellation | Live grant; signed, fresh NIP-44 observer control; command exactly `cancel_turn` | +| Observer telemetry | Agent ownership verified by relay; separate NIP-44 copy for each live gateway | +| Owner DMs, profile and channel administration | No delegated authority | +| Model changes and owner announcements | Owner key required | +| Explicit checkpoint/fresh recovery requests | Not delegated; existing agent-signed deployment remains required | + +A gateway remains a separate ordinary relay member. A grant provides neither +relay membership nor channel membership. It does not impersonate the owner. +Normal actions permitted to that separate account remain available, including +posting its own records into an explicitly joined recording channel. + +Only the newest signed head per coordinate counts. Consumers accept at most two +live gateways, ordered by grant timestamp and then gateway key. They read at most +32 heads per owner. Grants outside that window fail closed. + +Agent keys never serve as gateways. The SDK refuses a gateway profile carrying +an `auth` tag. The relay also refuses keys recorded as owned agents. NIP-OA +owner attestations cannot substitute for gateway grants. + +## Enroll and grant + +The manager provisions a distinct gateway key in the per-developer daemon +Kubernetes Secret. Its enrollment reply returns only `gateway_pubkey`. +It never returns the gateway secret key. Provisioning and onboarding have +separate PRs; deploy those before using this enrollment path. + +1. Enroll with the owner key using the Buzz app or `buzz onboard`. +2. Ask a relay operator to admit the returned gateway public key as a member. +3. Add that public key to each channel the daemon should use. +4. Run the grant command with your owner key. + +```sh +# Relay operator, using the configured admin environment: +buzz-admin add-member --pubkey "$GATEWAY_PUBKEY" --role member + +# Channel administrator: +buzz channels add-member --channel "$CHANNEL_ID" --pubkey "$GATEWAY_PUBKEY" + +# Owner (BUZZ_PRIVATE_KEY is your owner key): +buzz gateway grant "$GATEWAY_PUBKEY" --expires 90d +buzz gateway list +buzz gateway revoke "$GATEWAY_PUBKEY" +``` + +The CLI refuses NIP-OA agent credentials for grant management. It reads the +current head before writing and advances its timestamp when two actions occur +within the same second. Conflicting writes fail visibly and can be retried. +`list` returns the latest 32 heads, including expired grants and revocations. + +For key rotation, revoke the old public key first. An operator then deletes the +daemon Secret, and the owner enrolls again to generate new credentials. Admit +and grant the new public key. Remove the old key's relay and channel membership +when its records no longer need to remain accessible to that account. + +## Revocation and failure behavior + +Revocation uses a newer disabled replacement, without expiration. The relay +ignores NIP-09 deletion of gateway heads. Deleting a head could otherwise let an +old signed grant be replayed into an empty coordinate. Use `gateway revoke`. + +Authority reads use `"consistency":"strong"` on HTTP queries and WebSocket REQ +filters. The relay pins these reads to the writer, so a lagging replica cannot +renew stale authority or hide the current revocation head. + +The harness ages successful lookups from their start, for at most 30 seconds. +Each lookup has a five-second deadline. It serializes +refreshes so an older lookup cannot overwrite a newer result. A lookup failure +refuses gateway use and is retried after five seconds. Positive verdicts never +outlive the grant expiration. Owners remain usable during a gateway lookup +failure. + +The relay caches observer verdicts for at most 30 seconds and fences local +writes by generation. Other replicas observe revocation within that cache TTL. +The mention launcher rechecks authority before each launch. New grants enter +its listener filters within the existing 60-second listener recycle interval. +Revocation does not cancel a model turn already running. The owner can cancel +that turn using the existing owner control path. + +Telemetry fan-out is capped at two gateways. Additional recipients consume +subsequent idle ticks, preserving the existing average relay publication rate. +Ephemeral observer frames are not replayable history. + +## Rollout and verification + +Deploy the relay changes before granting gateway access. Then deploy the manager +and agent image containing the updated launcher and harness. Older harnesses +continue accepting owners but ignore gateways. No database migration is needed. + +Exercise one owner agent in a test channel. Admit the gateway into that relay +and channel, grant it, and send a mention using the gateway key. Confirm one +sandbox starts and answers. Confirm the gateway receives encrypted observer +telemetry and can cancel a turn. Revoke it and wait 30 seconds. Confirm its next +prompt and cancellation are refused, while the owner's prompt still works. +Confirm a second owner's agent and the owner's DMs remain inaccessible through +the grant. diff --git a/docs/remote-agents.md b/docs/remote-agents.md index bdea3330758..18117d28989 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -2,6 +2,10 @@ `draft` +Owner-delegated gateway keys let an orchestrator drive the owner’s agents without +holding the owner key. See [Gateway keys](agent-gateway-key.md) for the scoped +grant, membership prerequisites, revocation, and rollout. + ## Abstract This document specifies the protocol by which Buzz Desktop delegates the diff --git a/mobile/lib/shared/relay/nostr_models.dart b/mobile/lib/shared/relay/nostr_models.dart index 60bed113112..efc144b4b23 100644 --- a/mobile/lib/shared/relay/nostr_models.dart +++ b/mobile/lib/shared/relay/nostr_models.dart @@ -35,6 +35,9 @@ abstract final class EventKind { /// Owner-signed managed-agent records, including their runtime type. static const managedAgent = 30177; + + /// Owner-signed, expiring gateway delegation for remote agents. + static const agentGatewayDelegation = 30181; static const eventReminder = 30300; static const userStatus = 30315; static const dmVisibility = 30622; diff --git a/voltage/remote-agents.md b/voltage/remote-agents.md index 7c4184de05c..85689772f69 100644 --- a/voltage/remote-agents.md +++ b/voltage/remote-agents.md @@ -8,6 +8,10 @@ Every commit an agent makes is signed with a key that is on your GitHub account, - **Admins:** start at [Onboard a developer](#onboard-a-developer). - **Developers:** start at [Set up your agents](#set-up-your-agents). +A Paseo daemon uses its own gateway key. Follow +[Gateway keys](../docs/agent-gateway-key.md) to admit that key and grant access +to your agents. The daemon never needs your main Nostr secret. + ## How it works An unchanged phone app can trigger registered agents through [server mention launches](../docs/server-mention-launch.md). From 2e739b6b4079d303cf1daf85c33dc145a115baea Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:41:23 -0500 Subject: [PATCH 2/6] test(agents): verify gateway isolation and revocation boundaries Signed-off-by: Tee8z --- crates/buzz-acp/src/gateway/tests.rs | 18 +++++++++++++----- crates/buzz-relay/src/handlers/event.rs | 22 ++++++++++++++++++++-- crates/buzz-sdk/src/gateway/tests.rs | 13 ++++++++----- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/crates/buzz-acp/src/gateway/tests.rs b/crates/buzz-acp/src/gateway/tests.rs index 610e43e4867..734d3a53cac 100644 --- a/crates/buzz-acp/src/gateway/tests.rs +++ b/crates/buzz-acp/src/gateway/tests.rs @@ -75,11 +75,19 @@ pub(crate) async fn fake_relay() -> FakeRelay { .ok() .and_then(|filters| filters.into_iter().next()) .unwrap_or_default(); - assert_eq!( - filter["consistency"], - json!("strong"), - "authority reads must bypass replicas" - ); + let gateway_profile_read = filter["authors"].as_array().is_some_and(|authors| { + heads.lock().unwrap().iter().any(|head| { + buzz_sdk::gateway::Delegation::parse(head) + .is_ok_and(|(grant, _)| authors.contains(&json!(grant.gateway))) + }) + }); + if filter["kinds"] == json!([30181]) || gateway_profile_read { + assert_eq!( + filter["consistency"], + json!("strong"), + "authority reads must bypass replicas" + ); + } let (status, payload) = if fail.load(Ordering::SeqCst) { ("403 Forbidden", "{}".to_string()) } else if filter["kinds"] == json!([30181]) { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index b9ac014a441..a3af129d3c1 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -4178,7 +4178,7 @@ mod tests { assert!(!accepted(&text), "undelegated gateway control: {text}"); let old_grant = grant(&gateway, now - 10); - let text = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant.clone()).await; + let text = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant).await; assert!(accepted(&text), "grant stored: {text}"); let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; assert!(accepted(&text), "live gateway control: {text}"); @@ -4216,11 +4216,28 @@ mod tests { .unwrap() .sign_with_keys(&owner) .unwrap(); + let revocation_id = revocation.id; let text = ok_frame_scoped_owner(&state, &tenant, &owner, revocation).await; assert!(accepted(&text), "revocation stored: {text}"); let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; assert!(!accepted(&text), "revoked gateway control: {text}"); + // NIP-09 e-tag deletion cannot erase the durable revocation. + // Replay a correctly signed older head never submitted before: + // reusing an existing event id could be rejected by generic dedup. + let deletion = EventBuilder::new(Kind::EventDeletion, "") + .tag(Tag::event(revocation_id)) + .sign_with_keys(&owner) + .unwrap(); + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, deletion).await; + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, grant(&gateway, now - 7)).await; + state.observer_gateway_cache.invalidate_all(); + let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; + assert!( + !accepted(&text), + "revocation survived e-tag deletion and a never-seen old head: {text}" + ); + let coordinate = format!( "{KIND_AGENT_GATEWAY_DELEGATION}:{}:{}", owner.public_key().to_hex(), @@ -4231,7 +4248,8 @@ mod tests { .sign_with_keys(&owner) .unwrap(); let _ = ok_frame_scoped_owner(&state, &tenant, &owner, deletion).await; - let _ = ok_frame_scoped_owner(&state, &tenant, &owner, old_grant).await; + let _ = ok_frame_scoped_owner(&state, &tenant, &owner, grant(&gateway, now - 6)).await; + state.observer_gateway_cache.invalidate_all(); let text = ok_frame(&state, &tenant, &gateway, control(&gateway, &agent)).await; assert!( !accepted(&text), diff --git a/crates/buzz-sdk/src/gateway/tests.rs b/crates/buzz-sdk/src/gateway/tests.rs index f5fcd98e991..5d8e2878dc6 100644 --- a/crates/buzz-sdk/src/gateway/tests.rs +++ b/crates/buzz-sdk/src/gateway/tests.rs @@ -246,7 +246,10 @@ fn resolve_uses_the_newest_head_and_refuses_agents_and_excluded_keys() { .collect::>() }; - assert_eq!(gateways(&[grant_old.clone()], &[], &[]), vec![gw]); + assert_eq!( + gateways(std::slice::from_ref(&grant_old), &[], &[]), + vec![gw] + ); // A stale grant returned beside a newer revocation never wins, in any order. assert!(gateways(&[grant_old.clone(), revoke_new.clone()], &[], &[]).is_empty()); assert!(gateways(&[revoke_new.clone(), grant_old.clone()], &[], &[]).is_empty()); @@ -255,13 +258,13 @@ fn resolve_uses_the_newest_head_and_refuses_agents_and_excluded_keys() { vec![gw] ); assert_eq!( - gateways(&[grant_old.clone()], &[plain_profile], &[]), + gateways(std::slice::from_ref(&grant_old), &[plain_profile], &[]), vec![gw] ); - assert!(gateways(&[grant_old.clone()], &[foreign_claim], &[]).is_empty()); + assert!(gateways(std::slice::from_ref(&grant_old), &[foreign_claim], &[]).is_empty()); // An owner's own agent can never pass as the owner's gateway. assert!(gateways(&[agent_grant], &[agent_profile], &[]).is_empty()); - assert!(gateways(&[grant_old.clone()], &[], &[gw]).is_empty()); + assert!(gateways(std::slice::from_ref(&grant_old), &[], &[gw]).is_empty()); // Another signer's head is ignored, not merged. assert!(gateways(&[grant(&keys("55"), gw, NOW, NOW + DAY)], &[], &[]).is_empty()); } @@ -329,7 +332,7 @@ fn signed_grant_and_delegate_verifier_bind_the_owner_signature() { assert!(is_live_delegate( owner.public_key(), gateway.public_key(), - &[event.clone()], + std::slice::from_ref(&event), &[], now )); From 29b426fd860f37bec6141951a834aa137c1bc4b8 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:04:14 -0500 Subject: [PATCH 3/6] fix(agents): require current channel membership for gateways Signed-off-by: Tee8z --- crates/buzz-acp/src/gateway.rs | 35 ++- crates/buzz-acp/src/gateway/tests.rs | 172 ++++++++++- crates/buzz-acp/src/lib.rs | 1 + crates/buzz-relay/src/api/bridge.rs | 14 +- .../src/handlers/current_members.rs | 281 ++++++++++++++++++ crates/buzz-relay/src/handlers/mod.rs | 2 + crates/buzz-relay/src/handlers/req.rs | 11 +- .../buzz-relay/src/handlers/side_effects.rs | 4 +- .../src/mentions/gateways.rs | 28 ++ .../src/mentions/tests/gateway.rs | 66 ++++ .../src/mentions/tests/relay.rs | 22 +- .../src/mentions/worker.rs | 10 + .../buzz-sandbox-reaper/src/shared_tasks.rs | 2 +- crates/buzz-sdk/src/gateway.rs | 32 ++ crates/buzz-sdk/src/gateway/tests.rs | 49 +++ docs/agent-gateway-key.md | 13 + 16 files changed, 728 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-relay/src/handlers/current_members.rs diff --git a/crates/buzz-acp/src/gateway.rs b/crates/buzz-acp/src/gateway.rs index 66f66850075..2948b96c2b3 100644 --- a/crates/buzz-acp/src/gateway.rs +++ b/crates/buzz-acp/src/gateway.rs @@ -57,16 +57,23 @@ enum Cached { pub(crate) struct GatewayAccess { owner: PublicKey, agent: PublicKey, + channel: uuid::Uuid, rest: RestClient, cached: tokio::sync::Mutex, } impl GatewayAccess { /// Gateway access for `agent`, owned by `owner`, read through `rest`. - pub(crate) fn new(owner: PublicKey, agent: PublicKey, rest: RestClient) -> Self { + pub(crate) fn new( + owner: PublicKey, + agent: PublicKey, + rest: RestClient, + channel: uuid::Uuid, + ) -> Self { Self { owner, agent, + channel, rest, cached: tokio::sync::Mutex::new(Cached::Empty), } @@ -144,13 +151,37 @@ impl GatewayAccess { "limit": candidates.len(), })) .await?; + let relay = self + .rest + .relay_self() + .await + .map_err(|error| LookupError(error.to_string()))? + .and_then(|key| PublicKey::from_hex(&key).ok()) + .ok_or_else(|| LookupError("relay identity unavailable".into()))?; + let rosters = self + .query(json!({"kinds":[39002], "authors":[relay], + "#d":[self.channel], "limit":1})) + .await?; + if rosters.len() > 1 { + return Err(LookupError("ambiguous channel membership".into())); + } + let Some(roster) = rosters.first() else { + return Ok(Vec::new()); + }; + let member = |key| buzz_sdk::gateway::channel_member(roster, relay, self.channel, key); + if !member(self.owner) || !member(self.agent) { + return Ok(Vec::new()); + } Ok(buzz_sdk::gateway::resolve( self.owner, &heads, &profiles, &[self.owner, self.agent], now, - )) + ) + .into_iter() + .filter(|live| member(live.gateway())) + .collect()) } /// One `/query`, refusing any response that is not an array of events. diff --git a/crates/buzz-acp/src/gateway/tests.rs b/crates/buzz-acp/src/gateway/tests.rs index 734d3a53cac..a0f88568a05 100644 --- a/crates/buzz-acp/src/gateway/tests.rs +++ b/crates/buzz-acp/src/gateway/tests.rs @@ -16,9 +16,20 @@ pub(crate) struct FakeRelay { pub(crate) fail: Arc, pub(crate) queries: Arc, pub(crate) rest: RestClient, + pub(crate) members: Arc>>, task: tokio::task::JoinHandle<()>, } +impl FakeRelay { + fn admit(&self, members: &[PublicKey]) { + *self.members.lock().unwrap() = members.to_vec(); + } +} + +fn channel() -> uuid::Uuid { + "0b6c1f0e-2a7d-4c1e-9f3a-5d2b8e4c7a10".parse().unwrap() +} + impl Drop for FakeRelay { fn drop(&mut self) { self.task.abort(); @@ -31,14 +42,16 @@ pub(crate) async fn fake_relay() -> FakeRelay { let base_url = format!("http://{}", listener.local_addr().unwrap()); let heads = Arc::new(Mutex::new(Vec::::new())); let profiles = Arc::new(Mutex::new(Vec::::new())); + let members = Arc::new(Mutex::new(Vec::::new())); let fail = Arc::new(AtomicBool::new(false)); let queries = Arc::new(AtomicUsize::new(0)); let task = { - let (heads, profiles, fail, queries) = ( + let (heads, profiles, fail, queries, members) = ( heads.clone(), profiles.clone(), fail.clone(), queries.clone(), + members.clone(), ); tokio::spawn(async move { loop { @@ -81,7 +94,10 @@ pub(crate) async fn fake_relay() -> FakeRelay { .is_ok_and(|(grant, _)| authors.contains(&json!(grant.gateway))) }) }); - if filter["kinds"] == json!([30181]) || gateway_profile_read { + if filter["kinds"] == json!([30181]) + || filter["kinds"] == json!([39002]) + || gateway_profile_read + { assert_eq!( filter["consistency"], json!("strong"), @@ -90,6 +106,22 @@ pub(crate) async fn fake_relay() -> FakeRelay { } let (status, payload) = if fail.load(Ordering::SeqCst) { ("403 Forbidden", "{}".to_string()) + } else if text.starts_with("GET ") { + let relay = Keys::parse(&"11".repeat(32)).unwrap(); + ("200 OK", json!({"self":relay.public_key()}).to_string()) + } else if filter["kinds"] == json!([39002]) { + let relay = Keys::parse(&"11".repeat(32)).unwrap(); + let mut tags = vec![nostr::Tag::parse(["d", &channel().to_string()]).unwrap()]; + tags.extend( + members.lock().unwrap().iter().map(|key| { + nostr::Tag::parse(["p", &key.to_hex(), "", "member"]).unwrap() + }), + ); + let roster = EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(&relay) + .unwrap(); + ("200 OK", serde_json::to_string(&[roster]).unwrap()) } else if filter["kinds"] == json!([30181]) { ( "200 OK", @@ -114,6 +146,7 @@ pub(crate) async fn fake_relay() -> FakeRelay { FakeRelay { heads, profiles, + members, fail, queries, rest: RestClient { @@ -158,7 +191,13 @@ async fn gateway_access_is_cached_bounded_and_fails_closed() { Keys::generate(), Keys::generate(), ); - let access = GatewayAccess::new(owner.public_key(), agent.public_key(), relay.rest.clone()); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + ); let gw = gateway.public_key(); assert!(!access.is_live(&gw).await.unwrap(), "no delegation yet"); @@ -265,10 +304,12 @@ async fn author_gate_admits_a_live_gateway_only_in_owner_mode_channels() { .lock() .unwrap() .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); let access = Arc::new(GatewayAccess::new( owner.public_key(), agent.public_key(), relay.rest.clone(), + channel(), )); let mut gate = crate::InboundAuthorGate::connect(&relay.rest, &agent.public_key().to_hex(), "test").await; @@ -328,7 +369,7 @@ fn control_frame(sender: &Keys, agent: &Keys, command: &str) -> Event { let content = crate::encrypt_observer_payload( sender, &agent.public_key(), - &serde_json::json!({"type": command, "channelId": uuid::Uuid::new_v4()}), + &serde_json::json!({"type": command, "channelId": channel()}), ) .unwrap(); buzz_sdk::build_agent_observer_frame( @@ -361,7 +402,13 @@ async fn observer_control_from_a_gateway_is_cancel_only() { .lock() .unwrap() .push(grant(&owner, gateway.public_key(), 10)); - let access = GatewayAccess::new(owner.public_key(), agent.public_key(), relay.rest.clone()); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + let access = GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + ); let owner_hex = owner.public_key().to_hex(); let accepted = |sender: &Keys, command: &str| { let frame = control_frame(sender, &agent, command); @@ -408,10 +455,12 @@ async fn telemetry_reaches_live_gateways_within_the_frame_budget() { .lock() .unwrap() .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); let access = Arc::new(GatewayAccess::new( owner.public_key(), agent.public_key(), relay.rest.clone(), + channel(), )); // Prime the cache in real time; the paced loop below runs on paused time. assert_eq!(access.live().await.unwrap(), vec![gateway.public_key()]); @@ -504,10 +553,12 @@ async fn queued_gateway_turns_are_rechecked_at_dispatch() { .push(grant(&owner, gateway.public_key(), 10)); let mut ctx = crate::pool::tests::make_prompt_context_no_owner(); ctx.agent_owner_pubkey = Some(owner.public_key()); + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); ctx.gateways = Some(Arc::new(GatewayAccess::new( owner.public_key(), agent.public_key(), relay.rest.clone(), + channel(), ))); let channel = uuid::Uuid::new_v4(); let batch = |signer: &Keys| crate::queue::FlushBatch { @@ -591,3 +642,114 @@ async fn gateway_factory_admits_only_bound_owner_channel_threads() { ); } } + +/// A global grant never grants a private channel. Joining enables telemetry, +/// prompts and cancellation; removing membership disables all three on refresh. +#[tokio::test] +async fn channel_membership_gates_gateway_telemetry_prompts_and_cancellation() { + let relay = fake_relay().await; + let (owner, agent, gateway) = (Keys::generate(), Keys::generate(), Keys::generate()); + relay + .heads + .lock() + .unwrap() + .push(grant(&owner, gateway.public_key(), 10)); + relay.admit(&[owner.public_key(), agent.public_key()]); + let access = Arc::new(GatewayAccess::new( + owner.public_key(), + agent.public_key(), + relay.rest.clone(), + channel(), + )); + let mut gate = + crate::InboundAuthorGate::connect(&relay.rest, &agent.public_key().to_hex(), "test").await; + gate.enable_gateways(Some(access.clone())); + let owner_hex = owner.public_key().to_hex(); + let owner_cache = crate::OwnerCache::new(Some(owner_hex.clone())); + let allowlist = std::collections::HashSet::new(); + for allowed in [false, true, false] { + if allowed { + relay.admit(&[owner.public_key(), agent.public_key(), gateway.public_key()]); + } else { + relay.admit(&[owner.public_key(), agent.public_key()]); + } + skip(VERDICT_TTL).await; + assert_eq!( + access.is_live(&gateway.public_key()).await.unwrap(), + allowed + ); + let prompt = channel_message(&gateway, agent.public_key()); + assert_eq!( + gate.evaluate_for_test( + &prompt, + &crate::RespondTo::Owner, + &allowlist, + false, + &owner_cache, + &relay.rest + ) + .await + .allowed, + allowed + ); + let control = control_frame(&gateway, &agent, "cancel_turn"); + let delegated = crate::delegated_control_sender(Some(&access), &control, &owner_hex).await; + assert_eq!( + crate::authorized_control_payload(&agent, &control, &owner_hex, delegated).is_some(), + allowed + ); + let recipients = membership_telemetry(access.clone(), &owner, &agent).await; + let expected = if allowed { + vec![owner.public_key(), gateway.public_key()] + } else { + vec![owner.public_key()] + }; + assert_eq!(recipients, expected); + } +} + +async fn membership_telemetry( + access: Arc, + owner: &Keys, + agent: &Keys, +) -> Vec { + let observer = crate::observer::ObserverHandle::in_process(); + observer.emit( + "test_event", + None, + &crate::observer::context_for(Some(channel()), None, None), + serde_json::json!({"state":"private"}), + ); + let (publisher, mut published) = crate::relay::RelayEventPublisher::test_pair(); + let task = tokio::spawn(crate::run_relay_observer_publisher( + observer.snapshot(), + observer.subscribe(), + publisher, + agent.clone(), + agent.public_key().to_hex(), + owner.public_key(), + Some(access), + )); + let first = tokio::time::timeout(Duration::from_secs(2), published.recv()) + .await + .unwrap() + .unwrap(); + let mut frames = vec![first]; + if let Ok(Some(second)) = + tokio::time::timeout(crate::OBSERVER_PUBLISH_TICK * 2, published.recv()).await + { + frames.push(second); + } + task.abort(); + frames + .into_iter() + .map(|frame| { + frame + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("p")) + .and_then(|tag| PublicKey::from_hex(&tag.as_slice()[1]).ok()) + .unwrap() + }) + .collect() +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d1325fb3dd5..702e14f78e9 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1930,6 +1930,7 @@ fn gateway_access_for( owner, config.keys.public_key(), rest, + config.bound_session.as_ref()?.channel_id(), ))) } diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index b96fbb8f467..7710c5aa31f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2172,6 +2172,10 @@ async fn query_member_events( let mut catchall_results = stream::iter(catchall_queries.into_iter().map( |(idx, query, read_route)| { let db = db.clone(); + let state = state.clone(); + let tenant = tenant.clone(); + let reader = pubkey_bytes.clone(); + let filter = filters[idx].clone(); async move { // The route was resolved from client-carried `consistency` // intent in phase 1 (`resolve_read_route`). `Writer` pins the @@ -2179,7 +2183,15 @@ async fn query_member_events( // replica-eligible path. Only these two directions exist — the // inverse "force replica" is deliberately unrepresentable. let result = match read_route { - ReadRoute::Writer => db.query_events(&query).await, + ReadRoute::Writer => match crate::handlers::current_members::read( + &state, &tenant, &reader, &filter, + ) + .await + { + Ok(Some(events)) => Ok(events), + Ok(None) => db.query_events(&query).await, + Err(error) => Err(error), + }, ReadRoute::Routed => db.query_events_routed("bridge_query", &query).await, }; (idx, result) diff --git a/crates/buzz-relay/src/handlers/current_members.rs b/crates/buzz-relay/src/handlers/current_members.rs new file mode 100644 index 00000000000..c2372761120 --- /dev/null +++ b/crates/buzz-relay/src/handlers/current_members.rs @@ -0,0 +1,281 @@ +//! Current channel authority for scoped strong Nostr roster reads. +//! +//! A stored discovery event can lag canonical membership after projection +//! publication fails. Reconcile that one coordinate behind the existing writer +//! lock before returning authority. Ordinary and historical queries retain +//! their existing behavior. This path never fans out events. + +use std::sync::Arc; + +use buzz_core::{tenant::TenantContext, StoredEvent}; +use buzz_db::{DbError, EventQuery}; +use nostr::{Alphabet, Filter, SingleLetterTag}; + +use crate::state::AppState; + +/// Return current membership for an explicit strong authority filter, or `None` +/// when this is an ordinary event-history filter. One filter repairs at most +/// one channel; existing request filter/concurrency limits bound total work. +pub(crate) async fn read( + state: &Arc, + tenant: &TenantContext, + reader: &[u8], + filter: &Filter, +) -> Result>, DbError> { + if filter.kinds.as_ref().is_none_or(|kinds| { + kinds.len() != 1 + || !kinds + .iter() + .any(|kind| u32::from(kind.as_u16()) == buzz_core::kind::KIND_NIP29_GROUP_MEMBERS) + }) || filter.authors.as_ref().is_none_or(|authors| { + authors.len() != 1 || !authors.contains(&state.relay_keypair.public_key()) + }) || filter.ids.is_some() + || filter.since.is_some() + || filter.until.is_some() + || filter.search.is_some() + { + return Ok(None); + } + let d = SingleLetterTag::lowercase(Alphabet::D); + let Some(values) = filter + .generic_tags + .get(&d) + .filter(|values| values.len() == 1) + else { + return Ok(None); + }; + let Some(value) = values.iter().next() else { + return Ok(None); + }; + let Ok(channel) = uuid::Uuid::parse_str(value) else { + return Ok(Some(Vec::new())); + }; + if channel.to_string() != *value || filter.limit == Some(0) { + return Ok(Some(Vec::new())); + } + + // Read before taking the lock: never nest a pool checkout while holding + // a membership transaction. The locked canonical tags below decide whether + // this head remains authoritative, regardless of intervening publication. + let existing = state + .db + .query_events(&EventQuery { + kinds: Some(vec![buzz_core::kind::KIND_NIP29_GROUP_MEMBERS as i32]), + authors: Some(vec![state.relay_keypair.public_key().to_bytes().to_vec()]), + channel_id: Some(channel), + limit: Some(1), + ..EventQuery::for_community(tenant.community()) + }) + .await? + .into_iter() + .next(); + let relay = state.relay_keypair.public_key().to_bytes(); + let mut snapshot = state + .db + .lock_member_snapshot(tenant.community(), channel, &relay) + .await?; + // A cached access list must not let a removed reader repair or read a + // private channel's current membership. No writes precede this check. + if snapshot.identity.is_none() + || !snapshot + .members + .iter() + .any(|member| member.pubkey == reader) + { + snapshot.release().await?; + return Ok(Some(Vec::new())); + } + let expected = super::side_effects::group_members_tags( + &channel.to_string(), + snapshot.identity.as_ref(), + &snapshot.members, + ) + .map_err(|_| DbError::InvalidData("could not build channel roster".into()))?; + let tags = |tags: &nostr::Tags| { + let mut values = tags + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + values.sort(); + values + }; + let mut expected = expected + .iter() + .map(|tag| tag.as_slice().to_vec()) + .collect::>(); + expected.sort(); + if let Some(existing) = existing + .filter(|stored| stored.event.verify().is_ok() && tags(&stored.event.tags) == expected) + { + snapshot.release().await?; + return Ok(Some(vec![existing])); + } + let repaired = super::side_effects::store_group_members_event(state, &mut snapshot) + .await + .map_err(|_| DbError::InvalidData("could not repair channel roster".into()))?; + snapshot.release().await?; + Ok(Some(repaired.into_iter().collect())) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Keys; + use serde_json::json; + + /// Simulate committed membership removal with failed discovery publication. + /// Strong roster reads repair it from canonical rows; a removed reader + /// cannot repair or observe current private state. Repeated reads do not + /// create replacement churn, and database failure propagates. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn strong_roster_recovers_failed_projection_without_leaking_or_churning() { + let state = crate::state::tests::test_state().await; + let host = format!("gateway-roster-{}.test", uuid::Uuid::new_v4()); + let community = state + .db + .ensure_configured_community(&host) + .await + .unwrap() + .id; + let tenant = TenantContext::resolved(community, host); + let (owner, agent, gateway, stranger) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let channel = uuid::Uuid::new_v4(); + state + .db + .create_channel_with_id( + community, + channel, + "private-gateway-roster", + buzz_db::channel::ChannelType::Stream, + buzz_db::channel::ChannelVisibility::Private, + None, + owner.public_key().as_bytes(), + None, + ) + .await + .unwrap(); + for key in [&agent, &gateway] { + state + .db + .add_member( + community, + channel, + key.public_key().as_bytes(), + buzz_core::channel::MemberRole::Member, + Some(owner.public_key().as_bytes()), + ) + .await + .unwrap(); + } + let relay = state.relay_keypair.public_key(); + let filter: Filter = serde_json::from_value( + json!({"kinds":[39002], "authors":[relay], "#d":[channel], "limit":1}), + ) + .unwrap(); + let stored_query = EventQuery { + kinds: Some(vec![39002]), + authors: Some(vec![relay.to_bytes().to_vec()]), + channel_id: Some(channel), + limit: Some(1), + ..EventQuery::for_community(community) + }; + let original = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert!(buzz_sdk::gateway::channel_member( + &original.event, + relay, + channel, + gateway.public_key() + )); + // Canonical membership changes without emitting discovery metadata. + state + .db + .remove_member( + community, + channel, + gateway.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .unwrap(); + let stale = state + .db + .query_events(&stored_query) + .await + .unwrap() + .pop() + .unwrap(); + assert_eq!(stale.event.id, original.event.id); + assert!(buzz_sdk::gateway::channel_member( + &stale.event, + relay, + channel, + gateway.public_key() + )); + for reader in [&gateway, &stranger] { + assert!( + read(&state, &tenant, reader.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .is_empty() + ); + } + assert_eq!( + state.db.query_events(&stored_query).await.unwrap()[0] + .event + .id, + original.event.id, + "unauthorized reads did not repair or publish" + ); + let current = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert!(!buzz_sdk::gateway::channel_member( + ¤t.event, + relay, + channel, + gateway.public_key() + )); + assert_ne!(current.event.id, original.event.id); + let unchanged = read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .unwrap() + .unwrap() + .pop() + .unwrap(); + assert_eq!( + unchanged.event.id, current.event.id, + "current state does not churn" + ); + let foreign: Filter = serde_json::from_value( + json!({"kinds":[39002], "authors":[stranger.public_key()], "#d":[channel]}), + ) + .unwrap(); + assert!( + read(&state, &tenant, stranger.public_key().as_bytes(), &foreign) + .await + .unwrap() + .is_none() + ); + state.db.pool().close().await; + assert!( + read(&state, &tenant, agent.public_key().as_bytes(), &filter) + .await + .is_err() + ); + } +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 7e05d94251c..6e5c45cbf91 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -22,6 +22,8 @@ pub mod community_provisioning; pub mod count; #[cfg(test)] mod create_with_members_postgres_tests; +/// Canonical membership authority behind strong Nostr reads. +pub(crate) mod current_members; #[cfg(test)] mod deletion_postgres_tests; #[cfg(test)] diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index dd06620a897..0a983985e1c 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -518,9 +518,18 @@ pub(crate) async fn handle_req_consistent( |(idx, per_filter_channel, params)| { let db = db.clone(); let writer_read = writer_reads.get(idx).copied().unwrap_or(false); + let state = state.clone(); + let tenant = conn.tenant.clone(); + let reader = pubkey_bytes.clone(); + let filter = filters[idx].clone(); async move { let filter_events = if writer_read { - db.query_events(¶ms).await + match super::current_members::read(&state, &tenant, &reader, &filter).await + { + Ok(Some(events)) => Ok(events), + Ok(None) => db.query_events(¶ms).await, + Err(error) => Err(error), + } } else { db.query_events_routed("req_historical", ¶ms).await }; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 98b63eab2ef..36417de40b0 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -1579,7 +1579,7 @@ fn group_state_identity_tags( )?) } -fn group_members_tags( +pub(crate) fn group_members_tags( group_id: &str, identity: Option<&buzz_db::channel::ChannelIdentity>, members: &[MemberRecord], @@ -1598,7 +1598,7 @@ fn group_members_tags( Ok(tags) } -async fn store_group_members_event( +pub(crate) async fn store_group_members_event( state: &Arc, member_snapshot: &mut buzz_db::channel::LockedMemberSnapshot, ) -> anyhow::Result> { diff --git a/crates/buzz-sandbox-reaper/src/mentions/gateways.rs b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs index 196c17187d9..8835d6ebd6a 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/gateways.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/gateways.rs @@ -57,6 +57,34 @@ pub(super) async fn read( .map_err(|_| Error::Deadline)? } +/// Fresh channel authority for a launch. A global delegation never substitutes +/// for explicit current posting membership of owner, agent and gateway. +pub(super) async fn member( + connection: &mut NostrWsConnection, + identity: &Identity, + channel: uuid::Uuid, + gateway: nostr::PublicKey, +) -> Result { + let relay = crate::shared_tasks::relay_key(&identity.relay) + .await + .map_err(|_| Error::Relay)?; + let rosters = fetch( + connection, + "mention-gateway-channel", + json!({ + "kinds":[39002], "authors":[relay], "#d":[channel], "limit":1, + }), + 1, + ) + .await?; + let Some(roster) = rosters.first() else { + return Ok(false); + }; + Ok([identity.owner, identity.keys.public_key(), gateway] + .into_iter() + .all(|key| buzz_sdk::gateway::channel_member(roster, relay, channel, key))) +} + /// One finite REQ, read to EOSE. More than `limit` results is a relay fault. async fn fetch( connection: &mut NostrWsConnection, diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs index 5bf29fc631b..5e27302d5e0 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/gateway.rs @@ -21,6 +21,17 @@ fn delegation(enabled: bool, age: u64) -> Event { builder.unwrap().sign_with_keys(&keys("33")).unwrap() } +fn roster(gateway: bool) -> Event { + let mut tags = vec![Tag::parse(["d", CHANNEL]).unwrap()]; + for key in ["22", "33"].into_iter().chain(gateway.then_some("44")) { + tags.push(Tag::parse(["p", &keys(key).public_key().to_hex(), "", "member"]).unwrap()); + } + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(&keys("11")) + .unwrap() +} + /// Mention launch from a delegated gateway runs exactly like the owner's own /// mention; an undelegated key never launches; a revocation stored after the /// listener's snapshot is still enforced at launch time with a visible reply. @@ -59,12 +70,67 @@ async fn delegated_gateway_mentions_launch_until_revoked() { .unwrap() .accepted ); + let missing_member = signed_mention( + &keys("44"), + "@agent I have a grant but no channel membership", + ); + assert!( + gateway + .send_event(missing_member.clone()) + .await + .unwrap() + .accepted + ); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.content.contains("not a current posting member") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&missing_member.id.to_hex())) + }) + }) + .await; + assert!(cluster.lock().unwrap().sandboxes.is_empty()); + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(true)); let first = signed_mention(&keys("44"), "@agent run from the gateway"); assert!(gateway.send_event(first.clone()).await.unwrap().accepted); wait_for(|| completed(&cluster, &first)).await; assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); assert!(!completed(&cluster, &undelegated), "stranger was journaled"); + // Removing channel membership revokes launch independently of the grant. + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(false)); + let removed = signed_mention(&keys("44"), "@agent run after membership removal"); + assert!(gateway.send_event(removed.clone()).await.unwrap().accepted); + wait_for(|| { + relay.events.lock().unwrap().iter().any(|event| { + event.content.contains("not a current posting member") + && event + .tags + .iter() + .any(|tag| tag.as_slice().get(1) == Some(&removed.id.to_hex())) + }) + }) + .await; + assert_eq!(cluster.lock().unwrap().sandboxes.len(), 1); + relay + .events + .lock() + .unwrap() + .retain(|event| event.kind.as_u16() != 39002); + relay.events.lock().unwrap().push(roster(true)); + // Revoke after the listener captured its gateway snapshot. relay.events.lock().unwrap().push(delegation(false, 5)); let revoked = signed_mention(&keys("44"), "@agent run after revocation"); diff --git a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs index 9bf87b30d06..e758d161404 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/tests/relay.rs @@ -143,12 +143,30 @@ fn matches(filter: &Value, event: &Event) -> bool { } async fn connection( - socket: tokio::net::TcpStream, + mut socket: tokio::net::TcpStream, stored: Arc>>, hub: broadcast::Sender, subscriptions: watch::Sender>, lose_next_ack: Arc, ) -> Result<(), Box> { + // The authority lookup reads NIP-11 from the same authenticated community + // origin. Peek without consuming the WebSocket handshake. + let mut prefix = [0_u8; 16_384]; + for _ in 0..64 { + let n = tokio::time::timeout(std::time::Duration::from_secs(1), socket.peek(&mut prefix)) + .await??; + let text = String::from_utf8_lossy(&prefix[..n]); + if text.contains("\r\n\r\n") { + if !text.to_ascii_lowercase().contains("sec-websocket-key:") { + let body = json!({"self":keys("11").public_key()}).to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/nostr+json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + tokio::io::AsyncWriteExt::write_all(&mut socket, response.as_bytes()).await?; + return Ok(()); + } + break; + } + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } let mut ws = tokio_tungstenite::accept_async(socket).await?; let frame = |value: Value| Message::Text(value.to_string().into()); ws.send(frame(json!(["AUTH", "challenge"]))).await?; @@ -175,7 +193,7 @@ async fn connection( assert!(author.is_some()); let id = input[1].as_str().unwrap().to_string(); let filter = input[2].clone(); - if matches!(id.as_str(), "mention-gateways" | "mention-gateway-profiles") { + if matches!(id.as_str(), "mention-gateways" | "mention-gateway-profiles" | "mention-gateway-channel") { assert_eq!(filter["consistency"], json!("strong")); } let allowed = |event: &Event| matches!(event.kind.as_u16(), 0 | 24210 | 30177 | 30181) || event.tags.iter().any(|tag| tag.as_slice() == ["h", CHANNEL] || tag.as_slice() == ["d", CHANNEL]); diff --git a/crates/buzz-sandbox-reaper/src/mentions/worker.rs b/crates/buzz-sandbox-reaper/src/mentions/worker.rs index 3da63337dfd..0b6a262eef7 100644 --- a/crates/buzz-sandbox-reaper/src/mentions/worker.rs +++ b/crates/buzz-sandbox-reaper/src/mentions/worker.rs @@ -25,6 +25,7 @@ enum LaunchPermission { Allowed(Option), UnsupportedConversation, NoLongerRemote, + GatewayNotMember, } // A worker must renew its fenced journal before its longest external effect @@ -374,6 +375,9 @@ async fn drain( let result = match channel_launch_allowed(identity, &event).await { // A non-owner trigger needs a fresh live delegation unless the // manager may still admit it as a shared task. + Ok(LaunchPermission::GatewayNotMember) => { + json!({"ok": false, "code": "gateway_not_member"}) + } Ok(LaunchPermission::Allowed(None)) if event.pubkey != identity.owner && !manager.sharing_enabled() => { @@ -419,6 +423,7 @@ async fn drain( | "unsupported_conversation" | "shared_task_denied" | "gateway_not_delegated" + | "gateway_not_member" ) || pending.attempts >= MAX_ATTEMPTS { pending.notice = Some(failure_notice(identity, &event, code)?); @@ -497,6 +502,7 @@ fn failure_notice(identity: &Identity, event: &Event, code: &str) -> Result "Start this agent once from desktop to register its launch settings, then mention me again.", "unsupported_conversation" => "Automatic remote startup requires a channel thread. Mention me in a channel, or manage this conversation from desktop.", "shared_task_denied" => "Shared task refused: current sharing permission, channel membership, or handoff budget does not permit this request. Ask the agent owner to check sharing.", + "gateway_not_member" => "This gateway is not a current posting member of this channel, so I did not start. Ask the channel owner to restore membership.", "gateway_not_delegated" => "This gateway key is not currently delegated by my owner, so I did not start. Ask my owner to check `buzz gateway list`.", "shared_task_retry" => "Current sharing authorization is unavailable. Mention me again to retry; this request has not been allowed to run.", _ => "Agent startup could not be confirmed. Mention me again to retry.", @@ -535,6 +541,10 @@ async fn channel_launch_allowed( .into_iter() .find(|live| live.gateway() == trigger.pubkey) }; + if delegation.is_some() && !super::gateways::member(&mut connection, identity, + channel.parse().map_err(|_| Error::Registration)?, trigger.pubkey).await? { + return Ok(LaunchPermission::GatewayNotMember); + } send(&mut connection, json!(["REQ", "mention-trigger", {"kinds":[9,40002],"ids":[trigger.id],"#h":[channel],"limit":1}])).await?; send(&mut connection, json!(["REQ", "mention-channel", {"kinds":[39000],"#d":[channel],"limit":1}])).await?; // Query the current coordinate without a runtime filter: an old remote diff --git a/crates/buzz-sandbox-reaper/src/shared_tasks.rs b/crates/buzz-sandbox-reaper/src/shared_tasks.rs index 535e5363728..dcf80fad63b 100644 --- a/crates/buzz-sandbox-reaper/src/shared_tasks.rs +++ b/crates/buzz-sandbox-reaper/src/shared_tasks.rs @@ -52,7 +52,7 @@ pub(crate) async fn verify( .await } -async fn relay_key(relay: &str) -> Result { +pub(crate) async fn relay_key(relay: &str) -> Result { let mut url = url::Url::parse(relay).map_err(|_| policy::Error::Unavailable)?; let scheme = match url.scheme() { "wss" => "https", diff --git a/crates/buzz-sdk/src/gateway.rs b/crates/buzz-sdk/src/gateway.rs index 583b20377e3..b7bf3995e52 100644 --- a/crates/buzz-sdk/src/gateway.rs +++ b/crates/buzz-sdk/src/gateway.rs @@ -342,6 +342,38 @@ pub fn is_live_delegate( .any(|grant| grant.gateway() == signer) } +/// Whether `key` has current posting membership in a relay-signed channel +/// roster (kind39002). Callers must fetch the newest roster from the writer. +/// Missing, malformed, duplicated, guest, or foreign-channel entries deny. +pub fn channel_member( + roster: &Event, + relay: PublicKey, + channel: uuid::Uuid, + key: PublicKey, +) -> bool { + if u32::from(roster.kind.as_u16()) != crate::kind::KIND_NIP29_GROUP_MEMBERS + || roster.pubkey != relay + || roster.verify().is_err() + || single(roster, "d").ok().flatten() != Some(channel.to_string().as_str()) + { + return false; + } + let key = key.to_hex(); + let entries: Vec<_> = roster + .tags + .iter() + .map(Tag::as_slice) + .filter(|tag| tag.first().map(String::as_str) == Some("p") && tag.get(1) == Some(&key)) + .collect(); + match entries.as_slice() { + [entry] => entry + .get(3) + .and_then(|role| role.parse::().ok()) + .is_some_and(|role| role.can_post()), + _ => false, + } +} + fn lifetime_ok(created_at: u64, expires_at: u64) -> bool { expires_at > created_at && expires_at - created_at <= MAX_DELEGATION_SECS } diff --git a/crates/buzz-sdk/src/gateway/tests.rs b/crates/buzz-sdk/src/gateway/tests.rs index 5d8e2878dc6..3141998b8c9 100644 --- a/crates/buzz-sdk/src/gateway/tests.rs +++ b/crates/buzz-sdk/src/gateway/tests.rs @@ -349,3 +349,52 @@ fn signed_grant_and_delegate_verifier_bind_the_owner_signature() { build_gateway_grant(&owner, gateway.public_key(), now + MAX_DELEGATION_SECS + 60).is_err() ); } + +#[test] +fn channel_authority_requires_current_relay_signature_exact_channel_and_posting_role() { + let relay = keys("11"); + let channel = uuid::Uuid::new_v4(); + let member = gateway().public_key(); + let roster = |signer: &Keys, channel: uuid::Uuid, role: &str, duplicate: bool| { + let entry = Tag::parse(["p", &member.to_hex(), "", role]).unwrap(); + let mut tags = vec![ + Tag::parse(["d", &channel.to_string()]).unwrap(), + entry.clone(), + ]; + if duplicate { + tags.push(entry); + } + EventBuilder::new(Kind::Custom(39002), "") + .tags(tags) + .sign_with_keys(signer) + .unwrap() + }; + for (event, expected) in [ + (roster(&relay, channel, "member", false), true), + (roster(&relay, channel, "admin", false), true), + (roster(&relay, channel, "guest", false), false), + (roster(&relay, channel, "unknown", false), false), + (roster(&relay, channel, "member", true), false), + (roster(&relay, uuid::Uuid::new_v4(), "member", false), false), + (roster(&keys("22"), channel, "member", false), false), + ] { + assert_eq!( + channel_member(&event, relay.public_key(), channel, member), + expected + ); + assert!(!channel_member( + &event, + relay.public_key(), + channel, + keys("55").public_key() + )); + } + let mut tampered = roster(&relay, channel, "member", false); + tampered.content = "forged".into(); + assert!(!channel_member( + &tampered, + relay.public_key(), + channel, + member + )); +} diff --git a/docs/agent-gateway-key.md b/docs/agent-gateway-key.md index 0580bfd94e6..ff86480f3f8 100644 --- a/docs/agent-gateway-key.md +++ b/docs/agent-gateway-key.md @@ -91,6 +91,19 @@ Revocation uses a newer disabled replacement, without expiration. The relay ignores NIP-09 deletion of gateway heads. Deleting a head could otherwise let an old signed grant be replayed into an empty coordinate. Use `gateway revoke`. +Gateway authority also requires current posting membership in the bound channel. +The harness strongly reads the latest kind39002 roster signed by the current +NIP-11 relay key. The owner, agent, and gateway must all remain posting members. +A global grant never admits a gateway into a private channel. Membership removal +stops gateway prompts, cancellation, and telemetry after the 30-second cache +window. The launcher rechecks membership immediately before each launch. + +For an explicit strong kind39002 lookup with the current relay author and one channel +coordinate, the relay checks canonical writer membership behind its existing +membership lock. A stale discovery projection is repaired before returning it. +Only a current channel member can trigger or receive that repair. Unchanged +rosters keep their event ID; this read path never broadcasts metadata. + Authority reads use `"consistency":"strong"` on HTTP queries and WebSocket REQ filters. The relay pins these reads to the writer, so a lagging replica cannot renew stale authority or hide the current revocation head. From c9e40df9bfbea18c60ae37f1915681528ba5031f Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:25:48 -0500 Subject: [PATCH 4/6] fix(relay): preserve constrained roster query semantics Signed-off-by: Tee8z --- .../src/handlers/current_members.rs | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/buzz-relay/src/handlers/current_members.rs b/crates/buzz-relay/src/handlers/current_members.rs index c2372761120..0498987eb3e 100644 --- a/crates/buzz-relay/src/handlers/current_members.rs +++ b/crates/buzz-relay/src/handlers/current_members.rs @@ -33,6 +33,7 @@ pub(crate) async fn read( || filter.since.is_some() || filter.until.is_some() || filter.search.is_some() + || filter.generic_tags.len() != 1 { return Ok(None); } @@ -185,6 +186,28 @@ mod tests { limit: Some(1), ..EventQuery::for_community(community) }; + // Extra generic constraints retain ordinary filter semantics and must + // never be answered as an unconstrained current authority query. + let constrained: Filter = serde_json::from_value(json!({ + "kinds":[39002], "authors":[relay], "#d":[channel], + "#p":[stranger.public_key()], "limit":1 + })) + .unwrap(); + assert!( + read(&state, &tenant, agent.public_key().as_bytes(), &constrained) + .await + .unwrap() + .is_none() + ); + assert!( + state + .db + .query_events(&stored_query) + .await + .unwrap() + .is_empty(), + "an ordinary constrained query must not repair the projection" + ); let original = read(&state, &tenant, agent.public_key().as_bytes(), &filter) .await .unwrap() From 1b0150c8fceccbe7d0de6892d837e8f3b292e844 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:35:58 -0500 Subject: [PATCH 5/6] test(relay): register roster regression in Postgres lane Signed-off-by: Tee8z --- crates/buzz-relay/src/handlers/current_members.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/buzz-relay/src/handlers/current_members.rs b/crates/buzz-relay/src/handlers/current_members.rs index 0498987eb3e..fa1011d9366 100644 --- a/crates/buzz-relay/src/handlers/current_members.rs +++ b/crates/buzz-relay/src/handlers/current_members.rs @@ -119,7 +119,7 @@ pub(crate) async fn read( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::Keys; use serde_json::json; From 2af27c5daf0533c2422423cd83557fdbe163f96d Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 16:19:46 -0500 Subject: [PATCH 6/6] test(relay): seed gateway denial in observer cache fixture Signed-off-by: Tee8z --- crates/buzz-relay/src/handlers/event.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a3af129d3c1..d8452f57303 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1884,6 +1884,18 @@ mod tests { (community_b, agent_bytes.clone(), owner_bytes.clone()), false, ); + // A failed owner check now tries gateway authority too. Keep this + // no-database test on the production cached-denial path in B. + state.observer_gateway_cache.insert( + (community_b, agent_bytes.clone(), owner_bytes.clone()), + super::GatewayVerdict { + authorized: false, + generation: state + .gateway_generation + .load(std::sync::atomic::Ordering::Acquire), + valid_until: u64::MAX, + }, + ); let encrypted = encrypt_observer_payload( &agent,